TM-SGNL-iOS/Signal/Calls/UserInterface/CallMemberView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

635 lines
22 KiB
Swift

//
// 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)
}