mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			678 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			678 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import Combine
 | |
| import AVFoundation
 | |
| import SessionUIKit
 | |
| import SignalUtilitiesKit
 | |
| import SignalCoreKit
 | |
| import SessionUtilitiesKit
 | |
| 
 | |
| protocol PhotoCaptureViewControllerDelegate: AnyObject {
 | |
|     func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment)
 | |
|     func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController)
 | |
| }
 | |
| 
 | |
| enum PhotoCaptureError: Error {
 | |
|     case assertionError(description: String)
 | |
|     case initializationFailed
 | |
|     case captureFailed
 | |
| }
 | |
| 
 | |
| extension PhotoCaptureError: LocalizedError {
 | |
|     var localizedDescription: String {
 | |
|         switch self {
 | |
|         case .initializationFailed:
 | |
|             return NSLocalizedString("PHOTO_CAPTURE_UNABLE_TO_INITIALIZE_CAMERA", comment: "alert title")
 | |
|         case .captureFailed:
 | |
|             return NSLocalizedString("PHOTO_CAPTURE_UNABLE_TO_CAPTURE_IMAGE", comment: "alert title")
 | |
|         case .assertionError:
 | |
|             return NSLocalizedString("PHOTO_CAPTURE_GENERIC_ERROR", comment: "alert title, generic error preventing user from capturing a photo")
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| class PhotoCaptureViewController: OWSViewController {
 | |
| 
 | |
|     weak var delegate: PhotoCaptureViewControllerDelegate?
 | |
| 
 | |
|     private var photoCapture: PhotoCapture!
 | |
| 
 | |
|     deinit {
 | |
|         UIDevice.current.endGeneratingDeviceOrientationNotifications()
 | |
|         if let photoCapture = photoCapture {
 | |
|             photoCapture.stopCapture()
 | |
|                 .sinkUntilComplete(
 | |
|                     receiveCompletion: { result in
 | |
|                         switch result {
 | |
|                             case .failure: break
 | |
|                             case .finished: Logger.debug("stopCapture completed")
 | |
|                         }
 | |
|                     }
 | |
|                 )
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - Overrides
 | |
| 
 | |
|     override func loadView() {
 | |
|         self.view = UIView()
 | |
|         self.view.themeBackgroundColor = .newConversation_background
 | |
|     }
 | |
| 
 | |
|     override func viewDidLoad() {
 | |
|         super.viewDidLoad()
 | |
|         setupPhotoCapture()
 | |
|         setupOrientationMonitoring()
 | |
|         
 | |
|         updateNavigationItems()
 | |
|         updateFlashModeControl()
 | |
| 
 | |
|         let initialCaptureOrientation = AVCaptureVideoOrientation(deviceOrientation: UIDevice.current.orientation) ?? .portrait
 | |
|         updateIconOrientations(isAnimated: false, captureOrientation: initialCaptureOrientation)
 | |
| 
 | |
|         view.addGestureRecognizer(pinchZoomGesture)
 | |
|         view.addGestureRecognizer(focusGesture)
 | |
|         view.addGestureRecognizer(doubleTapToSwitchCameraGesture)
 | |
|     }
 | |
| 
 | |
|     override var prefersStatusBarHidden: Bool {
 | |
|         return true
 | |
|     }
 | |
| 
 | |
|     // MARK: -
 | |
|     var isRecordingMovie: Bool = false
 | |
|     let recordingTimerView = RecordingTimerView()
 | |
| 
 | |
|     func updateNavigationItems() {
 | |
|         if isRecordingMovie {
 | |
|             navigationItem.leftBarButtonItem = nil
 | |
|             navigationItem.rightBarButtonItems = nil
 | |
|             navigationItem.titleView = recordingTimerView
 | |
|             recordingTimerView.sizeToFit()
 | |
|         }
 | |
|         else {
 | |
|             navigationItem.titleView = nil
 | |
|             navigationItem.leftBarButtonItem = dismissControl.barButtonItem
 | |
|             let fixedSpace = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
 | |
|             fixedSpace.width = 16
 | |
| 
 | |
|             navigationItem.rightBarButtonItems = [switchCameraControl.barButtonItem, fixedSpace, flashModeControl.barButtonItem]
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // HACK: Though we don't have an input accessory view, the VC we are presented above (ConversationVC) does.
 | |
|     // If the app is backgrounded and then foregrounded, when OWSWindowManager calls mainWindow.makeKeyAndVisible
 | |
|     // the ConversationVC's inputAccessoryView will appear *above* us unless we'd previously become first responder.
 | |
|     override public var canBecomeFirstResponder: Bool {
 | |
|         Logger.debug("")
 | |
|         return true
 | |
|     }
 | |
| 
 | |
|     override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
 | |
|         return .portrait
 | |
|     }
 | |
| 
 | |
|     // MARK: - Views
 | |
| 
 | |
|     let captureButton = CaptureButton()
 | |
|     var previewView: CapturePreviewView!
 | |
| 
 | |
|     class PhotoControl {
 | |
|         let button: OWSButton
 | |
|         let barButtonItem: UIBarButtonItem
 | |
| 
 | |
|         init(imageName: String, block: @escaping () -> Void) {
 | |
|             self.button = OWSButton(imageName: imageName, tintColor: .white, block: block)
 | |
|             button.autoPinToSquareAspectRatio()
 | |
|             button.themeShadowColor = .black
 | |
|             button.layer.shadowOffset = CGSize.zero
 | |
|             button.layer.shadowOpacity = 0.35
 | |
|             button.layer.shadowRadius = 4
 | |
| 
 | |
|             self.barButtonItem = UIBarButtonItem(customView: button)
 | |
|         }
 | |
| 
 | |
|         func setImage(imageName: String) {
 | |
|             button.setImage(imageName: imageName)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private lazy var dismissControl: PhotoControl = {
 | |
|         return PhotoControl(imageName: "X") { [weak self] in
 | |
|             self?.didTapClose()
 | |
|         }
 | |
|     }()
 | |
| 
 | |
|     private lazy var switchCameraControl: PhotoControl = {
 | |
|         return PhotoControl(imageName: "ic_switch_camera") { [weak self] in
 | |
|             self?.didTapSwitchCamera()
 | |
|         }
 | |
|     }()
 | |
| 
 | |
|     private lazy var flashModeControl: PhotoControl = {
 | |
|         return PhotoControl(imageName: "ic_flash_mode_auto") { [weak self] in
 | |
|             self?.didTapFlashMode()
 | |
|         }
 | |
|     }()
 | |
| 
 | |
|     lazy var pinchZoomGesture: UIPinchGestureRecognizer = {
 | |
|         return UIPinchGestureRecognizer(target: self, action: #selector(didPinchZoom(pinchGesture:)))
 | |
|     }()
 | |
| 
 | |
|     lazy var focusGesture: UITapGestureRecognizer = {
 | |
|         return UITapGestureRecognizer(target: self, action: #selector(didTapFocusExpose(tapGesture:)))
 | |
|     }()
 | |
| 
 | |
|     lazy var doubleTapToSwitchCameraGesture: UITapGestureRecognizer = {
 | |
|         let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapToSwitchCamera(tapGesture:)))
 | |
|         tapGesture.numberOfTapsRequired = 2
 | |
|         return tapGesture
 | |
|     }()
 | |
| 
 | |
|     // MARK: - Events
 | |
| 
 | |
|     @objc
 | |
|     func didTapClose() {
 | |
|         self.delegate?.photoCaptureViewControllerDidCancel(self)
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didTapSwitchCamera() {
 | |
|         Logger.debug("")
 | |
|         switchCamera()
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didDoubleTapToSwitchCamera(tapGesture: UITapGestureRecognizer) {
 | |
|         Logger.debug("")
 | |
|         switchCamera()
 | |
|     }
 | |
| 
 | |
|     private func switchCamera() {
 | |
|         UIView.animate(withDuration: 0.2) {
 | |
|             let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001
 | |
|             self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation)
 | |
|         }
 | |
|         
 | |
|         photoCapture.switchCamera()
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sinkUntilComplete(
 | |
|                 receiveCompletion: { [weak self] result in
 | |
|                     switch result {
 | |
|                         case .finished: break
 | |
|                         case .failure(let error): self?.showFailureUI(error: error)
 | |
|                     }
 | |
|                 }
 | |
|             )
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didTapFlashMode() {
 | |
|         Logger.debug("")
 | |
|         photoCapture.switchFlashMode()
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sinkUntilComplete(
 | |
|                 receiveCompletion: { [weak self] _ in
 | |
|                     self?.updateFlashModeControl()
 | |
|                 }
 | |
|             )
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) {
 | |
|         switch pinchGesture.state {
 | |
|         case .began: fallthrough
 | |
|         case .changed:
 | |
|             photoCapture.updateZoom(scaleFromPreviousZoomFactor: pinchGesture.scale)
 | |
|         case .ended:
 | |
|             photoCapture.completeZoom(scaleFromPreviousZoomFactor: pinchGesture.scale)
 | |
|         default:
 | |
|             break
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     func didTapFocusExpose(tapGesture: UITapGestureRecognizer) {
 | |
|         let viewLocation = tapGesture.location(in: view)
 | |
|         let devicePoint = previewView.previewLayer.captureDevicePointConverted(fromLayerPoint: viewLocation)
 | |
|         photoCapture.focus(with: .autoFocus, exposureMode: .autoExpose, at: devicePoint, monitorSubjectAreaChange: true)
 | |
|     }
 | |
| 
 | |
|     // MARK: - Orientation
 | |
| 
 | |
|     private func setupOrientationMonitoring() {
 | |
|         UIDevice.current.beginGeneratingDeviceOrientationNotifications()
 | |
| 
 | |
|         NotificationCenter.default.addObserver(
 | |
|             self,
 | |
|             selector: #selector(didChangeDeviceOrientation),
 | |
|             name: UIDevice.orientationDidChangeNotification,
 | |
|             object: UIDevice.current
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     var lastKnownCaptureOrientation: AVCaptureVideoOrientation = .portrait
 | |
| 
 | |
|     @objc
 | |
|     func didChangeDeviceOrientation(notification: Notification) {
 | |
|         let currentOrientation = UIDevice.current.orientation
 | |
| 
 | |
|         if let captureOrientation = AVCaptureVideoOrientation(deviceOrientation: currentOrientation) {
 | |
|             // since the "face up" and "face down" orientations aren't reflected in the photo output,
 | |
|             // we need to capture the last known _other_ orientation so we can reflect the appropriate
 | |
|             // portrait/landscape in our captured photos.
 | |
|             Logger.verbose("lastKnownCaptureOrientation: \(lastKnownCaptureOrientation)->\(captureOrientation)")
 | |
|             lastKnownCaptureOrientation = captureOrientation
 | |
|             updateIconOrientations(isAnimated: true, captureOrientation: captureOrientation)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: -
 | |
| 
 | |
|     private func updateIconOrientations(isAnimated: Bool, captureOrientation: AVCaptureVideoOrientation) {
 | |
|         Logger.verbose("captureOrientation: \(captureOrientation)")
 | |
| 
 | |
|         let transformFromOrientation: CGAffineTransform
 | |
|         switch captureOrientation {
 | |
|             case .portrait: transformFromOrientation = .identity
 | |
|             case .portraitUpsideDown: transformFromOrientation = CGAffineTransform(rotationAngle: .pi)
 | |
|             case .landscapeLeft: transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi)
 | |
|             case .landscapeRight: transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi)
 | |
|             @unknown default: transformFromOrientation = .identity
 | |
|         }
 | |
| 
 | |
|         // Don't "unrotate" the switch camera icon if the front facing camera had been selected.
 | |
|         let tranformFromCameraType: CGAffineTransform = photoCapture.desiredPosition == .front ? CGAffineTransform(rotationAngle: -.pi) : .identity
 | |
| 
 | |
|         let updateOrientation = {
 | |
|             self.flashModeControl.button.transform = transformFromOrientation
 | |
|             self.switchCameraControl.button.transform   = transformFromOrientation.concatenating(tranformFromCameraType)
 | |
|         }
 | |
| 
 | |
|         if isAnimated {
 | |
|             UIView.animate(withDuration: 0.3, animations: updateOrientation)
 | |
|         } else {
 | |
|             updateOrientation()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private func setupPhotoCapture() {
 | |
|         photoCapture = PhotoCapture()
 | |
|         photoCapture.delegate = self
 | |
|         captureButton.delegate = photoCapture
 | |
|         previewView = CapturePreviewView(session: photoCapture.session)
 | |
| 
 | |
|         photoCapture.startCapture()
 | |
|             .receive(on: DispatchQueue.main)
 | |
|             .sinkUntilComplete(
 | |
|                 receiveCompletion: { [weak self] result in
 | |
|                     switch result {
 | |
|                         case .finished: self?.showCaptureUI()
 | |
|                         case .failure(let error): self?.showFailureUI(error: error)
 | |
|                     }
 | |
|                 }
 | |
|             )
 | |
|     }
 | |
| 
 | |
|     private func showCaptureUI() {
 | |
|         Logger.debug("")
 | |
|         view.addSubview(previewView)
 | |
|         if UIDevice.current.hasIPhoneXNotch {
 | |
|             previewView.autoPinEdgesToSuperviewEdges()
 | |
|         } else {
 | |
|             previewView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 0, leading: 0, bottom: 40, trailing: 0))
 | |
|         }
 | |
| 
 | |
|         view.addSubview(captureButton)
 | |
|         captureButton.autoHCenterInSuperview()
 | |
|         captureButton.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor, constant: SendMediaNavigationController.bottomButtonsCenterOffset).isActive = true
 | |
|     }
 | |
| 
 | |
|     private func showFailureUI(error: Error) {
 | |
|         Logger.error("error: \(error)")
 | |
|         let modal: ConfirmationModal = ConfirmationModal(
 | |
|             info: ConfirmationModal.Info(
 | |
|                 title: CommonStrings.errorAlertTitle,
 | |
|                 body: .text(error.localizedDescription),
 | |
|                 cancelTitle: CommonStrings.dismissButton,
 | |
|                 cancelStyle: .alert_text,
 | |
|                 afterClosed: { [weak self] in self?.dismiss(animated: true) }
 | |
|             )
 | |
|         )
 | |
|         
 | |
|         present(modal, animated: true)
 | |
|     }
 | |
| 
 | |
|     private func updateFlashModeControl() {
 | |
|         let imageName: String
 | |
|         switch photoCapture.flashMode {
 | |
|         case .auto:
 | |
|             imageName = "ic_flash_mode_auto"
 | |
|         case .on:
 | |
|             imageName = "ic_flash_mode_on"
 | |
|         case .off:
 | |
|             imageName = "ic_flash_mode_off"
 | |
|         default: preconditionFailure()
 | |
|         }
 | |
| 
 | |
|         self.flashModeControl.setImage(imageName: imageName)
 | |
|     }
 | |
| }
 | |
| 
 | |
| extension PhotoCaptureViewController: PhotoCaptureDelegate {
 | |
| 
 | |
|     // MARK: - Photo
 | |
| 
 | |
|     func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment) {
 | |
|         delegate?.photoCaptureViewController(self, didFinishProcessingAttachment: attachment)
 | |
|     }
 | |
| 
 | |
|     func photoCapture(_ photoCapture: PhotoCapture, processingDidError error: Error) {
 | |
|         showFailureUI(error: error)
 | |
|     }
 | |
| 
 | |
|     // MARK: - Video
 | |
| 
 | |
|     func photoCaptureDidBeginVideo(_ photoCapture: PhotoCapture) {
 | |
|         isRecordingMovie = true
 | |
|         updateNavigationItems()
 | |
|         recordingTimerView.startCounting()
 | |
|     }
 | |
| 
 | |
|     func photoCaptureDidCompleteVideo(_ photoCapture: PhotoCapture) {
 | |
|         isRecordingMovie = false
 | |
|         recordingTimerView.stopCounting()
 | |
|         updateNavigationItems()
 | |
|     }
 | |
| 
 | |
|     func photoCaptureDidCancelVideo(_ photoCapture: PhotoCapture) {
 | |
|         owsFailDebug("If we ever allow this, we should test.")
 | |
|         isRecordingMovie = false
 | |
|         recordingTimerView.stopCounting()
 | |
|         updateNavigationItems()
 | |
|     }
 | |
| 
 | |
|     // MARK: -
 | |
| 
 | |
|     var zoomScaleReferenceHeight: CGFloat? {
 | |
|         return view.bounds.height
 | |
|     }
 | |
| 
 | |
|     var captureOrientation: AVCaptureVideoOrientation {
 | |
|         return lastKnownCaptureOrientation
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Views
 | |
| 
 | |
| protocol CaptureButtonDelegate: AnyObject {
 | |
|     // MARK: Photo
 | |
|     func didTapCaptureButton(_ captureButton: CaptureButton)
 | |
| 
 | |
|     // MARK: Video
 | |
|     func didBeginLongPressCaptureButton(_ captureButton: CaptureButton)
 | |
|     func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton)
 | |
|     func didCancelLongPressCaptureButton(_ captureButton: CaptureButton)
 | |
| 
 | |
|     var zoomScaleReferenceHeight: CGFloat? { get }
 | |
|     func longPressCaptureButton(_ captureButton: CaptureButton, didUpdateZoomAlpha zoomAlpha: CGFloat)
 | |
| }
 | |
| 
 | |
| class CaptureButton: UIView {
 | |
| 
 | |
|     let innerButton = CircleView()
 | |
| 
 | |
|     var tapGesture: UITapGestureRecognizer!
 | |
| 
 | |
|     var longPressGesture: UILongPressGestureRecognizer!
 | |
|     let longPressDuration = 0.5
 | |
| 
 | |
|     let zoomIndicator = CircleView()
 | |
| 
 | |
|     weak var delegate: CaptureButtonDelegate?
 | |
| 
 | |
|     let defaultDiameter: CGFloat = ScaleFromIPhone5To7Plus(60, 80)
 | |
|     let recordingDiameter: CGFloat = ScaleFromIPhone5To7Plus(68, 120)
 | |
|     var innerButtonSizeConstraints: [NSLayoutConstraint]!
 | |
|     var zoomIndicatorSizeConstraints: [NSLayoutConstraint]!
 | |
| 
 | |
|     override init(frame: CGRect) {
 | |
|         super.init(frame: frame)
 | |
| 
 | |
|         tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap))
 | |
|         innerButton.addGestureRecognizer(tapGesture)
 | |
| 
 | |
|         longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress))
 | |
|         longPressGesture.minimumPressDuration = longPressDuration
 | |
|         innerButton.addGestureRecognizer(longPressGesture)
 | |
| 
 | |
|         addSubview(innerButton)
 | |
|         innerButtonSizeConstraints = autoSetDimensions(to: CGSize(width: defaultDiameter, height: defaultDiameter))
 | |
|         innerButton.themeBackgroundColor = .white
 | |
|         innerButton.layer.shadowOffset = .zero
 | |
|         innerButton.layer.shadowOpacity = 0.33
 | |
|         innerButton.layer.shadowRadius = 2
 | |
|         innerButton.alpha = 0.33
 | |
|         innerButton.autoPinEdgesToSuperviewEdges()
 | |
| 
 | |
|         addSubview(zoomIndicator)
 | |
|         zoomIndicatorSizeConstraints = zoomIndicator.autoSetDimensions(to: CGSize(width: defaultDiameter, height: defaultDiameter))
 | |
|         zoomIndicator.isUserInteractionEnabled = false
 | |
|         zoomIndicator.themeBorderColor = .white
 | |
|         zoomIndicator.layer.borderWidth = 1.5
 | |
|         zoomIndicator.autoAlignAxis(.horizontal, toSameAxisOf: innerButton)
 | |
|         zoomIndicator.autoAlignAxis(.vertical, toSameAxisOf: innerButton)
 | |
|     }
 | |
| 
 | |
|     required init?(coder aDecoder: NSCoder) {
 | |
|         fatalError("init(coder:) has not been implemented")
 | |
|     }
 | |
| 
 | |
|     // MARK: - Gestures
 | |
| 
 | |
|     @objc
 | |
|     func didTap(_ gesture: UITapGestureRecognizer) {
 | |
|         delegate?.didTapCaptureButton(self)
 | |
|     }
 | |
| 
 | |
|     var initialTouchLocation: CGPoint?
 | |
| 
 | |
|     @objc
 | |
|     func didLongPress(_ gesture: UILongPressGestureRecognizer) {
 | |
|         Logger.verbose("")
 | |
| 
 | |
|         guard let gestureView = gesture.view else {
 | |
|             owsFailDebug("gestureView was unexpectedly nil")
 | |
|             return
 | |
|         }
 | |
| 
 | |
|         switch gesture.state {
 | |
|         case .possible: break
 | |
|         case .began:
 | |
|             initialTouchLocation = gesture.location(in: gesture.view)
 | |
|             delegate?.didBeginLongPressCaptureButton(self)
 | |
|             UIView.animate(withDuration: 0.2) {
 | |
|                 self.innerButtonSizeConstraints.forEach { $0.constant = self.recordingDiameter }
 | |
|                 self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.recordingDiameter }
 | |
|                 self.superview?.layoutIfNeeded()
 | |
|             }
 | |
|         case .changed:
 | |
|             guard let referenceHeight = delegate?.zoomScaleReferenceHeight else {
 | |
|                 owsFailDebug("referenceHeight was unexpectedly nil")
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             guard referenceHeight > 0 else {
 | |
|                 owsFailDebug("referenceHeight was unexpectedly <= 0")
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             guard let initialTouchLocation = initialTouchLocation else {
 | |
|                 owsFailDebug("initialTouchLocation was unexpectedly nil")
 | |
|                 return
 | |
|             }
 | |
| 
 | |
|             let currentLocation = gesture.location(in: gestureView)
 | |
|             let minDistanceBeforeActivatingZoom: CGFloat = 30
 | |
|             let distance = initialTouchLocation.y - currentLocation.y - minDistanceBeforeActivatingZoom
 | |
|             let distanceForFullZoom = referenceHeight / 4
 | |
|             let ratio = distance / distanceForFullZoom
 | |
| 
 | |
|             let alpha = ratio.clamp(0, 1)
 | |
| 
 | |
|             Logger.verbose("distance: \(distance), alpha: \(alpha)")
 | |
| 
 | |
|             let zoomIndicatorDiameter = CGFloatLerp(recordingDiameter, 3, alpha)
 | |
|             self.zoomIndicatorSizeConstraints.forEach { $0.constant = zoomIndicatorDiameter }
 | |
|             zoomIndicator.superview?.layoutIfNeeded()
 | |
| 
 | |
|             delegate?.longPressCaptureButton(self, didUpdateZoomAlpha: alpha)
 | |
|         case .ended:
 | |
|             UIView.animate(withDuration: 0.2) {
 | |
|                 self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter }
 | |
|                 self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter }
 | |
| 
 | |
|                 self.superview?.layoutIfNeeded()
 | |
|             }
 | |
|             delegate?.didCompleteLongPressCaptureButton(self)
 | |
|         case .cancelled, .failed:
 | |
| 
 | |
|             UIView.animate(withDuration: 0.2) {
 | |
|                 self.innerButtonSizeConstraints.forEach { $0.constant = self.defaultDiameter }
 | |
|                 self.zoomIndicatorSizeConstraints.forEach { $0.constant = self.defaultDiameter }
 | |
| 
 | |
|                 self.superview?.layoutIfNeeded()
 | |
|             }
 | |
|             delegate?.didCancelLongPressCaptureButton(self)
 | |
|         default: break
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| class CapturePreviewView: UIView {
 | |
| 
 | |
|     let previewLayer: AVCaptureVideoPreviewLayer
 | |
| 
 | |
|     override var bounds: CGRect {
 | |
|         didSet {
 | |
|             previewLayer.frame = bounds
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     init(session: AVCaptureSession) {
 | |
|         previewLayer = AVCaptureVideoPreviewLayer(session: session)
 | |
|         super.init(frame: .zero)
 | |
|         self.contentMode = .scaleAspectFill
 | |
|         previewLayer.frame = bounds
 | |
|         layer.addSublayer(previewLayer)
 | |
|     }
 | |
| 
 | |
|     required init?(coder aDecoder: NSCoder) {
 | |
|         fatalError("init(coder:) has not been implemented")
 | |
|     }
 | |
| }
 | |
| 
 | |
| class RecordingTimerView: UIView {
 | |
| 
 | |
|     let stackViewSpacing: CGFloat = 4
 | |
| 
 | |
|     override init(frame: CGRect) {
 | |
|         super.init(frame: frame)
 | |
| 
 | |
|         let stackView = UIStackView(arrangedSubviews: [icon, label])
 | |
|         stackView.axis = .horizontal
 | |
|         stackView.alignment = .center
 | |
|         stackView.spacing = stackViewSpacing
 | |
| 
 | |
|         addSubview(stackView)
 | |
|         stackView.autoPinEdgesToSuperviewMargins()
 | |
| 
 | |
|         updateView()
 | |
|     }
 | |
| 
 | |
|     required init?(coder aDecoder: NSCoder) {
 | |
|         fatalError("init(coder:) has not been implemented")
 | |
|     }
 | |
| 
 | |
|     // MARK: - Subviews
 | |
| 
 | |
|     private lazy var label: UILabel = {
 | |
|         let label: UILabel = UILabel()
 | |
|         label.font = UIFont.monospacedDigitSystemFont(ofSize: 20, weight: .regular)
 | |
|         label.themeTextColor = .textPrimary
 | |
|         label.textAlignment = .center
 | |
|         label.layer.shadowOffset = CGSize.zero
 | |
|         label.layer.shadowOpacity = 0.35
 | |
|         label.layer.shadowRadius = 4
 | |
| 
 | |
|         return label
 | |
|     }()
 | |
| 
 | |
|     static let iconWidth: CGFloat = 6
 | |
| 
 | |
|     private let icon: UIView = {
 | |
|         let icon = CircleView()
 | |
|         icon.layer.shadowOffset = CGSize.zero
 | |
|         icon.layer.shadowOpacity = 0.35
 | |
|         icon.layer.shadowRadius = 4
 | |
|         icon.themeBackgroundColor = .danger
 | |
|         icon.autoSetDimensions(to: CGSize(width: iconWidth, height: iconWidth))
 | |
|         icon.alpha = 0
 | |
| 
 | |
|         return icon
 | |
|     }()
 | |
| 
 | |
|     // MARK: -
 | |
|     var recordingStartTime: TimeInterval?
 | |
| 
 | |
|     func startCounting() {
 | |
|         recordingStartTime = CACurrentMediaTime()
 | |
|         timer = Timer.weakScheduledTimer(withTimeInterval: 0.1, target: self, selector: #selector(updateView), userInfo: nil, repeats: true)
 | |
|         UIView.animate(
 | |
|             withDuration: 0.5,
 | |
|             delay: 0,
 | |
|             options: [.autoreverse, .repeat],
 | |
|             animations: { self.icon.alpha = 1 }
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     func stopCounting() {
 | |
|         timer?.invalidate()
 | |
|         timer = nil
 | |
|         icon.layer.removeAllAnimations()
 | |
|         UIView.animate(withDuration: 0.4) {
 | |
|             self.icon.alpha = 0
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: -
 | |
| 
 | |
|     private var timer: Timer?
 | |
| 
 | |
|     private lazy var timeFormatter: DateFormatter = {
 | |
|         let formatter = DateFormatter()
 | |
|         formatter.dateFormat = "mm:ss"
 | |
|         formatter.timeZone = TimeZone(identifier: "UTC")!
 | |
| 
 | |
|         return formatter
 | |
|     }()
 | |
| 
 | |
|     // This method should only be called when the call state is "connected".
 | |
|     var recordingDuration: TimeInterval {
 | |
|         guard let recordingStartTime = recordingStartTime else {
 | |
|             return 0
 | |
|         }
 | |
| 
 | |
|         return CACurrentMediaTime() - recordingStartTime
 | |
|     }
 | |
| 
 | |
|     @objc
 | |
|     private func updateView() {
 | |
|         let recordingDuration = self.recordingDuration
 | |
|         Logger.verbose("recordingDuration: \(recordingDuration)")
 | |
|         let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
 | |
|         label.text = timeFormatter.string(from: durationDate)
 | |
|     }
 | |
| }
 |