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 import CallsEmoji import TooltipUI import AlertUI import PresentationDataUtils import DeviceAccess import ContextUI import AppBundle 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 } final class CallVideoNode: ASDisplayNode, PreviewVideoNode { private var placeholderImageNode: ASImageNode? private let videoTransformContainer: ASDisplayNode private let videoView: PresentationCallVideoView private var effectView: UIVisualEffectView? private let videoPausedNode: ImmediateTextNode private var isBlurred: Bool = false private var currentCornerRadius: CGFloat = 0.0 private let isReadyUpdated: () -> Void private(set) var isReady: Bool = false private var isReadyTimer: SwiftSignalKit.Timer? private let readyPromise = ValuePromise(false) var ready: Signal { return self.readyPromise.get() } private let isFlippedUpdated: (CallVideoNode) -> Void private(set) var currentOrientation: PresentationCallVideoView.Orientation private(set) var currentAspect: CGFloat = 0.0 private var previousVideoHeight: CGFloat? init(videoView: PresentationCallVideoView, displayPlaceholderUntilReady: Bool = false, disabledText: String?, assumeReadyAfterTimeout: Bool, isReadyUpdated: @escaping () -> Void, orientationUpdated: @escaping () -> Void, isFlippedUpdated: @escaping (CallVideoNode) -> Void) { self.isReadyUpdated = isReadyUpdated self.isFlippedUpdated = isFlippedUpdated self.videoTransformContainer = ASDisplayNode() self.videoView = videoView videoView.view.clipsToBounds = true videoView.view.backgroundColor = .black self.currentOrientation = videoView.getOrientation() self.currentAspect = videoView.getAspect() self.videoPausedNode = ImmediateTextNode() self.videoPausedNode.alpha = 0.0 self.videoPausedNode.maximumNumberOfLines = 3 super.init() self.backgroundColor = .black self.clipsToBounds = true if #available(iOS 13.0, *) { self.layer.cornerCurve = .continuous } self.videoTransformContainer.view.addSubview(self.videoView.view) self.addSubnode(self.videoTransformContainer) if displayPlaceholderUntilReady { let placeholderImageNode = ASImageNode() placeholderImageNode.image = UIImage(bundleImageName: "Camera/SelfiePlaceholder") self.placeholderImageNode = placeholderImageNode self.addSubnode(placeholderImageNode) } 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() if let placeholderImageNode = strongSelf.placeholderImageNode { strongSelf.placeholderImageNode = nil placeholderImageNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak placeholderImageNode] _ in placeholderImageNode?.removeFromSupernode() }) } } } } 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() } deinit { self.isReadyTimer?.invalidate() } override func didLoad() { super.didLoad() if #available(iOS 13.0, *) { self.layer.cornerCurve = .continuous } } func animateRadialMask(from fromRect: CGRect, to toRect: CGRect) { let maskLayer = CAShapeLayer() maskLayer.frame = fromRect let path = CGMutablePath() path.addEllipse(in: CGRect(origin: CGPoint(), size: fromRect.size)) maskLayer.path = path self.layer.mask = maskLayer let topLeft = CGPoint(x: 0.0, y: 0.0) let topRight = CGPoint(x: self.bounds.width, y: 0.0) let bottomLeft = CGPoint(x: 0.0, y: self.bounds.height) let bottomRight = CGPoint(x: self.bounds.width, y: self.bounds.height) func distance(_ v1: CGPoint, _ v2: CGPoint) -> CGFloat { let dx = v1.x - v2.x let dy = v1.y - v2.y return sqrt(dx * dx + dy * dy) } var maxRadius = distance(toRect.center, topLeft) maxRadius = max(maxRadius, distance(toRect.center, topRight)) maxRadius = max(maxRadius, distance(toRect.center, bottomLeft)) maxRadius = max(maxRadius, distance(toRect.center, bottomRight)) maxRadius = ceil(maxRadius) let targetFrame = CGRect(origin: CGPoint(x: toRect.center.x - maxRadius, y: toRect.center.y - maxRadius), size: CGSize(width: maxRadius * 2.0, height: maxRadius * 2.0)) let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .easeInOut) transition.updatePosition(layer: maskLayer, position: targetFrame.center) transition.updateTransformScale(layer: maskLayer, scale: maxRadius * 2.0 / fromRect.width, completion: { [weak self] _ in self?.layer.mask = nil }) } func updateLayout(size: CGSize, 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 if let placeholderImageNode = self.placeholderImageNode, let image = placeholderImageNode.image { let placeholderSize = image.size.aspectFilled(size) transition.updateFrame(node: placeholderImageNode, frame: CGRect(origin: CGPoint(x: (size.width - placeholderSize.width) * 0.5, y: (size.height - placeholderSize.height) * 0.5), size: placeholderSize)) } 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(node: self.videoTransformContainer, from: scaleDifference, additive: true) } } self.previousVideoHeight = rotatedVideoHeight transition.updatePosition(node: self.videoTransformContainer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) transition.updateTransformRotation(view: self.videoTransformContainer.view, angle: rotationAngle) let localVideoFrame = CGRect(origin: CGPoint(), size: videoFrame.size) self.videoView.view.bounds = localVideoFrame self.videoView.view.center = localVideoFrame.center // TODO: properly fix the issue // On iOS 13 and later metal layer transformation is broken if the layer does not require compositing self.videoView.view.alpha = 0.995 if let effectView = self.effectView { transition.updateFrame(view: effectView, frame: localVideoFrame) } transition.updateCornerRadius(layer: self.layer, cornerRadius: self.currentCornerRadius) } func updateIsBlurred(isBlurred: Bool, light: Bool = false, animated: Bool = true) { if self.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.view.addSubview(effectView) } if animated { UIView.animate(withDuration: 0.3, animations: { self.videoPausedNode.alpha = 1.0 self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark) }) } else { self.effectView?.effect = UIBlurEffect(style: light ? .light : .dark) } } else if let effectView = self.effectView { self.effectView = nil UIView.animate(withDuration: 0.3, animations: { self.videoPausedNode.alpha = 0.0 effectView.effect = nil }, completion: { [weak effectView] _ in effectView?.removeFromSuperview() }) } } private var hasScheduledUnblur = false func flip(withBackground: Bool) { if withBackground { self.backgroundColor = .black } UIView.transition(with: withBackground ? self.videoTransformContainer.view : self.view, duration: 0.4, options: [.transitionFlipFromLeft, .curveEaseOut], animations: { UIView.performWithoutAnimation { self.updateIsBlurred(isBlurred: true, light: false, animated: false) } }) { finished in self.backgroundColor = nil self.hasScheduledUnblur = true Queue.mainQueue().after(0.5) { if self.hasScheduledUnblur { self.updateIsBlurred(isBlurred: false) } } } } }