mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
951 lines
38 KiB
Swift
951 lines
38 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import MediaPlayer
|
|
import AVKit
|
|
import Lucide
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
final class CallVC: UIViewController, VideoPreviewDelegate, AVRoutePickerViewDelegate {
|
|
private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120)
|
|
private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80)
|
|
private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173)
|
|
private static let minimizeButtonSize: CGFloat = 60
|
|
|
|
private let dependencies: Dependencies
|
|
let call: SessionCall
|
|
var latestKnownAudioOutputDeviceName: String?
|
|
var durationTimer: Timer?
|
|
var shouldRestartCamera = true
|
|
|
|
lazy var cameraManager: CameraManager = {
|
|
let result = CameraManager()
|
|
result.delegate = self
|
|
return result
|
|
}()
|
|
|
|
enum FloatingViewVideoSource {
|
|
case local
|
|
case remote
|
|
}
|
|
|
|
var floatingViewVideoSource: FloatingViewVideoSource = .local
|
|
|
|
// MARK: - UI Components
|
|
|
|
private lazy var floatingLocalVideoView: LocalVideoView = {
|
|
let result = LocalVideoView()
|
|
result.alpha = 0
|
|
result.themeBackgroundColor = .backgroundSecondary
|
|
result.set(.width, to: Self.floatingVideoViewWidth)
|
|
result.set(.height, to: Self.floatingVideoViewHeight)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var floatingRemoteVideoView: RemoteVideoView = {
|
|
let result = RemoteVideoView()
|
|
result.alpha = 0
|
|
result.themeBackgroundColor = .backgroundSecondary
|
|
result.set(.width, to: Self.floatingVideoViewWidth)
|
|
result.set(.height, to: Self.floatingVideoViewHeight)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var fullScreenLocalVideoView: LocalVideoView = {
|
|
let result = LocalVideoView()
|
|
result.alpha = 0
|
|
result.themeBackgroundColor = .backgroundPrimary
|
|
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleFullScreenVideoViewTapped)))
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var fullScreenRemoteVideoView: RemoteVideoView = {
|
|
let result = RemoteVideoView()
|
|
result.alpha = 0
|
|
result.themeBackgroundColor = .backgroundPrimary
|
|
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleFullScreenVideoViewTapped)))
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var floatingViewContainer: UIView = {
|
|
let result = UIView()
|
|
result.isHidden = true
|
|
result.clipsToBounds = true
|
|
result.layer.cornerRadius = UIDevice.current.isIPad ? 20 : 10
|
|
result.layer.masksToBounds = true
|
|
result.themeBackgroundColor = .backgroundSecondary
|
|
result.makeViewDraggable()
|
|
|
|
let noVideoIcon: UIImageView = UIImageView(
|
|
image: UIImage(systemName: "video.slash")?
|
|
.withRenderingMode(.alwaysTemplate)
|
|
)
|
|
noVideoIcon.themeTintColor = .textPrimary
|
|
noVideoIcon.set(.width, to: 34)
|
|
noVideoIcon.set(.height, to: 28)
|
|
result.addSubview(noVideoIcon)
|
|
noVideoIcon.center(in: result)
|
|
|
|
result.addSubview(floatingLocalVideoView)
|
|
floatingLocalVideoView.pin(to: result)
|
|
|
|
result.addSubview(floatingRemoteVideoView)
|
|
floatingRemoteVideoView.pin(to: result)
|
|
|
|
let swappingVideoIcon: UIImageView = UIImageView(
|
|
image: UIImage(systemName: "arrow.2.squarepath")?
|
|
.withRenderingMode(.alwaysTemplate)
|
|
)
|
|
swappingVideoIcon.themeTintColor = .textPrimary
|
|
swappingVideoIcon.set(.width, to: 16)
|
|
swappingVideoIcon.set(.height, to: 12)
|
|
result.addSubview(swappingVideoIcon)
|
|
swappingVideoIcon.pin(.top, to: .top, of: result, withInset: Values.smallSpacing)
|
|
swappingVideoIcon.pin(.trailing, to: .trailing, of: result, withInset: -Values.smallSpacing)
|
|
|
|
result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(switchVideo)))
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var fadeView: GradientView = {
|
|
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top)
|
|
.map { $0 + Values.veryLargeSpacing })
|
|
.defaulting(to: 64)
|
|
|
|
let result: GradientView = GradientView()
|
|
result.themeBackgroundGradient = [
|
|
.value(.backgroundPrimary, alpha: 0.4),
|
|
.value(.backgroundPrimary, alpha: 0)
|
|
]
|
|
result.set(.height, to: height)
|
|
|
|
return result
|
|
}()
|
|
|
|
public lazy var profilePictureView: SessionImageView = {
|
|
let result: SessionImageView = SessionImageView(
|
|
dataManager: dependencies[singleton: .imageDataManager]
|
|
)
|
|
result.set(.width, to: CallVC.avatarRadius * 2)
|
|
result.set(.height, to: CallVC.avatarRadius * 2)
|
|
result.layer.cornerRadius = CallVC.avatarRadius
|
|
result.layer.masksToBounds = true
|
|
result.contentMode = .scaleAspectFill
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var minimizeButton: UIButton = {
|
|
let result = UIButton(type: .custom)
|
|
result.setImage(
|
|
UIImage(named: "Minimize")?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
result.themeTintColor = .textPrimary
|
|
result.addTarget(self, action: #selector(minimize), for: UIControl.Event.touchUpInside)
|
|
|
|
result.isHidden = !call.hasConnected
|
|
result.set(.width, to: Self.minimizeButtonSize)
|
|
result.set(.height, to: Self.minimizeButtonSize)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var answerButton: UIButton = {
|
|
let result = UIButton(type: .custom)
|
|
result.accessibilityIdentifier = "Answer call"
|
|
result.accessibilityLabel = "Answer call"
|
|
result.setImage(
|
|
UIImage(named: "AnswerCall")?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
result.themeTintColor = .white
|
|
result.themeBackgroundColor = .callAccept_background
|
|
result.layer.cornerRadius = 30
|
|
result.addTarget(self, action: #selector(answerCall), for: UIControl.Event.touchUpInside)
|
|
|
|
result.isHidden = call.hasStartedConnecting
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var hangUpButton: UIButton = {
|
|
let result = UIButton(type: .custom)
|
|
result.accessibilityLabel = "End call button"
|
|
result.setImage(
|
|
UIImage(named: "EndCall")?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
result.themeTintColor = .white
|
|
result.themeBackgroundColor = .callDecline_background
|
|
result.layer.cornerRadius = 30
|
|
result.addTarget(self, action: #selector(endCall), for: UIControl.Event.touchUpInside)
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var responsePanel: UIStackView = {
|
|
let result = UIStackView(arrangedSubviews: [hangUpButton, answerButton])
|
|
result.axis = .horizontal
|
|
result.spacing = Values.veryLargeSpacing * 2 + 40
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var switchCameraButton: UIButton = {
|
|
let result = UIButton(type: .custom)
|
|
result.isEnabled = call.isVideoEnabled
|
|
result.setImage(
|
|
Lucide.image(icon: .switchCamera, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
result.themeTintColor = .textPrimary
|
|
result.themeBackgroundColor = .backgroundSecondary
|
|
result.layer.cornerRadius = 30
|
|
result.addTarget(self, action: #selector(switchCamera), for: UIControl.Event.touchUpInside)
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var switchAudioButton: UIButton = {
|
|
let result = UIButton(type: .custom)
|
|
result.setImage(
|
|
Lucide.image(icon: .mic, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
result.themeTintColor = (call.isMuted ?
|
|
.white :
|
|
.textPrimary
|
|
)
|
|
result.themeBackgroundColor = (call.isMuted ?
|
|
.danger :
|
|
.backgroundSecondary
|
|
)
|
|
result.layer.cornerRadius = 30
|
|
result.addTarget(self, action: #selector(switchAudio), for: UIControl.Event.touchUpInside)
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var videoButton: UIButton = {
|
|
let result = UIButton(type: .custom)
|
|
result.setImage(
|
|
Lucide.image(icon: .videoOff, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
result.themeTintColor = .textPrimary
|
|
result.themeBackgroundColor = .backgroundSecondary
|
|
result.layer.cornerRadius = 30
|
|
result.addTarget(self, action: #selector(operateCamera), for: UIControl.Event.touchUpInside)
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var routePickerView: AVRoutePickerView = {
|
|
let result = AVRoutePickerView()
|
|
result.delegate = self
|
|
result.alpha = 0
|
|
result.layer.cornerRadius = 30
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var routePickerButton: UIButton = {
|
|
let result = UIButton(type: .custom)
|
|
result.setImage(
|
|
Lucide.image(icon: .volume2, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
result.themeTintColor = .textPrimary
|
|
result.themeBackgroundColor = .backgroundSecondary
|
|
result.layer.cornerRadius = 30
|
|
result.addTarget(self, action: #selector(switchRoute), for: UIControl.Event.touchUpInside)
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var routePickerContainer: UIView = {
|
|
let result = UIView()
|
|
result.addSubview(routePickerView)
|
|
routePickerView.pin(to: result)
|
|
result.addSubview(routePickerButton)
|
|
routePickerButton.pin(to: result)
|
|
result.layer.cornerRadius = 30
|
|
result.set(.width, to: 60)
|
|
result.set(.height, to: 60)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var operationPanel: UIStackView = {
|
|
let result = UIStackView(arrangedSubviews: [switchCameraButton, videoButton, switchAudioButton, routePickerContainer])
|
|
result.axis = .horizontal
|
|
result.spacing = Values.veryLargeSpacing
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var titleLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
|
result.themeTextColor = .textPrimary
|
|
result.textAlignment = .center
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var callInfoLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
|
result.themeTextColor = .textPrimary
|
|
result.textAlignment = .center
|
|
|
|
if call.hasStartedConnecting { result.text = "callsConnecting".localized() }
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var callDetailedInfoLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
|
result.themeTextColor = .textPrimary
|
|
result.textAlignment = .center
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var callInfoLabelStackView: UIStackView = {
|
|
let result: UIStackView = UIStackView(arrangedSubviews: [callInfoLabel, callDetailedInfoLabel])
|
|
result.axis = .vertical
|
|
result.spacing = Values.mediumSpacing
|
|
result.isHidden = call.hasConnected
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var callDurationLabel: UILabel = {
|
|
let result = UILabel()
|
|
result.font = .boldSystemFont(ofSize: Values.veryLargeFontSize)
|
|
result.themeTextColor = .textPrimary
|
|
result.textAlignment = .center
|
|
result.isHidden = true
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
init(for call: SessionCall, using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
self.call = call
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
setUpStateChangeCallbacks()
|
|
self.modalPresentationStyle = .overFullScreen
|
|
self.modalTransitionStyle = .crossDissolve
|
|
}
|
|
|
|
func setUpStateChangeCallbacks() {
|
|
self.call.remoteVideoStateDidChange = { isEnabled in
|
|
DispatchQueue.main.async {
|
|
UIView.animate(withDuration: 0.25) {
|
|
let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView
|
|
remoteVideoView.alpha = isEnabled ? 1 : 0
|
|
|
|
// Retain floating view visibility if any of the video feeds are enabled
|
|
let isAnyVideoFeedEnabled: Bool = (isEnabled || self.call.isVideoEnabled)
|
|
|
|
// Shows floating camera to allow user to switch to fullscreen or floating
|
|
// even if the other party has not yet turned on their video feed.
|
|
self.floatingViewContainer.isHidden = !isAnyVideoFeedEnabled
|
|
}
|
|
|
|
if self.callInfoLabelStackView.alpha < 0.5 {
|
|
UIView.animate(withDuration: 0.25) {
|
|
self.operationPanel.alpha = 1
|
|
self.responsePanel.alpha = 1
|
|
self.callInfoLabelStackView.alpha = 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.call.hasStartedConnectingDidChange = {
|
|
DispatchQueue.main.async {
|
|
self.callInfoLabel.text = "callsConnecting".localized()
|
|
self.answerButton.alpha = 0
|
|
|
|
UIView.animate(
|
|
withDuration: 0.5,
|
|
delay: 0,
|
|
usingSpringWithDamping: 1,
|
|
initialSpringVelocity: 1,
|
|
options: .curveEaseIn,
|
|
animations: { [weak self] in
|
|
self?.answerButton.isHidden = true
|
|
},
|
|
completion: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
self.call.hasConnectedDidChange = { [weak self] in
|
|
DispatchQueue.main.async {
|
|
CallRingTonePlayer.shared.stopPlayingRingTone()
|
|
|
|
self?.minimizeButton.isHidden = false
|
|
self?.durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
|
|
self?.updateDuration()
|
|
}
|
|
self?.callInfoLabelStackView.isHidden = true
|
|
self?.callDurationLabel.isHidden = false
|
|
}
|
|
}
|
|
|
|
self.call.hasEndedDidChange = { [weak self] in
|
|
DispatchQueue.main.async {
|
|
self?.durationTimer?.invalidate()
|
|
self?.durationTimer = nil
|
|
self?.handleEndCallMessage()
|
|
}
|
|
}
|
|
|
|
self.call.hasStartedReconnecting = { [weak self] in
|
|
DispatchQueue.main.async {
|
|
self?.callInfoLabelStackView.isHidden = false
|
|
self?.callDurationLabel.isHidden = true
|
|
self?.callInfoLabel.text = "callsReconnecting".localized()
|
|
}
|
|
}
|
|
|
|
self.call.hasReconnected = { [weak self] in
|
|
DispatchQueue.main.async {
|
|
self?.callInfoLabelStackView.isHidden = true
|
|
self?.callDurationLabel.isHidden = false
|
|
}
|
|
}
|
|
|
|
self.call.updateCallDetailedStatus = { [weak self] status in
|
|
DispatchQueue.main.async {
|
|
self?.callDetailedInfoLabel.text = status
|
|
}
|
|
}
|
|
}
|
|
|
|
required init(coder: NSCoder) { preconditionFailure("Use init(for:) instead.") }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.themeBackgroundColor = .backgroundPrimary
|
|
|
|
setUpViewHierarchy()
|
|
setUpProfilePictureImage()
|
|
|
|
_ = call.videoCapturer // Force the lazy var to instantiate
|
|
|
|
titleLabel.text = self.call.contactName
|
|
if self.call.hasConnected {
|
|
callDurationLabel.isHidden = false
|
|
durationTimer?.invalidate()
|
|
durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
self?.updateDuration()
|
|
}
|
|
} else {
|
|
callDurationLabel.isHidden = true
|
|
dependencies[singleton: .callManager].startCall(call) { [weak self] error in
|
|
DispatchQueue.main.async {
|
|
if let _ = error {
|
|
self?.callInfoLabel.text = "callsErrorStart".localized()
|
|
self?.endCall()
|
|
}
|
|
else {
|
|
self?.callInfoLabel.text = "callsRinging".localized()
|
|
self?.answerButton.isHidden = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
setUpOrientationMonitoring()
|
|
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteDidChange), name: AVAudioSession.routeChangeNotification, object: nil)
|
|
}
|
|
|
|
deinit {
|
|
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
|
NotificationCenter.default.removeObserver(self)
|
|
}
|
|
|
|
func setUpViewHierarchy() {
|
|
// Profile picture container
|
|
let profilePictureContainer = UIView()
|
|
view.addSubview(profilePictureContainer)
|
|
|
|
// Remote video view
|
|
call.attachRemoteVideoRenderer(fullScreenRemoteVideoView)
|
|
view.addSubview(fullScreenRemoteVideoView)
|
|
fullScreenRemoteVideoView.translatesAutoresizingMaskIntoConstraints = false
|
|
fullScreenRemoteVideoView.pin(to: view)
|
|
|
|
// Local video view
|
|
call.attachLocalVideoRenderer(floatingLocalVideoView)
|
|
view.addSubview(fullScreenLocalVideoView)
|
|
fullScreenLocalVideoView.translatesAutoresizingMaskIntoConstraints = false
|
|
fullScreenLocalVideoView.pin(to: view)
|
|
|
|
// Fade view
|
|
view.addSubview(fadeView)
|
|
fadeView.translatesAutoresizingMaskIntoConstraints = false
|
|
fadeView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: view)
|
|
|
|
// Minimize button
|
|
view.addSubview(minimizeButton)
|
|
minimizeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
minimizeButton.pin(.left, to: .left, of: view)
|
|
minimizeButton.pin(.top, to: .top, of: view, withInset: 32)
|
|
|
|
// Title label
|
|
view.addSubview(titleLabel)
|
|
titleLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
titleLabel.center(.vertical, in: minimizeButton)
|
|
titleLabel.pin(.leading, to: .trailing, of: minimizeButton, withInset: Values.smallSpacing)
|
|
titleLabel.pin(.trailing, to: .trailing, of: view, withInset: -(Values.smallSpacing + Self.minimizeButtonSize))
|
|
|
|
// Response Panel
|
|
view.addSubview(responsePanel)
|
|
responsePanel.center(.horizontal, in: view)
|
|
responsePanel.pin(.bottom, to: .bottom, of: view.safeAreaLayoutGuide, withInset: -Values.smallSpacing)
|
|
|
|
// Operation Panel
|
|
view.addSubview(operationPanel)
|
|
operationPanel.center(.horizontal, in: view)
|
|
operationPanel.pin(.bottom, to: .top, of: responsePanel, withInset: -Values.veryLargeSpacing)
|
|
|
|
// Profile picture view
|
|
profilePictureContainer.pin(.top, to: .bottom, of: fadeView)
|
|
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
|
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
|
profilePictureContainer.addSubview(profilePictureView)
|
|
profilePictureView.center(in: profilePictureContainer)
|
|
|
|
// Call info label
|
|
let callInfoLabelContainer = UIView()
|
|
view.addSubview(callInfoLabelContainer)
|
|
callInfoLabelContainer.pin(.top, to: .bottom, of: profilePictureView)
|
|
callInfoLabelContainer.pin(.bottom, to: .bottom, of: profilePictureContainer)
|
|
callInfoLabelContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
|
callInfoLabelContainer.addSubview(callInfoLabelStackView)
|
|
callInfoLabelContainer.addSubview(callDurationLabel)
|
|
callInfoLabelStackView.translatesAutoresizingMaskIntoConstraints = false
|
|
callInfoLabelStackView.center(in: callInfoLabelContainer)
|
|
callDurationLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
callDurationLabel.center(in: callInfoLabelContainer)
|
|
}
|
|
|
|
func setUpProfilePictureImage() {
|
|
let profile: Profile? = dependencies[singleton: .storage].read { [call] db in
|
|
try Profile.fetchOne(db, id: call.sessionId)
|
|
}
|
|
|
|
switch profile?.displayPictureUrl.map({ try? dependencies[singleton: .displayPictureManager].path(for: $0) }) {
|
|
case .some(let filePath): profilePictureView.loadImage(from: filePath)
|
|
case .none:
|
|
profilePictureView.loadPlaceholder(
|
|
seed: call.sessionId,
|
|
text: call.contactName,
|
|
size: 300
|
|
)
|
|
}
|
|
}
|
|
|
|
private func addFloatingVideoView() {
|
|
guard let window: UIWindow = dependencies[singleton: .appContext].mainWindow else { return }
|
|
|
|
window.addSubview(floatingViewContainer)
|
|
floatingViewContainer.pin(.top, to: .top, of: window, withInset: (window.safeAreaInsets.top + Values.veryLargeSpacing))
|
|
floatingViewContainer.pin(.right, to: .right, of: window, withInset: -Values.smallSpacing)
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.start() }
|
|
|
|
shouldRestartCamera = true
|
|
addFloatingVideoView()
|
|
let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView
|
|
remoteVideoView.alpha = (call.isRemoteVideoEnabled ? 1 : 0)
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
|
|
if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.stop() }
|
|
|
|
floatingViewContainer.removeFromSuperview()
|
|
}
|
|
|
|
// MARK: - Orientation
|
|
|
|
private func setUpOrientationMonitoring() {
|
|
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
|
NotificationCenter.default.addObserver(self, selector: #selector(didChangeDeviceOrientation), name: UIDevice.orientationDidChangeNotification, object: UIDevice.current)
|
|
}
|
|
|
|
@objc func didChangeDeviceOrientation(notification: Notification) {
|
|
if UIDevice.current.isIPad { return }
|
|
|
|
func rotateAllButtons(rotationAngle: CGFloat) {
|
|
let transform = CGAffineTransform(rotationAngle: rotationAngle)
|
|
|
|
UIView.animate(withDuration: 0.2) {
|
|
self.answerButton.transform = transform
|
|
self.hangUpButton.transform = transform
|
|
self.switchAudioButton.transform = transform
|
|
self.switchCameraButton.transform = transform
|
|
self.videoButton.transform = transform
|
|
self.routePickerContainer.transform = transform
|
|
}
|
|
}
|
|
|
|
switch UIDevice.current.orientation {
|
|
case .portrait: rotateAllButtons(rotationAngle: 0)
|
|
case .portraitUpsideDown: rotateAllButtons(rotationAngle: .pi)
|
|
case .landscapeLeft: rotateAllButtons(rotationAngle: .pi * 0.5)
|
|
case .landscapeRight: rotateAllButtons(rotationAngle: .pi * 1.5)
|
|
default: break
|
|
}
|
|
}
|
|
|
|
// MARK: Call signalling
|
|
@MainActor func handleAnswerMessage(_ message: CallMessage) {
|
|
callInfoLabel.text = "callsConnecting".localized()
|
|
}
|
|
|
|
func handleEndCallMessage() {
|
|
Log.info(.calls, "Ending call.")
|
|
self.callInfoLabelStackView.isHidden = false
|
|
self.callDurationLabel.isHidden = true
|
|
self.callInfoLabel.text = "callsEnded".localized()
|
|
|
|
UIView.animate(withDuration: 0.25) {
|
|
let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView
|
|
remoteVideoView.alpha = 0
|
|
self.operationPanel.alpha = 1
|
|
self.responsePanel.alpha = 1
|
|
self.callInfoLabelStackView.alpha = 1
|
|
}
|
|
|
|
self.shouldHandleCallDismiss(delay: 2)
|
|
}
|
|
|
|
@objc private func answerCall() {
|
|
dependencies[singleton: .callManager].answerCall(call) { [weak self] error in
|
|
DispatchQueue.main.async {
|
|
if let _ = error {
|
|
self?.callInfoLabel.text = "callsErrorAnswer".localized()
|
|
self?.endCall()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func endCall(presentCameraRequestDialog: Bool = false) {
|
|
dependencies[singleton: .callManager].endCall(call) { [weak self, dependencies] error in
|
|
self?.shouldHandleCallDismiss(delay: 1, presentCameraRequestDialog: presentCameraRequestDialog)
|
|
|
|
if let _ = error {
|
|
self?.call.endSessionCall()
|
|
dependencies[singleton: .callManager].reportCurrentCallEnded(reason: .declinedElsewhere)
|
|
}
|
|
}
|
|
}
|
|
|
|
// stringlint:ignore_contents
|
|
@objc private func updateDuration() {
|
|
guard let connectedDate = call.connectedDate else { return }
|
|
let duration = Int(Date().timeIntervalSince1970 - connectedDate.timeIntervalSince1970)
|
|
callDurationLabel.text = String(format: "%.2d:%.2d", duration/60, duration%60)
|
|
}
|
|
|
|
// MARK: - Minimize to a floating view
|
|
|
|
@objc private func minimize() {
|
|
self.shouldRestartCamera = false
|
|
|
|
let miniCallView = MiniCallView(from: self, using: dependencies)
|
|
miniCallView.show()
|
|
|
|
presentingViewController?.dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
// MARK: - Video and Audio
|
|
|
|
@objc private func operateCamera() {
|
|
|
|
if (call.isVideoEnabled) {
|
|
// Hides local video feed
|
|
(floatingViewVideoSource == .local
|
|
? floatingLocalVideoView
|
|
: fullScreenLocalVideoView).alpha = 0
|
|
|
|
floatingViewContainer.isHidden = !call.isRemoteVideoEnabled
|
|
cameraManager.stop()
|
|
videoButton.themeTintColor = .textPrimary
|
|
videoButton.themeBackgroundColor = .backgroundSecondary
|
|
videoButton.setImage(
|
|
Lucide.image(icon: .videoOff, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
switchCameraButton.isEnabled = false
|
|
call.isVideoEnabled = false
|
|
}
|
|
else {
|
|
|
|
// Added delay of preview due to permission dialog alert dismissal on allow.
|
|
// It causes issue on `VideoPreviewVC` presentation animation,
|
|
// If camera permission is already allowed no animation delay is needed
|
|
let previewDelay = Permissions.camera == .undetermined ? 0.5 : 0
|
|
|
|
Permissions.requestCameraPermissionIfNeeded(
|
|
useCustomDeniedAlert: true,
|
|
using: dependencies
|
|
) { [weak self, dependencies] isAuthorized in
|
|
|
|
let status = Permissions.camera
|
|
|
|
switch (isAuthorized, status) {
|
|
case (false, .denied):
|
|
guard let presentingViewController: UIViewController = (self?.navigationController ?? dependencies[singleton: .appContext].frontMostViewController)
|
|
else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "cameraAccessRequired".localized(),
|
|
body: .attributedText(
|
|
"cameraAccessDeniedMessage"
|
|
.put(key: "app_name", value: Constants.app_name)
|
|
.localizedFormatted(),
|
|
scrollMode: .never
|
|
),
|
|
confirmTitle: "endCallToEnable".localized(),
|
|
confirmStyle: .danger,
|
|
cancelTitle: "remindMeLater".localized(),
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { _ in
|
|
self?.endCall(presentCameraRequestDialog: true)
|
|
},
|
|
onCancel: { modal in
|
|
dependencies[defaults: .standard, key: .shouldRemindGrantingCameraPermissionForCalls] = true
|
|
modal.dismiss(animated: true)
|
|
}
|
|
)
|
|
)
|
|
presentingViewController.present(confirmationModal, animated: true, completion: nil)
|
|
}
|
|
case (true, _):
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + previewDelay) { [weak self] in
|
|
let previewVC = VideoPreviewVC()
|
|
previewVC.delegate = self
|
|
self?.present(previewVC, animated: true, completion: nil)
|
|
}
|
|
break
|
|
default: break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func cameraDidConfirmTurningOn() {
|
|
floatingViewContainer.isHidden = false
|
|
|
|
let localVideoView: LocalVideoView = self.floatingViewVideoSource == .local ? self.floatingLocalVideoView : self.fullScreenLocalVideoView
|
|
localVideoView.alpha = 1
|
|
|
|
// Camera preparation
|
|
cameraManager.prepare()
|
|
cameraManager.start()
|
|
|
|
videoButton.themeTintColor = .backgroundSecondary
|
|
videoButton.themeBackgroundColor = .textPrimary
|
|
videoButton.setImage(
|
|
Lucide.image(icon: .video, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
switchCameraButton.isEnabled = true
|
|
call.isVideoEnabled = true
|
|
}
|
|
|
|
@objc private func switchVideo() {
|
|
if self.floatingViewVideoSource == .remote {
|
|
call.removeRemoteVideoRenderer(self.floatingRemoteVideoView)
|
|
call.removeLocalVideoRenderer(self.fullScreenLocalVideoView)
|
|
|
|
self.floatingRemoteVideoView.alpha = 0
|
|
self.floatingLocalVideoView.alpha = call.isVideoEnabled ? 1 : 0
|
|
self.fullScreenRemoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0
|
|
self.fullScreenLocalVideoView.alpha = 0
|
|
|
|
self.floatingViewVideoSource = .local
|
|
call.attachRemoteVideoRenderer(self.fullScreenRemoteVideoView)
|
|
call.attachLocalVideoRenderer(self.floatingLocalVideoView)
|
|
} else {
|
|
call.removeRemoteVideoRenderer(self.fullScreenRemoteVideoView)
|
|
call.removeLocalVideoRenderer(self.floatingLocalVideoView)
|
|
|
|
self.floatingRemoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0
|
|
self.floatingLocalVideoView.alpha = 0
|
|
self.fullScreenRemoteVideoView.alpha = 0
|
|
self.fullScreenLocalVideoView.alpha = call.isVideoEnabled ? 1 : 0
|
|
|
|
self.floatingViewVideoSource = .remote
|
|
call.attachRemoteVideoRenderer(self.floatingRemoteVideoView)
|
|
call.attachLocalVideoRenderer(self.fullScreenLocalVideoView)
|
|
}
|
|
}
|
|
|
|
@objc private func switchCamera() {
|
|
cameraManager.switchCamera()
|
|
}
|
|
|
|
@objc private func switchAudio() {
|
|
if call.isMuted {
|
|
switchAudioButton.themeTintColor = .textPrimary
|
|
switchAudioButton.themeBackgroundColor = .backgroundSecondary
|
|
call.isMuted = false
|
|
}
|
|
else {
|
|
switchAudioButton.themeTintColor = .white
|
|
switchAudioButton.themeBackgroundColor = .danger
|
|
call.isMuted = true
|
|
}
|
|
|
|
switchAudioButton.setImage(
|
|
Lucide.image(icon: call.isMuted ? .micOff: .mic, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate),
|
|
for: .normal
|
|
)
|
|
}
|
|
|
|
@objc private func switchRoute() {
|
|
simulateRoutePickerViewTapping()
|
|
}
|
|
|
|
private func simulateRoutePickerViewTapping() {
|
|
guard let routeButton = routePickerView.subviews.first(where: { $0 is UIButton }) as? UIButton else {
|
|
return
|
|
}
|
|
routeButton.sendActions(for: .touchUpInside)
|
|
}
|
|
|
|
@objc private func audioRouteDidChange() {
|
|
let currentSession = AVAudioSession.sharedInstance()
|
|
let currentRoute = currentSession.currentRoute
|
|
if let currentOutput = currentRoute.outputs.first {
|
|
if let latestKnownAudioOutputDeviceName = latestKnownAudioOutputDeviceName, currentOutput.portName == latestKnownAudioOutputDeviceName { return }
|
|
|
|
latestKnownAudioOutputDeviceName = currentOutput.portName
|
|
|
|
switch currentOutput.portType {
|
|
case .builtInSpeaker:
|
|
let image = Lucide.image(icon: .volume2, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate)
|
|
|
|
routePickerButton.setImage(image, for: .normal)
|
|
routePickerButton.themeTintColor = .backgroundSecondary
|
|
routePickerButton.themeBackgroundColor = .textPrimary
|
|
|
|
case .headphones:
|
|
let image = UIImage(named: "Headsets")?.withRenderingMode(.alwaysTemplate)
|
|
routePickerButton.setImage(image, for: .normal)
|
|
routePickerButton.themeTintColor = .backgroundSecondary
|
|
routePickerButton.themeBackgroundColor = .textPrimary
|
|
|
|
case .bluetoothLE: fallthrough
|
|
case .bluetoothA2DP:
|
|
let image = UIImage(named: "Bluetooth")?.withRenderingMode(.alwaysTemplate)
|
|
routePickerButton.setImage(image, for: .normal)
|
|
routePickerButton.themeTintColor = .backgroundSecondary
|
|
routePickerButton.themeBackgroundColor = .textPrimary
|
|
|
|
case .bluetoothHFP:
|
|
let image = UIImage(named: "Airpods")?.withRenderingMode(.alwaysTemplate)
|
|
routePickerButton.setImage(image, for: .normal)
|
|
routePickerButton.themeTintColor = .backgroundSecondary
|
|
routePickerButton.themeBackgroundColor = .textPrimary
|
|
|
|
case .builtInReceiver: fallthrough
|
|
default:
|
|
let image = Lucide.image(icon: .volume2, size: IconSize.medium.size)?
|
|
.withRenderingMode(.alwaysTemplate)
|
|
|
|
routePickerButton.setImage(image, for: .normal)
|
|
routePickerButton.themeTintColor = .textPrimary
|
|
routePickerButton.themeBackgroundColor = .backgroundSecondary
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func handleFullScreenVideoViewTapped(gesture: UITapGestureRecognizer) {
|
|
let isHidden = callDurationLabel.alpha < 0.5
|
|
|
|
UIView.animate(withDuration: 0.5) {
|
|
self.operationPanel.alpha = isHidden ? 1 : 0
|
|
self.responsePanel.alpha = isHidden ? 1 : 0
|
|
self.callDurationLabel.alpha = isHidden ? 1 : 0
|
|
}
|
|
}
|
|
|
|
private func shouldHandleCallDismiss(delay: TimeInterval, presentCameraRequestDialog: Bool = false) {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(Int(delay))) { [weak self, dependencies] in
|
|
guard self?.presentingViewController != nil else { return }
|
|
|
|
self?.dismiss(animated: true, completion: {
|
|
if presentCameraRequestDialog {
|
|
Permissions.showEnableCameraAccessInstructions(using: dependencies)
|
|
} else {
|
|
Permissions.remindCameraAccessRequirement(using: dependencies)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// MARK: - AVRoutePickerViewDelegate
|
|
|
|
func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {}
|
|
|
|
func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {}
|
|
}
|