From 06e1c999876aa15b0094788eccf5cd54ac73ecc4 Mon Sep 17 00:00:00 2001 From: Andreas Traczyk Date: Wed, 17 Jan 2018 14:07:08 -0500 Subject: [PATCH] video calls: support device rotation Change-Id: If5e224fedf0f4447eebff3753961a019118395b7 Reviewed-by: Andreas Traczyk --- Ring/Ring/AppDelegate.swift | 23 ++++ Ring/Ring/Calls/CallViewController.storyboard | 15 ++- Ring/Ring/Calls/CallViewController.swift | 70 +++++++++--- Ring/Ring/Calls/CallViewModel.swift | 12 ++- Ring/Ring/Extensions/UIImage+Helpers.swift | 12 +++ Ring/Ring/Services/VideoService.swift | 101 +++++++++++++++--- 6 files changed, 195 insertions(+), 38 deletions(-) diff --git a/Ring/Ring/AppDelegate.swift b/Ring/Ring/AppDelegate.swift index caf3db3..22a60fc 100644 --- a/Ring/Ring/AppDelegate.swift +++ b/Ring/Ring/AppDelegate.swift @@ -286,6 +286,29 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } completionHandler() } + + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + if let rootViewController = self.topViewControllerWithRootViewController(rootViewController: window?.rootViewController) { + if (rootViewController.responds(to: Selector(("canRotate")))) { + return .all + } + } + return .portrait + } + + private func topViewControllerWithRootViewController(rootViewController: UIViewController!) -> UIViewController? { + if rootViewController == nil { + return nil + } + if rootViewController.isKind(of: (UITabBarController).self) { + return topViewControllerWithRootViewController(rootViewController: (rootViewController as! UITabBarController).selectedViewController) + } else if rootViewController.isKind(of: (UINavigationController).self) { + return topViewControllerWithRootViewController(rootViewController: (rootViewController as! UINavigationController).visibleViewController) + } else if rootViewController.presentedViewController != nil { + return topViewControllerWithRootViewController(rootViewController: rootViewController.presentedViewController) + } + return rootViewController + } } extension AppDelegate: PKPushRegistryDelegate { diff --git a/Ring/Ring/Calls/CallViewController.storyboard b/Ring/Ring/Calls/CallViewController.storyboard index bd2af8b..f3e9574 100644 --- a/Ring/Ring/Calls/CallViewController.storyboard +++ b/Ring/Ring/Calls/CallViewController.storyboard @@ -118,7 +118,7 @@ - + @@ -141,7 +141,7 @@ - + @@ -165,7 +165,7 @@ - + @@ -183,15 +183,17 @@ - + + + @@ -214,6 +216,8 @@ + + @@ -224,7 +228,8 @@ - + + diff --git a/Ring/Ring/Calls/CallViewController.swift b/Ring/Ring/Calls/CallViewController.swift index 75ed034..7661d6c 100644 --- a/Ring/Ring/Calls/CallViewController.swift +++ b/Ring/Ring/Calls/CallViewController.swift @@ -44,7 +44,10 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased { @IBOutlet private weak var audioOnlyImage: UIImageView! @IBOutlet private weak var callNameLabel: UILabel! @IBOutlet private weak var callInfoTimerLabel: UILabel! - @IBOutlet private weak var infoLabelConstraint: NSLayoutConstraint! + @IBOutlet private weak var infoLabelTopConstraint: NSLayoutConstraint! + @IBOutlet private weak var callButtonsLeftConstraint: NSLayoutConstraint! + @IBOutlet private weak var callButtonsRightConstraint: NSLayoutConstraint! + @IBOutlet private weak var infoLabelHeightConstraint: NSLayoutConstraint! @IBOutlet private weak var callPulse: UIView! @IBOutlet private weak var buttonsContainer: ButtonsContainerView! @@ -57,6 +60,8 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased { private var task: DispatchWorkItem? + private var shouldRotateScreen = false + override func viewDidLoad() { super.viewDidLoad() let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(screenTapped)) @@ -156,6 +161,12 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased { .bind(to: self.buttonsContainer.pauseCallButton.rx.image()) .disposed(by: self.disposeBag) + self.viewModel.isActiveVideoCall + .observeOn(MainScheduler.instance) + .subscribe(onNext: { [weak self] rotate in + self?.shouldRotateScreen = rotate + }).disposed(by: self.disposeBag) + // disable switch camera button for audio only calls self.buttonsContainer.switchCameraButton.isEnabled = !(self.viewModel.isAudioOnly) } @@ -285,24 +296,40 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased { self.view.layoutIfNeeded() } + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + let orientation = UIDevice.current.orientation + switch orientation { + case .landscapeRight, .landscapeLeft: + let height = size.height - 150 + self.infoLabelHeightConstraint.constant = height + default: + self.infoLabelHeightConstraint.constant = 200 + } + self.viewModel.setCameraOrientation(orientation: UIDevice.current.orientation) + super.viewWillTransition(to: size, with: coordinator) + } + func showContactInfo() { if !self.infoContainer.isHidden { task?.cancel() self.hideContactInfo() return } - - self.infoLabelConstraint.constant = -200.00 + self.infoLabelTopConstraint.constant = -200.00 + self.callButtonsRightConstraint.constant = self.view.bounds.width + self.callButtonsLeftConstraint.constant = -self.view.bounds.width self.buttonsContainer.isHidden = false self.infoContainer.isHidden = false self.view.layoutIfNeeded() UIView.animate(withDuration: 0.2, delay: 0.0, options: .curveEaseOut, - animations: { [weak self] in - self?.infoLabelConstraint.constant = 0.00 - self?.view.layoutIfNeeded() - }, completion: nil) + animations: { [unowned self] in + self.infoLabelTopConstraint.constant = 0.00 + self.callButtonsRightConstraint.constant = 0.00 + self.callButtonsLeftConstraint.constant = 0.00 + self.view.layoutIfNeeded() + }, completion: nil) task = DispatchWorkItem { self.hideContactInfo() } DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 2, execute: task!) @@ -311,18 +338,33 @@ class CallViewController: UIViewController, StoryboardBased, ViewModelBased { func hideContactInfo() { UIView.animate(withDuration: 0.2, delay: 0.00, options: .curveEaseOut, - animations: { [weak self] in - self?.infoLabelConstraint.constant = -200.00 - self?.view.layoutIfNeeded() - }, completion: { [weak self] _ in - self?.infoContainer.isHidden = true - self?.buttonsContainer.isHidden = true + animations: { [unowned self] in + self.infoLabelTopConstraint.constant = -200.00 + self.callButtonsRightConstraint.constant = self.view.bounds.width + self.callButtonsLeftConstraint.constant = -self.view.bounds.width + self.view.layoutIfNeeded() + }, completion: { [weak self] _ in + self?.infoContainer.isHidden = true + self?.buttonsContainer.isHidden = true }) } func showAllInfo() { self.buttonsContainer.isHidden = false self.infoContainer.isHidden = false - self.infoLabelConstraint.constant = 0.00 + self.infoLabelTopConstraint.constant = 0.00 + } + + @objc func canRotate() { + // empty function to support call screen rotation + } + + override func viewWillDisappear(_ animated: Bool) { + UIDevice.current.setValue(Int(UIInterfaceOrientation.portrait.rawValue), forKey: "orientation") + super.viewWillDisappear(animated) + } + + override var shouldAutorotate: Bool { + return self.shouldRotateScreen } } diff --git a/Ring/Ring/Calls/CallViewModel.swift b/Ring/Ring/Calls/CallViewModel.swift index dc86025..3cf29b8 100644 --- a/Ring/Ring/Calls/CallViewModel.swift +++ b/Ring/Ring/Calls/CallViewModel.swift @@ -146,19 +146,19 @@ class CallViewModel: Stateable, ViewModel { }) }() - lazy var shouldRespondOnTap: Observable = { + lazy var isActiveVideoCall: Observable = { return self.callService.currentCall .filter({ [weak self] call in return call.callId == self?.call?.callId }).map({ call in - return call.state == .current + return call.state == .current && !self.isAudioOnly }) }() lazy var showCallOptions: Observable = { return Observable.combineLatest(self.screenTapped.asObservable(), - shouldRespondOnTap) { [unowned self] (tapped, shouldRespond) in - if tapped && shouldRespond && !self.isAudioOnly { + isActiveVideoCall) { [unowned self] (tapped, shouldRespond) in + if tapped && shouldRespond { return true } return false @@ -378,4 +378,8 @@ class CallViewModel: Stateable, ViewModel { func switchSpeaker() { self.audioService.switchSpeaker() } + + func setCameraOrientation(orientation: UIDeviceOrientation) { + videoService.setCameraOrientation(orientation: orientation) + } } diff --git a/Ring/Ring/Extensions/UIImage+Helpers.swift b/Ring/Ring/Extensions/UIImage+Helpers.swift index 0a2222e..ec124e0 100644 --- a/Ring/Ring/Extensions/UIImage+Helpers.swift +++ b/Ring/Ring/Extensions/UIImage+Helpers.swift @@ -93,6 +93,18 @@ extension UIImage { return imageData } + public convenience init?(color: UIColor, size: CGSize = CGSize(width: 1, height: 1)) { + let rect = CGRect(origin: .zero, size: size) + UIGraphicsBeginImageContextWithOptions(rect.size, false, 0.0) + color.setFill() + UIRectFill(rect) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + + guard let cgImage = image?.cgImage else { return nil } + self.init(cgImage: cgImage) + } + func resizeIntoRectangle(of size: CGSize) -> UIImage? { if self.size.width < size.width && self.size.height < size.height { return self diff --git a/Ring/Ring/Services/VideoService.swift b/Ring/Ring/Services/VideoService.swift index ade9068..095a857 100644 --- a/Ring/Ring/Services/VideoService.swift +++ b/Ring/Ring/Services/VideoService.swift @@ -44,9 +44,14 @@ protocol FrameExtractorDelegate: class { class FrameExtractor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { + let nameLandscape = "frontCameraLanscape" + let namePortrait = "frontCameraPortrait" + let nameCamera = "camera://" + private let log = SwiftyBeaver.self private let quality = AVCaptureSession.Preset.medium + private var orientation = AVCaptureVideoOrientation.portrait var permissionGranted = Variable(false) @@ -64,7 +69,7 @@ class FrameExtractor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { super.init() } - func getDeviceInfo(forPosition position: AVCaptureDevice.Position) throws -> DeviceInfo { + func getDeviceInfo(forPosition position: AVCaptureDevice.Position, orientation: UIDeviceOrientation) throws -> DeviceInfo { guard self.permissionGranted.value else { throw VideoError.needPermission } @@ -82,11 +87,19 @@ class FrameExtractor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { bestRate = frameRates.maxFrameRate } } - let devInfo: DeviceInfo = ["format": "BGRA", - "width": String(dimensions.height), - "height": String(dimensions.width), - "rate": String(bestRate)] - return devInfo + if orientation == .portrait || orientation == .portraitUpsideDown { + let devInfo: DeviceInfo = ["format": "BGRA", + "width": String(dimensions.height), + "height": String(dimensions.width), + "rate": String(bestRate)] + return devInfo + } else { + let devInfo: DeviceInfo = ["format": "BGRA", + "width": String(dimensions.width), + "height": String(dimensions.height), + "rate": String(bestRate)] + return devInfo + } } func startCapturing() { @@ -120,7 +133,8 @@ class FrameExtractor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { } } - func configureSession(withPosition position: AVCaptureDevice.Position) throws { + func configureSession(withPosition position: AVCaptureDevice.Position, + withOrientation orientation: AVCaptureVideoOrientation) throws { captureSession.beginConfiguration() guard self.permissionGranted.value else { throw VideoError.needPermission @@ -149,7 +163,7 @@ class FrameExtractor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { guard connection.isVideoMirroringSupported else { throw VideoError.unsupportedParameter } - connection.videoOrientation = .portrait + connection.videoOrientation = orientation connection.isVideoMirrored = position == .front captureSession.commitConfiguration() } @@ -203,7 +217,7 @@ class FrameExtractor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { completable(.error(VideoError.switchCameraFailed)) return Disposables.create {} } - connection.videoOrientation = .portrait + connection.videoOrientation = self.orientation self.captureSession.commitConfiguration() completable(.completed) } else { @@ -213,6 +227,30 @@ class FrameExtractor: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate { } } + func rotateCamera(orientation: AVCaptureVideoOrientation) -> Completable { + return Completable.create { [unowned self] completable in + guard self.permissionGranted.value else { + completable(.error(VideoError.needPermission)) + return Disposables.create {} + } + self.captureSession.beginConfiguration() + let videoOutput = self.captureSession.outputs[0] + guard let connection = videoOutput.connection(with: AVFoundation.AVMediaType.video) else { + completable(.error(VideoError.getConnectionFailed)) + return Disposables.create {} + } + guard connection.isVideoOrientationSupported else { + completable(.error(VideoError.unsupportedParameter)) + return Disposables.create {} + } + self.orientation = orientation + connection.videoOrientation = orientation + self.captureSession.commitConfiguration() + completable(.completed) + return Disposables.create { } + } + } + // MARK: Sample buffer to UIImage conversion private func imageFromSampleBuffer(sampleBuffer: CMSampleBuffer) -> UIImage? { guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return nil } @@ -239,6 +277,7 @@ class VideoService: FrameExtractorDelegate { let capturedVideoFrame = PublishSubject() private let log = SwiftyBeaver.self + private var blockOutgoingFrame = true fileprivate let disposeBag = DisposeBag() @@ -262,11 +301,13 @@ class VideoService: FrameExtractorDelegate { private func enumerateVideoInputDevices() { do { - try camera.configureSession(withPosition: AVCaptureDevice.Position.front) + try camera.configureSession(withPosition: AVCaptureDevice.Position.front, withOrientation: AVCaptureVideoOrientation.portrait) self.log.debug("Camera successfully configured") - let frontCameraDevInfo: [String: String] = try camera.getDeviceInfo(forPosition: AVCaptureDevice.Position.front) - self.log.debug("Camera device info: \(frontCameraDevInfo as AnyObject)") - videoAdapter.addVideoDevice(withName: "frontCamera", withDevInfo: frontCameraDevInfo) + let frontLandscapeCameraDevInfo: [String: String] = try camera.getDeviceInfo(forPosition: AVCaptureDevice.Position.front, orientation: .landscapeLeft) + let frontPortraitCameraDevInfo: [String: String] = try camera.getDeviceInfo(forPosition: AVCaptureDevice.Position.front, orientation: .portrait) + videoAdapter.addVideoDevice(withName: camera.nameLandscape, withDevInfo: frontLandscapeCameraDevInfo) + videoAdapter.addVideoDevice(withName: camera.namePortrait, withDevInfo: frontPortraitCameraDevInfo) + } catch let e as VideoError { self.log.error("Error during capture device enumeration: \(e)") } catch { @@ -282,6 +323,33 @@ class VideoService: FrameExtractorDelegate { print(error) }.disposed(by: self.disposeBag) } + + func setCameraOrientation(orientation: UIDeviceOrientation) { + self.blockOutgoingFrame = true + let deviceName: String = + (orientation == .landscapeLeft || orientation == .landscapeRight) ? + self.camera.nameLandscape : self.camera.namePortrait + self.switchInput(toDevice: self.camera.nameCamera + deviceName) + var newOrientation: AVCaptureVideoOrientation + switch orientation { + case .portrait: + newOrientation = AVCaptureVideoOrientation.portrait + case .portraitUpsideDown: + newOrientation = AVCaptureVideoOrientation.portraitUpsideDown + case .landscapeLeft: + newOrientation = AVCaptureVideoOrientation.landscapeRight + case .landscapeRight: + newOrientation = AVCaptureVideoOrientation.landscapeLeft + default: + newOrientation = AVCaptureVideoOrientation.portrait + } + self.camera.rotateCamera(orientation: newOrientation) + .subscribe(onCompleted: { [unowned self] in + self.log.debug("new camera orientation: \(orientation)") + }, onError: { error in + self.log.debug("camera re-orientation error: \(error)") + }).disposed(by: self.disposeBag) + } } extension VideoService: VideoAdapterDelegate { @@ -306,6 +374,7 @@ extension VideoService: VideoAdapterDelegate { func startCapture(withDevice device: String) { self.log.debug("Capture started...") self.camera.startCapturing() + self.blockOutgoingFrame = false } func stopCapture() { @@ -318,8 +387,10 @@ extension VideoService: VideoAdapterDelegate { } func captured(image: UIImage) { - videoAdapter.writeOutgoingFrame(with: image) self.capturedVideoFrame.onNext(image) + if self.blockOutgoingFrame { + return + } + videoAdapter.writeOutgoingFrame(with: image) } - } -- GitLab