// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient import SignalRingRTC import SignalServiceKit import SignalUI enum CallMemberVisualContext: Equatable { case videoGrid, videoOverflow, speaker } protocol CallMemberComposableView: UIView { func configure( call: SignalCall, isFullScreen: Bool, remoteGroupMemberDeviceState: RemoteDeviceState? ) func rotateForPhoneOrientation(_ rotationAngle: CGFloat) func updateDimensions() func clearConfiguration() } class CallMemberView: UIView { private let callMemberCameraOffView: CallMemberCameraOffView private let callMemberWaitingAndErrorView: CallMemberWaitingAndErrorView private let callMemberChromeOverlayView: CallMemberChromeOverlayView /// This view is "associated" because it is not actually part of `CallMemberView`'s /// view hierarchy. The original intent was to have this view be a subview, just like /// any other `CallMemberComposableView`. Unfortunately, it was very difficult get /// this view's layer to animate (see `animatePip` method) properly with this setup. /// So instead, the view that instantiates a `CallMemberView` also instantiates /// `CallMemberVideoView` and arranges the two as siblings in the view hierarchy. In /// somewhat of an architectural hack, `CallMemberView` still manages updates an /// animations of `_associatedCallMemberVideoView`. private let _associatedCallMemberVideoView: CallMemberVideoView private var composableViews = [CallMemberComposableView]() private let type: MemberType private var call: SignalCall? // Properties relating to local member pip expansions/contractions. private var shouldAllowTapHandling: Bool? private var isPipAnimationInProgress = false private var isPipExpanded: Bool = false weak var animatableLocalMemberViewDelegate: AnimatableLocalMemberViewDelegate? init(type: MemberType) { self.type = type self.callMemberCameraOffView = CallMemberCameraOffView(type: type) self.callMemberWaitingAndErrorView = CallMemberWaitingAndErrorView(type: type) self.callMemberChromeOverlayView = CallMemberChromeOverlayView(type: type) self._associatedCallMemberVideoView = CallMemberVideoView(type: type) super.init(frame: .zero) self.backgroundColor = .clear let orderedComposableViews: [CallMemberComposableView] switch type { case .local, .remoteInIndividual: orderedComposableViews = [ callMemberCameraOffView, callMemberChromeOverlayView ] case .remoteInGroup: orderedComposableViews = [ callMemberCameraOffView, callMemberWaitingAndErrorView, callMemberChromeOverlayView ] } let tapGestureRecognizer = UITapGestureRecognizer( target: self, action: #selector(callMemberViewWasTapped) ) self.addGestureRecognizer(tapGestureRecognizer) clipsToBounds = true orderedComposableViews.forEach { view in self.addSubview(view) view.autoPinEdgesToSuperviewEdges() } self.composableViews = orderedComposableViews + [self._associatedCallMemberVideoView] NotificationCenter.default.addObserver( self, selector: #selector(updateOrientationForPhone), name: CallService.phoneOrientationDidChange, object: nil ) } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { let view = super.hitTest(point, with: event) if view == self { switch self.type { case .remoteInGroup(_), .remoteInIndividual(_): return nil case .local: if self.shouldAllowTapHandling == true { return view } return nil } } return view } override func layoutSubviews() { super.layoutSubviews() updateDimensions() } @objc private func updateOrientationForPhone(_ notification: Notification) { let rotationAngle = notification.object as! CGFloat if window == nil { self.composableViews.forEach { view in view.rotateForPhoneOrientation(rotationAngle) } } else { UIView.animate(withDuration: 0.3) { self.composableViews.forEach { view in view.rotateForPhoneOrientation(rotationAngle) } } } } enum MemberType { case local case remoteInGroup(CallMemberVisualContext) case remoteInIndividual(IndividualCall) } func configure( call: SignalCall, isFullScreen: Bool = false, remoteGroupMemberDeviceState: RemoteDeviceState? = nil ) { self.call = call self.shouldAllowTapHandling = !isFullScreen switch self.type { case .local: owsAssertDebug(remoteGroupMemberDeviceState == nil, "RemoteDeviceStates are only applicable to remote members in group calls!") layer.shadowOffset = .zero layer.shadowOpacity = 0.25 layer.shadowRadius = 4 layer.cornerRadius = isFullScreen ? 0 : Constants.defaultPipCornerRadius case .remoteInGroup: owsAssertDebug(remoteGroupMemberDeviceState != nil, "RemoteDeviceState must be given for remote members in group calls!") case .remoteInIndividual: owsAssertDebug(remoteGroupMemberDeviceState == nil, "RemoteDeviceStates are only applicable to group calls!") } self.composableViews.forEach { view in view.configure( call: call, isFullScreen: isFullScreen, remoteGroupMemberDeviceState: remoteGroupMemberDeviceState ) } } private func updateDimensions() { self.composableViews.forEach { view in view.updateDimensions() } } func clearConfiguration() { self.composableViews.forEach { view in view.clearConfiguration() } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } var associatedCallMemberVideoView: CallMemberVideoView? { return self._associatedCallMemberVideoView } func cleanupVideoViews() { self._associatedCallMemberVideoView.clearConfiguration() } func configureRemoteVideo(device: RemoteDeviceState, context: CallMemberVisualContext) { self._associatedCallMemberVideoView.configureRemoteVideo( device: device, context: context ) } /// Applies the changes in the `apply` block to both the `CallMemberView` and /// its `associatedCallMemberVideoView`. (See documentation of the latter to /// understand why it is not simply a subview of the former. The tl;dr is: /// pip animations were finicky and much easier to get right with these views /// as siblings.) Since many layout changes made to `CallMemberView` also need /// to be made to `associatedCallMemberVideoView`, this method acts a convenience /// wrapper to apply the changes to both at once. /// /// - Parameter startWithVideoView: Whether the `apply` block should be applied first /// to the `associatedCallMemberVideoView` and then to the `CallMemberView`. For /// example, the order matters when `apply` includes adding subviews. Generally, we /// want the video view to sit underneath the `CallMemberView`. /// - Parameter apply: The block that will be applied to each UIView - the `CallMemberView` /// and the `associatedCallMemberVideoView`. func applyChangesToCallMemberViewAndVideoView(startWithVideoView: Bool = true, apply: (UIView) -> Void) { if startWithVideoView { apply(self._associatedCallMemberVideoView) apply(self) } else { apply(self) apply(self._associatedCallMemberVideoView) } } var isCallMinimized: Bool { get { self.callMemberWaitingAndErrorView.isCallMinimized } set { self.callMemberWaitingAndErrorView.isCallMinimized = newValue } } weak var errorPresenter: CallMemberErrorPresenter? { get { self.callMemberWaitingAndErrorView.errorPresenter } set { self.callMemberWaitingAndErrorView.errorPresenter = newValue } } var isGroupCall: Bool { get { if let remoteVideoView { return remoteVideoView.isGroupCall } return false } set { if let remoteVideoView { remoteVideoView.isGroupCall = newValue } } } var isScreenShare: Bool { get { if let remoteVideoView { return remoteVideoView.isScreenShare } return false } set { if let remoteVideoView { remoteVideoView.isScreenShare = newValue } } } var isFullScreen: Bool { get { if let remoteVideoView { return remoteVideoView.isFullScreen } return false } set { if let remoteVideoView { remoteVideoView.isFullScreen = newValue } } } var remoteVideoView: RemoteVideoView? { if let remoteVideoView = self._associatedCallMemberVideoView.remoteVideoViewIfApplicable() { return remoteVideoView } return nil } } // MARK: - Local View Gesture Handling extension CallMemberView: UIGestureRecognizerDelegate { private func callMemberVideoViewAnchorPoint(for nearestCorner: Corner) -> CGPoint { switch nearestCorner { case .upperLeft: return CGPoint(x: 0, y: 0) case .upperRight: return CGPoint(x: 1, y: 0) case .lowerLeft: return CGPoint(x: 0, y: 1) case .lowerRight: return CGPoint(x: 1, y: 1) } } private func animatePip(fromFrame: CGRect, isExpanding: Bool) { guard shouldAllowTapHandling == true, !isPipAnimationInProgress else { return } let nearestCorner = nearestCorner(innerFrame: self.frame, memberType: .local) let toFrame: CGRect if isExpanding { guard let call, // The design concept is that the pip expands, thereby // enabling the flip camera button. So if the camera // is off, there's no need for the expansion. !call.isOutgoingVideoMuted else { return } toFrame = self.enlargedFrame( startingFrame: fromFrame, anchorCorner: nearestCorner ) } else { toFrame = self.shrunkenFrame( currentFrame: fromFrame, anchorCorner: nearestCorner ) } guard fromFrame.size != toFrame.size else { // An optimization. When there are two members in a call on iPad, // the PIP is already at size. return } self.associatedCallMemberVideoView?.layer.anchorPoint = callMemberVideoViewAnchorPoint(for: nearestCorner) let animator = UIViewPropertyAnimator( duration: 0.3, springDamping: 1, springResponse: 0.3 ) let scaleX = toFrame.width/fromFrame.width let scaleY = toFrame.height/fromFrame.height animator.addAnimations { self.frame = toFrame self._associatedCallMemberVideoView.transform = CGAffineTransform( scaleX: scaleX, y: scaleY ) } self._associatedCallMemberVideoView.frame = fromFrame self._associatedCallMemberVideoView.layer.cornerRadius = Constants.defaultPipCornerRadius self.animatableLocalMemberViewDelegate?.animatableLocalMemberViewWillBeginAnimation(self) self.isPipAnimationInProgress = true CATransaction.begin() CATransaction.setAnimationDuration(0.3) CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) CATransaction.setCompletionBlock { [weak self] in guard let self else { return } CATransaction.begin() CATransaction.setDisableActions(true) /// Disabling actions disables default system animations of the below properties, /// which, if left enabled, create a visual "jolt" at the end of the animation. self._associatedCallMemberVideoView.transform = .identity self._associatedCallMemberVideoView.frame = toFrame self._associatedCallMemberVideoView.layer.cornerRadius = Constants.defaultPipCornerRadius CATransaction.commit() self.isPipExpanded = isExpanding self.isPipAnimationInProgress = false if isExpanding { self.animatableLocalMemberViewDelegate?.animatableLocalMemberViewDidCompleteExpandAnimation(self) } else { self.animatableLocalMemberViewDelegate?.animatableLocalMemberViewDidCompleteShrinkAnimation(self) } } animator.startAnimation() let toCornerRadius = Constants.defaultPipCornerRadius/scaleX let cornerAnimation = CABasicAnimation(keyPath: #keyPath(CALayer.cornerRadius)) cornerAnimation.fromValue = Constants.defaultPipCornerRadius cornerAnimation.toValue = toCornerRadius cornerAnimation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) cornerAnimation.duration = 0.3 self._associatedCallMemberVideoView.layer.cornerRadius = toCornerRadius self._associatedCallMemberVideoView.layer.add(cornerAnimation, forKey: #keyPath(CALayer.cornerRadius)) CATransaction.commit() } @objc fileprivate func callMemberViewWasTapped() { switch self.type { case .local: animatePip(fromFrame: self.frame, isExpanding: !self.isPipExpanded) case .remoteInGroup, .remoteInIndividual: return } } // MARK: Frame Math enum Constants { static let enlargedPipWidth: CGFloat = 170 fileprivate static let enlargedPipHeight: CGFloat = 300 static let enlargedPipWidthIpadLandscape: CGFloat = 272 fileprivate static let enlargedPipHeightIpadLandscape: CGFloat = 204 fileprivate static let enlargedPipWidthIpadPortrait: CGFloat = 204 fileprivate static let enlargedPipHeightIpadPortrait: CGFloat = 272 static let defaultPipCornerRadius: CGFloat = 10 } private var enlargedPipSize: CGSize { if UIDevice.current.isIPad { if self.frame.width > self.frame.height { return CGSize( width: Constants.enlargedPipWidthIpadLandscape, height: Constants.enlargedPipHeightIpadLandscape ) } else { return CGSize( width: Constants.enlargedPipWidthIpadPortrait, height: Constants.enlargedPipHeightIpadPortrait ) } } else { return CGSize( width: Constants.enlargedPipWidth, height: Constants.enlargedPipHeight ) } } private func enlargedFrame( startingFrame: CGRect, anchorCorner: Corner ) -> CGRect { let enlargedPipSize = enlargedPipSize switch anchorCorner { case .upperLeft: return CGRect( origin: startingFrame.origin, size: CGSize(width: enlargedPipSize.width, height: enlargedPipSize.height) ) case .upperRight: return CGRect( x: startingFrame.x - (enlargedPipSize.width - startingFrame.width), y: startingFrame.y, width: enlargedPipSize.width, height: enlargedPipSize.height ) case .lowerLeft: return CGRect( x: startingFrame.x, y: startingFrame.y - (enlargedPipSize.height - startingFrame.height), width: enlargedPipSize.width, height: enlargedPipSize.height ) case .lowerRight: return CGRect( x: startingFrame.x - (enlargedPipSize.width - startingFrame.width), y: startingFrame.y - (enlargedPipSize.height - startingFrame.height), width: enlargedPipSize.width, height: enlargedPipSize.height ) } } private func shrunkenFrame( currentFrame: CGRect, anchorCorner: Corner ) -> CGRect { let newSize = Self.pipSize( expandedPipFrame: nil, remoteDeviceCount: animatableLocalMemberViewDelegate?.remoteDeviceCount ?? 1 ) switch anchorCorner { case .upperLeft: return CGRect( origin: currentFrame.origin, size: newSize ) case .upperRight: return CGRect( origin: CGPoint( x: currentFrame.x + (currentFrame.width - newSize.width), y: currentFrame.y ), size: newSize ) case .lowerLeft: return CGRect( origin: CGPoint( x: currentFrame.x, y: currentFrame.y + (currentFrame.height - newSize.height) ), size: newSize ) case .lowerRight: return CGRect( origin: CGPoint( x: currentFrame.x + (currentFrame.width - newSize.width), y: currentFrame.y + (currentFrame.height - newSize.height) ), size: newSize ) } } private enum Corner { case upperLeft case upperRight case lowerLeft case lowerRight } private func nearestCorner( innerFrame: CGRect, memberType: MemberType ) -> Corner { switch memberType { case .local: guard let outerBounds = self.animatableLocalMemberViewDelegate?.enclosingBounds else { return .upperRight } let innerFrameCenter = CGPoint( x: innerFrame.x + innerFrame.width/2.0, y: innerFrame.y + innerFrame.height/2.0 ) let distanceSquaredFromUpperLeft = distanceSquared( x1: innerFrameCenter.x, x2: outerBounds.x, y1: innerFrameCenter.y, y2: outerBounds.y ) let distanceSquaredFromUpperRight = distanceSquared( x1: innerFrameCenter.x, x2: outerBounds.width, y1: innerFrameCenter.y, y2: outerBounds.y ) let distanceSquaredFromLowerLeft = distanceSquared( x1: innerFrameCenter.x, x2: outerBounds.x, y1: innerFrameCenter.y, y2: outerBounds.height ) let distanceSquaredFromLowerRight = distanceSquared( x1: innerFrameCenter.x, x2: outerBounds.width, y1: innerFrameCenter.y, y2: outerBounds.height ) let choices = [ (Corner.upperLeft, distanceSquaredFromUpperLeft), (Corner.upperRight, distanceSquaredFromUpperRight), (Corner.lowerLeft, distanceSquaredFromLowerLeft), (Corner.lowerRight, distanceSquaredFromLowerRight) ] var min = CGFloat.infinity var nearestCorner = Corner.upperLeft for (choice, distance) in choices { if distance < min { min = distance nearestCorner = choice } } return nearestCorner case .remoteInGroup, .remoteInIndividual: // Pip in group calls is fixed in lower right corner. return .lowerRight } } private func distanceSquared( x1: CGFloat, x2: CGFloat, y1: CGFloat, y2: CGFloat ) -> CGFloat { pow((x1 - x2), 2) + pow((y1 - y2), 2) } static func pipSize( expandedPipFrame: CGRect?, remoteDeviceCount: Int ) -> CGSize { if let expandedPipFrame { if UIDevice.current.isIPad { return ReturnToCallViewController.inherentPipSize } else { return expandedPipFrame.size } } else { if remoteDeviceCount > 1 { let pipWidth = GroupCallVideoOverflow.itemHeight * ReturnToCallViewController.inherentPipSize.aspectRatio let pipHeight = GroupCallVideoOverflow.itemHeight return CGSize(width: pipWidth, height: pipHeight) } else { return ReturnToCallViewController.inherentPipSize } } } } /// For local member pip expansion and contraction animations. protocol AnimatableLocalMemberViewDelegate: AnyObject { /// The bounds of the view that the local `CallMemberView` pip is laid out relative to. /// This will typically be the bounds of the superview. var enclosingBounds: CGRect { get } /// The number of members in the call, excluding the local user. var remoteDeviceCount: Int { get } /// Called when the expansion animation completes. func animatableLocalMemberViewDidCompleteExpandAnimation(_ localMemberView: CallMemberView) /// Called when the contraction animation completes. func animatableLocalMemberViewDidCompleteShrinkAnimation(_ localMemberView: CallMemberView) /// Called right before a contraction or expansion animation is triggered. func animatableLocalMemberViewWillBeginAnimation(_ localMemberView: CallMemberView) }