Swiftgram/submodules/TelegramCallsUI/Sources/CallControllerNode.swift
2019-10-11 16:05:35 +04:00

546 lines
24 KiB
Swift

import Foundation
import UIKit
import Display
import AsyncDisplayKit
import Postbox
import TelegramCore
import SwiftSignalKit
import TelegramPresentationData
import TelegramUIPreferences
import TelegramAudio
import AccountContext
import LocalizedPeerData
import PhotoResources
final class CallControllerNode: ASDisplayNode {
private let sharedContext: SharedAccountContext
private let account: Account
private let statusBar: StatusBar
private var presentationData: PresentationData
private var peer: Peer?
private let debugInfo: Signal<(String, String), NoError>
private var forceReportRating = false
private let easyDebugAccess: Bool
private let containerNode: ASDisplayNode
private let imageNode: TransformImageNode
private let dimNode: ASDisplayNode
private let backButtonArrowNode: ASImageNode
private let backButtonNode: HighlightableButtonNode
private let statusNode: CallControllerStatusNode
private let buttonsNode: CallControllerButtonsNode
private var keyPreviewNode: CallControllerKeyPreviewNode?
private var debugNode: CallDebugNode?
private var keyTextData: (Data, String)?
private let keyButtonNode: HighlightableButtonNode
private var validLayout: (ContainerViewLayout, CGFloat)?
var isMuted: Bool = false {
didSet {
self.buttonsNode.isMuted = self.isMuted
}
}
private var shouldStayHiddenUntilConnection: Bool = false
private var audioOutputState: ([AudioSessionOutput], currentOutput: AudioSessionOutput?)?
private var callState: PresentationCallState?
var toggleMute: (() -> Void)?
var setCurrentAudioOutput: ((AudioSessionOutput) -> Void)?
var beginAudioOuputSelection: (() -> Void)?
var acceptCall: (() -> Void)?
var endCall: (() -> Void)?
var back: (() -> Void)?
var presentCallRating: ((CallId) -> Void)?
var callEnded: ((Bool) -> Void)?
var dismissedInteractively: (() -> Void)?
init(sharedContext: SharedAccountContext, account: Account, presentationData: PresentationData, statusBar: StatusBar, debugInfo: Signal<(String, String), NoError>, shouldStayHiddenUntilConnection: Bool = false, easyDebugAccess: Bool) {
self.sharedContext = sharedContext
self.account = account
self.presentationData = presentationData
self.statusBar = statusBar
self.debugInfo = debugInfo
self.shouldStayHiddenUntilConnection = shouldStayHiddenUntilConnection
self.easyDebugAccess = easyDebugAccess
self.containerNode = ASDisplayNode()
if self.shouldStayHiddenUntilConnection {
self.containerNode.alpha = 0.0
}
self.imageNode = TransformImageNode()
self.imageNode.contentAnimations = [.subsequentUpdates]
self.dimNode = ASDisplayNode()
self.dimNode.isLayerBacked = true
self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.4)
self.backButtonArrowNode = ASImageNode()
self.backButtonArrowNode.displayWithoutProcessing = true
self.backButtonArrowNode.displaysAsynchronously = false
self.backButtonArrowNode.image = NavigationBarTheme.generateBackArrowImage(color: .white)
self.backButtonNode = HighlightableButtonNode()
self.statusNode = CallControllerStatusNode()
self.buttonsNode = CallControllerButtonsNode(strings: self.presentationData.strings)
self.keyButtonNode = HighlightableButtonNode()
super.init()
self.setViewBlock({
return UITracingLayerView()
})
self.containerNode.backgroundColor = .black
self.addSubnode(self.containerNode)
self.backButtonNode.setTitle(presentationData.strings.Common_Back, with: Font.regular(17.0), with: .white, for: [])
self.backButtonNode.hitTestSlop = UIEdgeInsets(top: -8.0, left: -20.0, bottom: -8.0, right: -8.0)
self.backButtonNode.highligthedChanged = { [weak self] highlighted in
if let strongSelf = self {
if highlighted {
strongSelf.backButtonNode.layer.removeAnimation(forKey: "opacity")
strongSelf.backButtonArrowNode.layer.removeAnimation(forKey: "opacity")
strongSelf.backButtonNode.alpha = 0.4
strongSelf.backButtonArrowNode.alpha = 0.4
} else {
strongSelf.backButtonNode.alpha = 1.0
strongSelf.backButtonArrowNode.alpha = 1.0
strongSelf.backButtonNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
strongSelf.backButtonArrowNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2)
}
}
}
self.containerNode.addSubnode(self.imageNode)
self.containerNode.addSubnode(self.dimNode)
self.containerNode.addSubnode(self.statusNode)
self.containerNode.addSubnode(self.buttonsNode)
self.containerNode.addSubnode(self.keyButtonNode)
self.containerNode.addSubnode(self.backButtonArrowNode)
self.containerNode.addSubnode(self.backButtonNode)
self.buttonsNode.mute = { [weak self] in
self?.toggleMute?()
}
self.buttonsNode.speaker = { [weak self] in
self?.beginAudioOuputSelection?()
}
self.buttonsNode.end = { [weak self] in
self?.endCall?()
}
self.buttonsNode.accept = { [weak self] in
self?.acceptCall?()
}
self.keyButtonNode.addTarget(self, action: #selector(self.keyPressed), forControlEvents: .touchUpInside)
self.backButtonNode.addTarget(self, action: #selector(self.backPressed), forControlEvents: .touchUpInside)
}
override func didLoad() {
super.didLoad()
let panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:)))
self.view.addGestureRecognizer(panRecognizer)
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))
self.view.addGestureRecognizer(tapRecognizer)
}
func updatePeer(accountPeer: Peer, peer: Peer, hasOther: Bool) {
if !arePeersEqual(self.peer, peer) {
self.peer = peer
if let peerReference = PeerReference(peer), !peer.profileImageRepresentations.isEmpty {
let representations: [ImageRepresentationWithReference] = peer.profileImageRepresentations.map({ ImageRepresentationWithReference(representation: $0, reference: .avatar(peer: peerReference, resource: $0.resource)) })
self.imageNode.setSignal(chatAvatarGalleryPhoto(account: self.account, representations: representations, autoFetchFullSize: true))
self.dimNode.isHidden = false
} else {
self.imageNode.setSignal(callDefaultBackground())
self.dimNode.isHidden = true
}
self.statusNode.title = peer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)
if hasOther {
self.statusNode.subtitle = self.presentationData.strings.Call_AnsweringWithAccount(accountPeer.displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder)).0
if let callState = 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()
}
}
func updateCallState(_ callState: PresentationCallState) {
self.callState = callState
let statusValue: CallControllerStatusValue
var statusReception: Int32?
switch callState {
case .waiting, .connecting:
statusValue = .text(self.presentationData.strings.Call_StatusConnecting)
case let .requesting(ringing):
if ringing {
statusValue = .text(self.presentationData.strings.Call_StatusRinging)
} else {
statusValue = .text(self.presentationData.strings.Call_StatusRequesting)
}
case .terminating:
statusValue = .text(self.presentationData.strings.Call_StatusEnded)
case let .terminated(_, reason, _):
if let reason = reason {
switch reason {
case let .ended(type):
switch type {
case .busy:
statusValue = .text(self.presentationData.strings.Call_StatusBusy)
case .hungUp, .missed:
statusValue = .text(self.presentationData.strings.Call_StatusEnded)
}
case .error:
statusValue = .text(self.presentationData.strings.Call_StatusFailed)
}
} else {
statusValue = .text(self.presentationData.strings.Call_StatusEnded)
}
case .ringing:
var text = self.presentationData.strings.Call_StatusIncoming
if !self.statusNode.subtitle.isEmpty {
text += "\n\(self.statusNode.subtitle)"
}
statusValue = .text(text)
case let .active(timestamp, reception, keyVisualHash):
let strings = self.presentationData.strings
statusValue = .timer({ value in
return strings.Call_StatusOngoing(value).0
}, timestamp)
if self.keyTextData?.0 != keyVisualHash {
let text = stringForEmojiHashOfData(keyVisualHash, 4)!
self.keyTextData = (keyVisualHash, text)
self.keyButtonNode.setAttributedTitle(NSAttributedString(string: text, attributes: [NSAttributedString.Key.font: Font.regular(22.0), NSAttributedString.Key.kern: 2.5 as NSNumber]), for: [])
let keyTextSize = self.keyButtonNode.measure(CGSize(width: 200.0, height: 200.0))
self.keyButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
self.keyButtonNode.frame = CGRect(origin: self.keyButtonNode.frame.origin, size: keyTextSize)
if let (layout, navigationBarHeight) = self.validLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
}
statusReception = reception
}
switch callState {
case .terminated, .terminating:
if !self.statusNode.alpha.isEqual(to: 0.5) {
self.statusNode.alpha = 0.5
self.buttonsNode.alpha = 0.5
self.keyButtonNode.alpha = 0.5
self.backButtonArrowNode.alpha = 0.5
self.backButtonNode.alpha = 0.5
self.statusNode.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.25)
self.buttonsNode.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.25)
self.keyButtonNode.layer.animateAlpha(from: 1.0, to: 0.5, duration: 0.25)
}
default:
if !self.statusNode.alpha.isEqual(to: 1.0) {
self.statusNode.alpha = 1.0
self.buttonsNode.alpha = 1.0
self.keyButtonNode.alpha = 1.0
self.backButtonArrowNode.alpha = 1.0
self.backButtonNode.alpha = 1.0
}
}
if self.shouldStayHiddenUntilConnection {
switch callState {
case .connecting, .active:
self.containerNode.alpha = 1.0
default:
break
}
}
self.statusNode.status = statusValue
self.statusNode.reception = statusReception
self.updateButtonsMode()
if case let .terminated(id, _, reportRating) = callState, let callId = id {
let presentRating = reportRating || self.forceReportRating
if presentRating {
self.presentCallRating?(callId)
}
self.callEnded?(presentRating)
}
}
private func updateButtonsMode() {
guard let callState = self.callState else {
return
}
switch callState {
case .ringing:
self.buttonsNode.updateMode(.incoming)
default:
var mode: CallControllerButtonsSpeakerMode = .none
if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput {
switch currentOutput {
case .builtin:
mode = .builtin
case .speaker:
mode = .speaker
case .headphones:
mode = .headphones
case .port:
mode = .bluetooth
}
if availableOutputs.count <= 1 {
mode = .none
}
}
self.buttonsNode.updateMode(.active(mode))
}
}
func animateIn() {
var bounds = self.bounds
bounds.origin = CGPoint()
self.bounds = bounds
self.layer.removeAnimation(forKey: "bounds")
self.statusBar.layer.removeAnimation(forKey: "opacity")
self.containerNode.layer.removeAnimation(forKey: "opacity")
self.containerNode.layer.removeAnimation(forKey: "scale")
self.statusBar.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
if !self.shouldStayHiddenUntilConnection {
self.containerNode.layer.animateScale(from: 1.04, to: 1.0, duration: 0.3)
self.containerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
}
func animateOut(completion: @escaping () -> Void) {
self.statusBar.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
if !self.shouldStayHiddenUntilConnection || self.containerNode.alpha > 0.0 {
self.containerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false)
self.containerNode.layer.animateScale(from: 1.0, to: 1.04, duration: 0.3, removeOnCompletion: false, completion: { _ in
completion()
})
} else {
completion()
}
}
func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
self.validLayout = (layout, navigationBarHeight)
transition.updateFrame(node: self.containerNode, frame: CGRect(origin: CGPoint(), size: layout.size))
transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size))
if let keyPreviewNode = self.keyPreviewNode {
transition.updateFrame(node: keyPreviewNode, frame: CGRect(origin: CGPoint(), size: layout.size))
keyPreviewNode.updateLayout(size: layout.size, transition: .immediate)
}
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: layout.size))
let arguments = TransformImageArguments(corners: ImageCorners(), imageSize: CGSize(width: 640.0, height: 640.0).aspectFilled(layout.size), boundingSize: layout.size, intrinsicInsets: UIEdgeInsets())
let apply = self.imageNode.asyncLayout()(arguments)
apply()
let navigationOffset: CGFloat = max(20.0, layout.safeInsets.top)
let 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: navigationOffset + 11.0), size: image.size))
}
transition.updateFrame(node: self.backButtonNode, frame: CGRect(origin: CGPoint(x: 29.0, y: navigationOffset + 11.0), size: backSize))
var statusOffset: CGFloat
if layout.metrics.widthClass == .regular && layout.metrics.heightClass == .regular {
if layout.size.height.isEqual(to: 1366.0) {
statusOffset = 160.0
} else {
statusOffset = 120.0
}
} else {
if layout.size.height.isEqual(to: 736.0) {
statusOffset = 80.0
} else if layout.size.width.isEqual(to: 320.0) {
statusOffset = 60.0
} else {
statusOffset = 64.0
}
}
statusOffset += layout.safeInsets.top
let buttonsHeight: CGFloat = 75.0
let buttonsOffset: CGFloat
if layout.size.width.isEqual(to: 320.0) {
if layout.size.height.isEqual(to: 480.0) {
buttonsOffset = 60.0
} else {
buttonsOffset = 73.0
}
} else {
buttonsOffset = 83.0
}
let statusHeight = self.statusNode.updateLayout(constrainedWidth: layout.size.width, transition: transition)
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(x: 0.0, y: statusOffset), size: CGSize(width: layout.size.width, height: statusHeight)))
self.buttonsNode.updateLayout(constrainedWidth: layout.size.width, transition: transition)
transition.updateFrame(node: self.buttonsNode, frame: CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - (buttonsOffset - 40.0) - buttonsHeight - layout.intrinsicInsets.bottom), size: CGSize(width: layout.size.width, height: buttonsHeight)))
let keyTextSize = self.keyButtonNode.frame.size
transition.updateFrame(node: self.keyButtonNode, frame: CGRect(origin: CGPoint(x: layout.size.width - keyTextSize.width - 8.0, y: navigationOffset + 8.0), size: keyTextSize))
if let debugNode = self.debugNode {
transition.updateFrame(node: debugNode, frame: CGRect(origin: CGPoint(), size: layout.size))
}
}
@objc func keyPressed() {
if self.keyPreviewNode == nil, let keyText = self.keyTextData?.1, let peer = self.peer {
let keyPreviewNode = CallControllerKeyPreviewNode(keyText: keyText, infoText: self.presentationData.strings.Call_EmojiDescription(peer.compactDisplayTitle).0.replacingOccurrences(of: "%%", with: "%"), dismiss: { [weak self] in
if let _ = self?.keyPreviewNode {
self?.backPressed()
}
})
self.containerNode.insertSubnode(keyPreviewNode, aboveSubnode: self.dimNode)
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)
}
}
}
@objc func backPressed() {
if let keyPreviewNode = self.keyPreviewNode {
self.keyPreviewNode = nil
keyPreviewNode.animateOut(to: self.keyButtonNode.frame, toNode: self.keyButtonNode, completion: { [weak self, weak keyPreviewNode] in
self?.keyButtonNode.isHidden = false
keyPreviewNode?.removeFromSupernode()
})
} else {
self.back?()
}
}
private var debugTapCounter: (Double, Int) = (0.0, 0)
@objc func tapGesture(_ recognizer: UITapGestureRecognizer) {
if case .ended = recognizer.state {
if let _ = self.keyPreviewNode {
self.backPressed()
} else {
let point = recognizer.location(in: recognizer.view)
if self.statusNode.frame.contains(point) {
if self.easyDebugAccess {
self.presentDebugNode()
} else {
let timestamp = CACurrentMediaTime()
if self.debugTapCounter.0 < timestamp - 0.75 {
self.debugTapCounter.0 = timestamp
self.debugTapCounter.1 = 0
}
if self.debugTapCounter.0 >= timestamp - 0.75 {
self.debugTapCounter.0 = timestamp
self.debugTapCounter.1 += 1
}
if self.debugTapCounter.1 >= 10 {
self.debugTapCounter.1 = 0
self.presentDebugNode()
}
}
}
}
}
}
private func presentDebugNode() {
guard self.debugNode == nil else {
return
}
self.forceReportRating = true
let debugNode = CallDebugNode(signal: self.debugInfo)
debugNode.dismiss = { [weak self] in
if let strongSelf = self {
strongSelf.debugNode?.removeFromSupernode()
strongSelf.debugNode = nil
}
}
self.addSubnode(debugNode)
self.debugNode = debugNode
if let (layout, navigationBarHeight) = self.validLayout {
self.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: .immediate)
}
}
@objc func panGesture(_ recognizer: UIPanGestureRecognizer) {
switch recognizer.state {
case .changed:
let offset = recognizer.translation(in: self.view).y
var bounds = self.bounds
bounds.origin.y = -offset
self.bounds = bounds
case .ended:
let velocity = recognizer.velocity(in: self.view).y
if abs(velocity) < 100.0 {
var bounds = self.bounds
let previous = bounds
bounds.origin = CGPoint()
self.bounds = bounds
self.layer.animateBounds(from: previous, to: bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring)
} else {
var bounds = self.bounds
let previous = bounds
bounds.origin = CGPoint(x: 0.0, y: velocity > 0.0 ? -bounds.height: bounds.height)
self.bounds = bounds
self.layer.animateBounds(from: previous, to: bounds, duration: 0.15, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, completion: { [weak self] _ in
self?.dismissedInteractively?()
})
}
case .cancelled:
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)
default:
break
}
}
}