Signal-iOS/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift

1248 lines
46 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import AVFoundation
import Foundation
import Lottie
import Photos
import UIKit
import SignalMessaging
protocol PhotoCaptureViewControllerDelegate: AnyObject {
func photoCaptureViewControllerDidFinish(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewControllerDidTryToCaptureTooMany(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewControllerCanCaptureMoreItems(_ photoCaptureViewController: PhotoCaptureViewController) -> Bool
func photoCaptureViewControllerDidRequestPresentPhotoLibrary(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didRequestSwitchBatchMode batchMode: Bool) -> Bool
}
protocol PhotoCaptureViewControllerDataSource: AnyObject {
var numberOfMediaItems: Int { get }
func addMedia(attachment: SignalAttachment)
}
enum PhotoCaptureError: Error {
case assertionError(description: String)
case initializationFailed
case captureFailed
case invalidVideo
}
extension PhotoCaptureError: LocalizedError, UserErrorDescriptionProvider {
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, .invalidVideo:
return NSLocalizedString("PHOTO_CAPTURE_GENERIC_ERROR", comment: "alert title, generic error preventing user from capturing a photo")
}
}
}
class PhotoCaptureViewController: OWSViewController, InteractiveDismissDelegate {
weak var delegate: PhotoCaptureViewControllerDelegate?
weak var dataSource: PhotoCaptureViewControllerDataSource?
private var interactiveDismiss: PhotoCaptureInteractiveDismiss?
public lazy var photoCapture = PhotoCapture()
deinit {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
photoCapture.stopCapture().done {
Logger.debug("stopCapture completed")
}
}
// MARK: - Overrides
override func loadView() {
view = UIView()
view.backgroundColor = Theme.darkThemeBackgroundColor
view.preservesSuperviewLayoutMargins = true
definesPresentationContext = true
initializeUI()
}
override func viewDidLoad() {
super.viewDidLoad()
setupPhotoCapture()
// If the view is already visible, setup the volume button listener
// now that the capture UI is ready. Otherwise, we'll wait until
// we're visible.
if isVisible {
VolumeButtons.shared?.addObserver(observer: photoCapture)
}
updateFlashModeControl()
view.addGestureRecognizer(pinchZoomGesture)
view.addGestureRecognizer(tapToFocusGesture)
view.addGestureRecognizer(doubleTapToSwitchCameraGesture)
if let navController = self.navigationController {
let interactiveDismiss = PhotoCaptureInteractiveDismiss(viewController: navController)
interactiveDismiss.interactiveDismissDelegate = self
interactiveDismiss.addGestureRecognizer(to: view)
self.interactiveDismiss = interactiveDismiss
}
tapToFocusGesture.require(toFail: doubleTapToSwitchCameraGesture)
bottomBar.photoLibraryButton.configure()
if let sideBar = sideBar {
sideBar.photoLibraryButton.configure()
}
}
private var isVisible = false
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
isVisible = true
let previewOrientation: AVCaptureVideoOrientation
if UIDevice.current.isIPad {
previewOrientation = AVCaptureVideoOrientation(interfaceOrientation: CurrentAppContext().interfaceOrientation) ?? .portrait
} else {
previewOrientation = .portrait
}
UIViewController.attemptRotationToDeviceOrientation()
photoCapture.updateVideoPreviewConnection(toOrientation: previewOrientation)
updateIconOrientations(isAnimated: false, captureOrientation: previewOrientation)
resumePhotoCapture()
if let dataSource = dataSource, dataSource.numberOfMediaItems > 0 {
isInBatchMode = true
}
updateDoneButtonAppearance()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if hasCaptureStarted {
BenchEventComplete(eventId: "Show-Camera")
VolumeButtons.shared?.addObserver(observer: photoCapture)
}
UIApplication.shared.isIdleTimerDisabled = true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isVisible = false
VolumeButtons.shared?.removeObserver(photoCapture)
pausePhotoCapture()
UIApplication.shared.isIdleTimerDisabled = false
}
override var prefersStatusBarHidden: Bool {
guard !CurrentAppContext().hasActiveCall else {
return false
}
return true
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
return .portrait
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
if UIDevice.current.isIPad {
// Since we support iPad multitasking, we cannot *disable* rotation of our views.
// Rotating the preview layer is really distracting, so we fade out the preview layer
// while the rotation occurs.
self.previewView.alpha = 0
coordinator.animate(alongsideTransition: { _ in },
completion: { _ in
UIView.animate(withDuration: 0.1) {
self.previewView.alpha = 1
}
})
}
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
// we pin to a constant rather than margin, because on notched devices the
// safeAreaInsets/margins change as the device rotates *EVEN THOUGH* the interface
// is locked to portrait.
// Only grab this once -- otherwise when we swipe to dismiss this is updated and the top bar jumps to having zero offset
if topBarOffsetFromTop.constant == 0 {
let maxInsetDimension = max(view.safeAreaInsets.top, view.safeAreaInsets.left, view.safeAreaInsets.bottom)
topBarOffsetFromTop.constant = max(maxInsetDimension, previewView.frame.minY)
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular
}
// MARK: - Layout Code
private var isIPadUIInRegularMode = false {
didSet {
guard oldValue != isIPadUIInRegularMode else { return }
updateIPadInterfaceLayout()
}
}
private var isRecordingVideo: Bool = false {
didSet {
if isRecordingVideo {
topBar.mode = .videoRecording
topBar.recordingTimerView.startCounting()
bottomBar.captureControl.setState(.recording, animationDuration: 0.4)
if let sideBar = sideBar {
sideBar.cameraCaptureControl.setState(.recording, animationDuration: 0.4)
}
} else {
topBar.mode = isIPadUIInRegularMode ? .closeButton : .cameraControls
topBar.recordingTimerView.stopCounting()
bottomBar.captureControl.setState(.initial, animationDuration: 0.2)
if let sideBar = sideBar {
sideBar.cameraCaptureControl.setState(.initial, animationDuration: 0.2)
}
}
bottomBar.isRecordingVideo = isRecordingVideo
if let sideBar = sideBar {
sideBar.isRecordingVideo = isRecordingVideo
}
doneButton.isHidden = isRecordingVideo || doneButton.badgeNumber == 0
}
}
private var isInBatchMode: Bool = false {
didSet {
let buttonImage = isInBatchMode ? ButtonImages.batchModeOn : ButtonImages.batchModeOff
topBar.batchModeButton.setImage(buttonImage, for: .normal)
if let sideBar = sideBar {
sideBar.batchModeButton.setImage(buttonImage, for: .normal)
}
}
}
private let topBar = TopBar(frame: .zero)
private var topBarOffsetFromTop: NSLayoutConstraint!
private let bottomBar = BottomBar(frame: .zero)
private var bottomBarVerticalPositionConstraint: NSLayoutConstraint!
private var sideBar: SideBar? // Optional because most devices are iPhones and will never need this.
private lazy var tapToFocusView: AnimationView = {
let view = AnimationView(name: "tap_to_focus")
view.animationSpeed = 1
view.backgroundBehavior = .forceFinish
view.contentMode = .scaleAspectFit
view.autoSetDimensions(to: CGSize(square: 150))
view.setContentHuggingHigh()
return view
}()
private var previewView: CapturePreviewView {
return photoCapture.previewView
}
private lazy var doneButton: MediaDoneButton = {
let button = MediaDoneButton(type: .custom)
button.badgeNumber = 0
button.userInterfaceStyleOverride = .dark
return button
}()
private var doneButtonIPhoneConstraints: [NSLayoutConstraint]!
private var doneButtonIPadConstraints: [NSLayoutConstraint]!
private func initializeUI() {
// Step 1. Initialize all UI elements for iPhone layout (which can also be used on an iPad).
view.addSubview(previewView)
view.addSubview(topBar)
topBar.mode = .cameraControls
topBar.closeButton.addTarget(self, action: #selector(didTapClose), for: .touchUpInside)
topBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside)
topBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside)
topBar.autoPinWidthToSuperview()
topBarOffsetFromTop = topBar.autoPinEdge(toSuperviewEdge: .top)
view.addSubview(bottomBar)
bottomBar.isCompactHeightLayout = !UIDevice.current.hasIPhoneXNotch
bottomBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside)
bottomBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside)
bottomBar.autoPinWidthToSuperview()
if bottomBar.isCompactHeightLayout {
// On devices with home button bar is simply pinned to the bottom of the screen
// with a margin that defines space under the shutter button.
view.bottomAnchor.constraint(equalTo: bottomBar.bottomAnchor, constant: 32).isActive = true
} else {
// On `notch` devices:
// i. Shutter button is placed 16 pts above the bottom edge of the preview view.
previewView.bottomAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.bottomAnchor, constant: 16).isActive = true
// ii. Other buttons are centered vertically in the black box between
// bottom of the preview view and top of bottom safe area.
bottomBarVerticalPositionConstraint = bottomBar.controlButtonsLayoutGuide.centerYAnchor.constraint(equalTo: previewView.bottomAnchor)
view.addConstraint(bottomBarVerticalPositionConstraint)
}
view.addSubview(doneButton)
doneButton.isHidden = true
doneButton.translatesAutoresizingMaskIntoConstraints = false
doneButtonIPhoneConstraints = [ doneButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
doneButton.centerYAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerYAnchor) ]
view.addConstraints(doneButtonIPhoneConstraints)
doneButton.addTarget(self, action: #selector(didTapDoneButton), for: .touchUpInside)
view.addSubview(tapToFocusView)
tapToFocusView.isUserInteractionEnabled = false
tapToFocusLeftConstraint = tapToFocusView.centerXAnchor.constraint(equalTo: view.leftAnchor)
tapToFocusLeftConstraint.isActive = true
tapToFocusTopConstraint = tapToFocusView.centerYAnchor.constraint(equalTo: view.topAnchor)
tapToFocusTopConstraint.isActive = true
// Step 2. Check if we're running on an iPad and update UI accordingly.
// Note that `traitCollectionDidChange` won't be called during initial view loading process.
isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular
// This background footer doesn't let view controller underneath current VC
// to be visible at the bottom of the screen during interactive dismiss.
if UIDevice.current.hasIPhoneXNotch {
let blackFooter = UIView()
blackFooter.backgroundColor = view.backgroundColor
view.insertSubview(blackFooter, at: 0)
blackFooter.autoPinWidthToSuperview()
blackFooter.autoPinEdge(toSuperviewEdge: .bottom)
blackFooter.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5).isActive = true
}
}
private func initializeIPadSpecificUIIfNecessary() {
guard sideBar == nil else { return }
let sideBar = SideBar(frame: .zero)
sideBar.cameraCaptureControl.delegate = photoCapture
sideBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside)
sideBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside)
sideBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside)
sideBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside)
view.addSubview(sideBar)
sideBar.autoPinTrailingToSuperviewMargin()
sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
self.sideBar = sideBar
sideBar.batchModeButton.setImage(isInBatchMode ? ButtonImages.batchModeOn : ButtonImages.batchModeOff, for: .normal)
updateFlashModeControl()
doneButtonIPadConstraints = [ doneButton.centerXAnchor.constraint(equalTo: sideBar.centerXAnchor),
doneButton.bottomAnchor.constraint(equalTo: sideBar.topAnchor, constant: -16)]
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard !(interactiveDismiss?.interactionInProgress ?? false) else { return }
// Clamp capture view to 16:9 on iPhones.
var previewFrame = view.bounds
var cornerRadius: CGFloat = 0
if !UIDevice.current.isIPad {
let targetAspectRatio: CGFloat = 16/9
let currentAspectRatio: CGFloat = previewFrame.height / previewFrame.width
if abs(currentAspectRatio - targetAspectRatio) > 0.001 {
previewFrame.y = view.safeAreaInsets.top
previewFrame.height = previewFrame.width * targetAspectRatio
cornerRadius = 18
}
}
previewView.frame = previewFrame
previewView.previewLayer.cornerRadius = cornerRadius
// See comment in `initializeUI`.
if !bottomBar.isCompactHeightLayout {
let blackBarHeight = view.bounds.maxY - previewFrame.maxY - view.safeAreaInsets.bottom
bottomBarVerticalPositionConstraint.constant = 0.5 * blackBarHeight
}
}
private func updateIPadInterfaceLayout() {
owsAssertDebug(UIDevice.current.isIPad)
if isIPadUIInRegularMode {
initializeIPadSpecificUIIfNecessary()
view.removeConstraints(doneButtonIPhoneConstraints)
view.addConstraints(doneButtonIPadConstraints)
} else {
view.removeConstraints(doneButtonIPadConstraints)
view.addConstraints(doneButtonIPhoneConstraints)
}
if !isRecordingVideo {
topBar.mode = isIPadUIInRegularMode ? .closeButton : .cameraControls
}
bottomBar.isHidden = isIPadUIInRegularMode
sideBar?.isHidden = !isIPadUIInRegularMode
}
func updateDoneButtonAppearance () {
if isInBatchMode, let badgeNumber = dataSource?.numberOfMediaItems {
doneButton.badgeNumber = badgeNumber
doneButton.isHidden = false
} else {
doneButton.isHidden = true
}
}
// MARK: - Interactive Dismiss
func interactiveDismissDidBegin(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
view.backgroundColor = .clear
}
func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
dismiss(animated: true)
}
func interactiveDismissDidCancel(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
view.backgroundColor = Theme.darkThemeBackgroundColor
}
// MARK: - Gestures
lazy var pinchZoomGesture: UIPinchGestureRecognizer = {
return UIPinchGestureRecognizer(target: self, action: #selector(didPinchZoom(pinchGesture:)))
}()
lazy var tapToFocusGesture: 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() {
delegate?.photoCaptureViewControllerDidCancel(self)
}
@objc
func didTapSwitchCamera() {
switchCamera()
}
@objc
func didDoubleTapToSwitchCamera(tapGesture: UITapGestureRecognizer) {
guard !isRecordingVideo else {
// - Orientation gets out of sync when switching cameras mid movie.
// - Audio gets out of sync when switching cameras mid movie
// https://stackoverflow.com/questions/13951182/audio-video-out-of-sync-after-switch-camera
return
}
switchCamera()
}
private func switchCamera() {
if let switchCameraButton = isIPadUIInRegularMode ? sideBar?.switchCameraButton : bottomBar.switchCameraButton {
UIView.animate(withDuration: 0.2) {
let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001
switchCameraButton.transform = switchCameraButton.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation)
}
}
photoCapture.switchCamera().catch { error in
self.showFailureUI(error: error)
}
}
@objc
func didTapFlashMode() {
firstly {
photoCapture.switchFlashMode()
}.done {
self.updateFlashModeControl()
}.catch { error in
owsFailDebug("Error: \(error)")
}
}
@objc
func didTapBatchMode() {
guard let delegate = delegate else {
return
}
isInBatchMode = delegate.photoCaptureViewController(self, didRequestSwitchBatchMode: !isInBatchMode)
}
@objc
func didTapPhotoLibrary() {
delegate?.photoCaptureViewControllerDidRequestPresentPhotoLibrary(self)
}
@objc
func didTapDoneButton() {
delegate?.photoCaptureViewControllerDidFinish(self)
}
@objc
func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) {
switch pinchGesture.state {
case .began, .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)
// // If the user taps near the capture button, it's more likely a mis-tap than intentional.
// // Skip the focus animation in that case, since it looks bad.
// let captureButtonOrigin = captureButton.superview!.convert(captureButton.frame.origin, to: view)
// if UIDevice.current.isIPad {
// guard viewLocation.x < captureButtonOrigin.x else {
// Logger.verbose("Skipping animation for right edge on iPad")
//
// // Finish any outstanding focus animation, otherwise it will remain in an
// // uncompleted state.
// if let lastUserFocusTapPoint = lastUserFocusTapPoint {
// completeFocusAnimation(forFocusPoint: lastUserFocusTapPoint)
// }
// return
// }
// } else {
// guard viewLocation.y < captureButtonOrigin.y else {
// Logger.verbose("Skipping animation for bottom row on iPhone")
//
// // Finish any outstanding focus animation, otherwise it will remain in an
// // uncompleted state.
// if let lastUserFocusTapPoint = lastUserFocusTapPoint {
// completeFocusAnimation(forFocusPoint: lastUserFocusTapPoint)
// }
// return
// }
// }
lastUserFocusTapPoint = devicePoint
do {
let convertedPoint = tapToFocusView.superview!.convert(viewLocation, from: view)
positionTapToFocusView(center: convertedPoint)
tapToFocusView.superview?.layoutIfNeeded()
startFocusAnimation()
}
}
// MARK: - Focus Animations
private var tapToFocusLeftConstraint: NSLayoutConstraint!
private var tapToFocusTopConstraint: NSLayoutConstraint!
private var lastUserFocusTapPoint: CGPoint?
private func positionTapToFocusView(center: CGPoint) {
tapToFocusLeftConstraint.constant = center.x
tapToFocusTopConstraint.constant = center.y
}
private func startFocusAnimation() {
tapToFocusView.stop()
tapToFocusView.play(fromProgress: 0.0, toProgress: 0.9)
}
private func completeFocusAnimation(forFocusPoint focusPoint: CGPoint) {
guard let lastUserFocusTapPoint = lastUserFocusTapPoint else { return }
guard lastUserFocusTapPoint.within(0.005, of: focusPoint) else {
Logger.verbose("focus completed for obsolete focus point. User has refocused.")
return
}
tapToFocusView.play(toProgress: 1.0)
}
// MARK: - Orientation
private func updateIconOrientations(isAnimated: Bool, captureOrientation: AVCaptureVideoOrientation) {
guard !UIDevice.current.isIPad else { return }
Logger.verbose("captureOrientation: \(captureOrientation)")
let transformFromOrientation: CGAffineTransform
switch captureOrientation {
case .portrait:
transformFromOrientation = .identity
case .portraitUpsideDown:
transformFromOrientation = CGAffineTransform(rotationAngle: .pi)
case .landscapeRight:
transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi)
case .landscapeLeft:
transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi)
@unknown default:
owsFailDebug("unexpected captureOrientation: \(captureOrientation.rawValue)")
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 buttonsToUpdate: [UIView] = [ topBar.batchModeButton, topBar.flashModeButton, bottomBar.photoLibraryButton ]
let updateOrientation = {
buttonsToUpdate.forEach { $0.transform = transformFromOrientation }
self.bottomBar.switchCameraButton.transform = transformFromOrientation.concatenating(tranformFromCameraType)
}
if isAnimated {
UIView.animate(withDuration: 0.3, animations: updateOrientation)
} else {
updateOrientation()
}
}
// MARK: - Photo Capture
var hasCaptureStarted = false
private func captureReady() {
self.hasCaptureStarted = true
BenchEventComplete(eventId: "Show-Camera")
}
private func setupPhotoCapture() {
photoCapture.delegate = self
bottomBar.captureControl.delegate = photoCapture
if let sideBar = sideBar {
sideBar.cameraCaptureControl.delegate = photoCapture
}
// If the session is already running, we're good to go.
guard !photoCapture.session.isRunning else {
return self.captureReady()
}
firstly {
photoCapture.prepareVideoCapture()
}.catch { [weak self] error in
guard let self = self else { return }
self.showFailureUI(error: error)
}
}
private func pausePhotoCapture() {
guard photoCapture.session.isRunning else { return }
firstly {
photoCapture.stopCapture()
}.done { [weak self] in
self?.hasCaptureStarted = false
}.catch { [weak self] error in
self?.showFailureUI(error: error)
}
}
private func resumePhotoCapture() {
guard !photoCapture.session.isRunning else { return }
firstly {
photoCapture.resumeCapture()
}.done { [weak self] in
self?.captureReady()
}.catch { [weak self] error in
self?.showFailureUI(error: error)
}
}
private func showFailureUI(error: Error) {
Logger.error("error: \(error)")
OWSActionSheets.showActionSheet(title: nil,
message: error.userErrorDescription,
buttonTitle: CommonStrings.dismissButton,
buttonAction: { [weak self] _ in self?.dismiss(animated: true) })
}
private func updateFlashModeControl() {
let image: UIImage?
switch photoCapture.flashMode {
case .auto:
image = ButtonImages.flashAuto
case .on:
image = ButtonImages.flashOn
case .off:
image = ButtonImages.flashOff
@unknown default:
owsFailDebug("unexpected photoCapture.flashMode: \(photoCapture.flashMode.rawValue)")
image = ButtonImages.flashAuto
}
topBar.flashModeButton.setImage(image, for: .normal)
if let sideBar = sideBar {
sideBar.flashModeButton.setImage(image, for: .normal)
}
}
}
private struct ButtonImages {
static let close = UIImage(named: "media-composer-close")
static let switchCamera = UIImage(named: "media-composer-switch-camera-24")
static let batchModeOn = UIImage(named: "media-composer-create-album-solid-24")
static let batchModeOff = UIImage(named: "media-composer-create-album-outline-24")
static let flashOn = UIImage(named: "media-composer-flash-filled-24")
static let flashOff = UIImage(named: "media-composer-flash-outline-24")
static let flashAuto = UIImage(named: "media-composer-flash-auto-24")
}
private class TopBar: UIView {
private(set) var closeButton: CameraOverlayButton!
private var cameraControlsContainerView: UIView!
private(set) var flashModeButton: CameraOverlayButton!
private(set) var batchModeButton: CameraOverlayButton!
private(set) var recordingTimerView: RecordingTimerView!
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 4)
closeButton = CameraOverlayButton(image: ButtonImages.close, userInterfaceStyleOverride: .dark)
addSubview(closeButton)
closeButton.autoPinHeightToSuperviewMargins()
closeButton.autoPinLeadingToSuperviewMargin()
recordingTimerView = RecordingTimerView(frame: .zero)
addSubview(recordingTimerView)
recordingTimerView.autoPinHeightToSuperview(withMargin: 8)
recordingTimerView.autoHCenterInSuperview()
flashModeButton = CameraOverlayButton(image: ButtonImages.flashAuto, userInterfaceStyleOverride: .dark)
batchModeButton = CameraOverlayButton(image: ButtonImages.batchModeOff, userInterfaceStyleOverride: .dark)
let stackView = UIStackView(arrangedSubviews: [ batchModeButton, flashModeButton ])
stackView.spacing = 16
addSubview(stackView)
stackView.autoPinHeightToSuperviewMargins()
stackView.autoPinTrailingToSuperviewMargin()
cameraControlsContainerView = stackView
}
// MARK: - Mode
enum Mode {
case cameraControls, closeButton, videoRecording
}
var mode: Mode = .cameraControls {
didSet {
switch mode {
case .cameraControls:
closeButton.isHidden = false
cameraControlsContainerView.isHidden = false
recordingTimerView?.isHidden = true
case .closeButton:
closeButton.isHidden = false
cameraControlsContainerView.isHidden = true
recordingTimerView?.isHidden = true
case .videoRecording:
closeButton.isHidden = true
cameraControlsContainerView.isHidden = true
recordingTimerView.isHidden = false
}
}
}
}
private class BottomBar: UIView {
private var compactHeightLayoutConstraints = [NSLayoutConstraint]()
private var regularHeightLayoutConstraints = [NSLayoutConstraint]()
var isCompactHeightLayout = false {
didSet {
guard oldValue != isCompactHeightLayout else { return }
updateCompactHeightLayoutConstraints()
}
}
var isRecordingVideo = false {
didSet {
photoLibraryButton.isHidden = isRecordingVideo
switchCameraButton.isHidden = isRecordingVideo
}
}
private(set) var photoLibraryButton: MediaPickerThumbnailButton!
private(set) var switchCameraButton: CameraOverlayButton!
let controlButtonsLayoutGuide = UILayoutGuide() // area encompassing Photo Library and Switch Camera buttons.
private(set) var captureControl: CameraCaptureControl!
var shutterButtonLayoutGuide: UILayoutGuide {
captureControl.shutterButtonLayoutGuide
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
layoutMargins = UIEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 14)
addLayoutGuide(controlButtonsLayoutGuide)
addConstraints([ controlButtonsLayoutGuide.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
controlButtonsLayoutGuide.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor) ])
photoLibraryButton = MediaPickerThumbnailButton(frame: CGRect(origin: .zero, size: .square(bounds.height)))
photoLibraryButton.translatesAutoresizingMaskIntoConstraints = false
addSubview(photoLibraryButton)
addConstraints([ photoLibraryButton.leadingAnchor.constraint(equalTo: controlButtonsLayoutGuide.leadingAnchor),
photoLibraryButton.centerYAnchor.constraint(equalTo: controlButtonsLayoutGuide.centerYAnchor),
photoLibraryButton.topAnchor.constraint(greaterThanOrEqualTo: controlButtonsLayoutGuide.topAnchor) ])
switchCameraButton = CameraOverlayButton(image: ButtonImages.switchCamera, userInterfaceStyleOverride: .dark)
switchCameraButton.translatesAutoresizingMaskIntoConstraints = false
addSubview(switchCameraButton)
addConstraints([ switchCameraButton.trailingAnchor.constraint(equalTo: controlButtonsLayoutGuide.trailingAnchor),
switchCameraButton.topAnchor.constraint(greaterThanOrEqualTo: controlButtonsLayoutGuide.topAnchor),
switchCameraButton.centerYAnchor.constraint(equalTo: controlButtonsLayoutGuide.centerYAnchor) ])
captureControl = CameraCaptureControl(axis: .horizontal)
captureControl.translatesAutoresizingMaskIntoConstraints = false
addSubview(captureControl)
captureControl.autoPinTopToSuperviewMargin()
captureControl.autoPinTrailingToSuperviewMargin()
addConstraint(captureControl.shutterButtonLayoutGuide.centerXAnchor.constraint(equalTo: centerXAnchor))
compactHeightLayoutConstraints.append(contentsOf: [ controlButtonsLayoutGuide.centerYAnchor.constraint(equalTo: captureControl.shutterButtonLayoutGuide.centerYAnchor),
captureControl.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) ])
regularHeightLayoutConstraints.append(contentsOf: [ controlButtonsLayoutGuide.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
captureControl.bottomAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.bottomAnchor) ])
updateCompactHeightLayoutConstraints()
}
private func updateCompactHeightLayoutConstraints() {
if isCompactHeightLayout {
removeConstraints(regularHeightLayoutConstraints)
addConstraints(compactHeightLayoutConstraints)
} else {
removeConstraints(compactHeightLayoutConstraints)
addConstraints(regularHeightLayoutConstraints)
}
}
}
private class SideBar: UIView {
var isRecordingVideo = false {
didSet {
cameraControlsContainerView.isHidden = isRecordingVideo
photoLibraryButton.isHidden = isRecordingVideo
}
}
private var cameraControlsContainerView: UIView!
private(set) var flashModeButton: CameraOverlayButton!
private(set) var batchModeButton: CameraOverlayButton!
private(set) var switchCameraButton: CameraOverlayButton!
private(set) var photoLibraryButton: MediaPickerThumbnailButton!
private(set) var cameraCaptureControl = CameraCaptureControl(axis: .vertical)
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
layoutMargins = UIEdgeInsets(margin: 8)
flashModeButton = CameraOverlayButton(image: ButtonImages.flashAuto, userInterfaceStyleOverride: .dark)
switchCameraButton = CameraOverlayButton(image: ButtonImages.switchCamera, userInterfaceStyleOverride: .dark)
batchModeButton = CameraOverlayButton(image: ButtonImages.batchModeOff, userInterfaceStyleOverride: .dark)
let stackView = UIStackView(arrangedSubviews: [ batchModeButton, flashModeButton, switchCameraButton ])
stackView.spacing = 16
stackView.axis = .vertical
addSubview(stackView)
stackView.autoPinWidthToSuperviewMargins()
stackView.autoPinTopToSuperviewMargin()
cameraControlsContainerView = stackView
addSubview(cameraCaptureControl)
cameraCaptureControl.autoHCenterInSuperview()
cameraCaptureControl.shutterButtonLayoutGuide.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 36).isActive = true
photoLibraryButton = MediaPickerThumbnailButton(frame: CGRect(origin: .zero, size: .square(bounds.height)))
addSubview(photoLibraryButton)
photoLibraryButton.autoHCenterInSuperview()
photoLibraryButton.topAnchor.constraint(equalTo: cameraCaptureControl.shutterButtonLayoutGuide.bottomAnchor, constant: 36).isActive = true
photoLibraryButton.bottomAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.bottomAnchor).isActive = true
}
}
extension PhotoCaptureViewController: PhotoCaptureDelegate {
// MARK: - Photo
func photoCaptureDidStart(_ photoCapture: PhotoCapture) {
let captureFeedbackView = UIView()
captureFeedbackView.backgroundColor = .black
view.insertSubview(captureFeedbackView, aboveSubview: previewView)
captureFeedbackView.autoPinEdgesToSuperviewEdges()
// Ensure the capture feedback is laid out before we remove it,
// depending on where we're coming from a layout pass might not
// trigger in 0.05 seconds otherwise.
view.setNeedsLayout()
view.layoutIfNeeded()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
captureFeedbackView.removeFromSuperview()
}
}
func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessing attachment: SignalAttachment) {
dataSource?.addMedia(attachment: attachment)
updateDoneButtonAppearance()
if !isInBatchMode {
delegate?.photoCaptureViewControllerDidFinish(self)
}
}
func photoCapture(_ photoCapture: PhotoCapture, didFailProcessing error: Error) {
isRecordingVideo = false
if case PhotoCaptureError.invalidVideo = error {
// Don't show an error if the user aborts recording before video
// recording has begun.
return
}
showFailureUI(error: error)
}
func photoCaptureCanCaptureMoreItems(_ photoCapture: PhotoCapture) -> Bool {
return delegate?.photoCaptureViewControllerCanCaptureMoreItems(self) ?? false
}
func photoCaptureDidTryToCaptureTooMany(_ photoCapture: PhotoCapture) {
delegate?.photoCaptureViewControllerDidTryToCaptureTooMany(self)
}
// MARK: - Video
func photoCaptureDidBeginRecording(_ photoCapture: PhotoCapture) {
isRecordingVideo = true
}
func photoCaptureDidFinishRecording(_ photoCapture: PhotoCapture) {
isRecordingVideo = false
}
func photoCaptureDidCancelRecording(_ photoCapture: PhotoCapture) {
isRecordingVideo = false
}
// MARK: -
var zoomScaleReferenceDistance: CGFloat? {
if isIPadUIInRegularMode {
return view.bounds.width
}
return view.bounds.height
}
func beginCaptureButtonAnimation(_ duration: TimeInterval) {
bottomBar.captureControl.setState(.recording, animationDuration: duration)
if let sideBar = sideBar {
sideBar.cameraCaptureControl.setState(.recording, animationDuration: duration)
}
}
func endCaptureButtonAnimation(_ duration: TimeInterval) {
bottomBar.captureControl.setState(.initial, animationDuration: duration)
if let sideBar = sideBar {
sideBar.cameraCaptureControl.setState(.initial, animationDuration: duration)
}
}
func photoCapture(_ photoCapture: PhotoCapture, didChangeOrientation orientation: AVCaptureVideoOrientation) {
updateIconOrientations(isAnimated: true, captureOrientation: orientation)
if UIDevice.current.isIPad {
photoCapture.updateVideoPreviewConnection(toOrientation: orientation)
}
}
func photoCapture(_ photoCapture: PhotoCapture, didCompleteFocusing focusPoint: CGPoint) {
completeFocusAnimation(forFocusPoint: focusPoint)
}
}
// MARK: - Views
private class MediaPickerThumbnailButton: UIButton {
private static let visibleSize = CGSize(square: 36)
func configure() {
layer.cornerRadius = 10
layer.borderWidth = 1.5
layer.borderColor = UIColor.ows_whiteAlpha80.cgColor
clipsToBounds = true
let placeholderView = UIVisualEffectView(effect: UIBlurEffect(style: .light))
insertSubview(placeholderView, at: 0)
placeholderView.autoPinEdgesToSuperviewEdges()
// Async Fetch last image
DispatchQueue.global(qos: .userInteractive).async {
let fetchOptions = PHFetchOptions()
fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
fetchOptions.fetchLimit = 1
let fetchResult = PHAsset.fetchAssets(with: PHAssetMediaType.image, options: fetchOptions)
if fetchResult.count > 0, let asset = fetchResult.firstObject {
let targetImageSize = MediaPickerThumbnailButton.visibleSize
PHImageManager.default().requestImage(for: asset, targetSize: targetImageSize, contentMode: .aspectFill, options: nil) { (image, _) in
DispatchQueue.main.async {
self.setImage(image, for: .normal)
placeholderView.alpha = 0
}
}
}
}
}
override var intrinsicContentSize: CGSize {
return Self.visibleSize
}
}
class CapturePreviewView: UIView {
let previewLayer: AVCaptureVideoPreviewLayer
override var bounds: CGRect {
didSet {
previewLayer.frame = bounds
}
}
override var frame: CGRect {
didSet {
previewLayer.frame = bounds
}
}
override var contentMode: UIView.ContentMode {
get {
switch previewLayer.videoGravity {
case .resizeAspectFill:
return .scaleAspectFill
case .resizeAspect:
return .scaleAspectFit
case .resize:
return .scaleToFill
default:
owsFailDebug("Unexpected contentMode")
return .scaleToFill
}
}
set {
switch newValue {
case .scaleAspectFill:
previewLayer.videoGravity = .resizeAspectFill
case .scaleAspectFit:
previewLayer.videoGravity = .resizeAspect
case .scaleToFill:
previewLayer.videoGravity = .resize
default:
owsFailDebug("Unexpected contentMode")
}
}
}
init(session: AVCaptureSession) {
previewLayer = AVCaptureVideoPreviewLayer(session: session)
if Platform.isSimulator {
// helpful for debugging layout on simulator which has no real capture device
previewLayer.backgroundColor = UIColor.green.withAlphaComponent(0.4).cgColor
}
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")
}
}
private class RecordingTimerView: PillView {
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 0)
let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
addSubview(backgroundView)
backgroundView.autoPinEdgesToSuperviewEdges()
let stackView = UIStackView(arrangedSubviews: [icon, label])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.spacing = 5
addSubview(stackView)
stackView.autoPinEdgesToSuperviewMargins()
updateView()
}
// MARK: - Subviews
private lazy var label: UILabel = {
let label = UILabel()
label.font = UIFont.ows_monospacedDigitFont(withSize: 20)
label.textAlignment = .center
label.textColor = UIColor.white
return label
}()
private let icon: UIView = {
let icon = CircleView()
icon.backgroundColor = .red
icon.autoSetDimensions(to: CGSize(square: 6))
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
}
label.text = nil
}
// 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
let durationDate = Date(timeIntervalSinceReferenceDate: recordingDuration)
label.text = timeFormatter.string(from: durationDate)
}
}
private extension UIView {
func embeddedInContainerView(layoutMargins: UIEdgeInsets = .zero) -> UIView {
var containerViewFrame = bounds
containerViewFrame.width += layoutMargins.leading + layoutMargins.trailing
containerViewFrame.height += layoutMargins.top + layoutMargins.bottom
let containerView = UIView(frame: containerViewFrame)
containerView.layoutMargins = layoutMargins
containerView.addSubview(self)
autoPinEdgesToSuperviewMargins()
return containerView
}
}