From c156afce6ea526a51f64161ca20fb49c5669532f Mon Sep 17 00:00:00 2001 From: Andrei Date: Thu, 15 Jun 2023 23:24:57 +0300 Subject: [PATCH] Refactored library functioanlity, making views more efficient and composable --- README.md | 128 ++++---- .../CaptureSession.swift | 21 -- .../AVFoundationsInternals/PreviewLayer.swift | 27 -- Sources/CameraKage/CameraKage.swift | 189 ++---------- .../Inputs/Audio}/AudioCaptureDevice.swift | 18 +- .../Devices/Inputs/Audio/AudioInput.swift | 12 + .../Inputs/Video}/VideoCaptureDevice.swift | 57 +++- .../Devices/Inputs/Video/VideoInput.swift | 20 ++ .../Devices/Layers/Video/PreviewLayer.swift | 60 ++++ .../Devices/Layers/Video/VideoLayer.swift | 16 + .../Devices/Outputs/Movie/MovieCapturer.swift | 19 ++ .../Outputs/Movie}/MovieOutput.swift | 54 +++- .../Devices/Outputs/Photo/PhotoCapturer.swift | 16 + .../Outputs/Photo}/PhotoOutput.swift | 43 ++- .../Devices/Session/CaptureSession.swift | 101 +++++++ .../CameraKage/Devices/Session/Session.swift | 20 ++ .../LayerVideoGravity+AVFoundation.swift | 18 ++ ...alityPrioritizationMode+AVFoundation.swift | 18 ++ .../VideoOrientationMode+AVFoundation.swift | 19 ++ .../VideoStabilizationMode+AVFoundation.swift | 20 ++ .../CameraKage/General/Camera/Camera.swift | 226 -------------- .../General/Camera/CameraComponent.swift | 74 ----- .../General/Camera/CameraComposer.swift | 170 ----------- .../Camera/CameraComposerProtocol.swift | 25 -- .../General/CameraViews/BaseCameraView.swift | 196 +++++++++++++ .../General/CameraViews/CameraView.swift | 133 +++++++++ .../General/CameraViews/PhotoCameraView.swift | 76 +++++ .../General/CameraViews/VideoCameraView.swift | 107 +++++++ .../Cameras/BaseCamera/BaseCamera.swift | 104 +++++++ .../BaseCamera/BaseCameraInterface.swift | 32 ++ .../General/Cameras/Camera/Camera.swift | 82 ++++++ .../Cameras/Camera/CameraInterface.swift | 10 + .../Cameras/PhotoCamera/PhotoCamera.swift | 53 ++++ .../PhotoCamera/PhotoCameraInterface.swift | 15 + .../Cameras/VideoCamera/VideoCamera.swift | 63 ++++ .../VideoCamera/VideoCameraInterface.swift | 18 ++ .../General/Delegates/DelegatesManager.swift | 17 +- .../Internal/CameraComposerDelegate.swift | 28 -- .../Delegates/Internal/CameraDelegate.swift | 16 - .../Internal/SessionComposerDelegate.swift | 20 -- .../Delegates/Internal/SessionDelegate.swift | 20 ++ .../Delegates/Public/BaseCameraDelegate.swift | 64 ++++ .../Delegates/Public/CameraDelegate.swift | 10 + .../Delegates/Public/CameraKageDelegate.swift | 103 ------- .../Public/PhotoCameraDelegate.swift | 18 ++ .../Public/VideoCameraDelegate.swift | 28 ++ .../General/Session/SessionComposer.swift | 277 ++++++++++++------ .../Session/SessionComposerProtocol.swift | 19 -- .../Settings/CameraComponentOptions.swift | 45 +-- .../General/Settings/LayerVideoGravity.swift | 19 ++ .../PhotoQualityPrioritizationMode.swift | 19 ++ .../Settings/VideoOrientationMode.swift | 22 ++ .../Settings/VideoStabilizationMode.swift | 25 ++ .../CameraKageTests/BaseCameraViewTests.swift | 202 +++++++++++++ .../CameraKageDelegateMock.swift | 13 - Tests/CameraKageTests/CameraKageTests.swift | 57 ---- .../DelegatesManagerMock.swift | 29 -- .../Mocks/BaseCameraDelegateStub.swift | 13 + .../Mocks/BaseCameraMock.swift | 105 +++++++ .../Mocks/CaptureSessionMock.swift | 41 +++ .../Mocks/DelegatesManagerMock.swift | 27 ++ .../{ => Mocks}/PermissionManagerMock.swift | 0 .../Mocks/VideoInputMock.swift | 59 ++++ .../Mocks/VideoLayerMock.swift | 33 +++ 64 files changed, 2368 insertions(+), 1221 deletions(-) delete mode 100644 Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift delete mode 100644 Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift rename Sources/CameraKage/{AVFoundationsInternals => Devices/Inputs/Audio}/AudioCaptureDevice.swift (71%) create mode 100644 Sources/CameraKage/Devices/Inputs/Audio/AudioInput.swift rename Sources/CameraKage/{AVFoundationsInternals => Devices/Inputs/Video}/VideoCaptureDevice.swift (74%) create mode 100644 Sources/CameraKage/Devices/Inputs/Video/VideoInput.swift create mode 100644 Sources/CameraKage/Devices/Layers/Video/PreviewLayer.swift create mode 100644 Sources/CameraKage/Devices/Layers/Video/VideoLayer.swift create mode 100644 Sources/CameraKage/Devices/Outputs/Movie/MovieCapturer.swift rename Sources/CameraKage/{AVFoundationsInternals => Devices/Outputs/Movie}/MovieOutput.swift (61%) create mode 100644 Sources/CameraKage/Devices/Outputs/Photo/PhotoCapturer.swift rename Sources/CameraKage/{AVFoundationsInternals => Devices/Outputs/Photo}/PhotoOutput.swift (65%) create mode 100644 Sources/CameraKage/Devices/Session/CaptureSession.swift create mode 100644 Sources/CameraKage/Devices/Session/Session.swift create mode 100644 Sources/CameraKage/Extensions/Internal/LayerVideoGravity+AVFoundation.swift create mode 100644 Sources/CameraKage/Extensions/Internal/PhotoQualityPrioritizationMode+AVFoundation.swift create mode 100644 Sources/CameraKage/Extensions/Internal/VideoOrientationMode+AVFoundation.swift create mode 100644 Sources/CameraKage/Extensions/Internal/VideoStabilizationMode+AVFoundation.swift delete mode 100644 Sources/CameraKage/General/Camera/Camera.swift delete mode 100644 Sources/CameraKage/General/Camera/CameraComponent.swift delete mode 100644 Sources/CameraKage/General/Camera/CameraComposer.swift delete mode 100644 Sources/CameraKage/General/Camera/CameraComposerProtocol.swift create mode 100644 Sources/CameraKage/General/CameraViews/BaseCameraView.swift create mode 100644 Sources/CameraKage/General/CameraViews/CameraView.swift create mode 100644 Sources/CameraKage/General/CameraViews/PhotoCameraView.swift create mode 100644 Sources/CameraKage/General/CameraViews/VideoCameraView.swift create mode 100644 Sources/CameraKage/General/Cameras/BaseCamera/BaseCamera.swift create mode 100644 Sources/CameraKage/General/Cameras/BaseCamera/BaseCameraInterface.swift create mode 100644 Sources/CameraKage/General/Cameras/Camera/Camera.swift create mode 100644 Sources/CameraKage/General/Cameras/Camera/CameraInterface.swift create mode 100644 Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCamera.swift create mode 100644 Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCameraInterface.swift create mode 100644 Sources/CameraKage/General/Cameras/VideoCamera/VideoCamera.swift create mode 100644 Sources/CameraKage/General/Cameras/VideoCamera/VideoCameraInterface.swift delete mode 100644 Sources/CameraKage/General/Delegates/Internal/CameraComposerDelegate.swift delete mode 100644 Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift delete mode 100644 Sources/CameraKage/General/Delegates/Internal/SessionComposerDelegate.swift create mode 100644 Sources/CameraKage/General/Delegates/Internal/SessionDelegate.swift create mode 100644 Sources/CameraKage/General/Delegates/Public/BaseCameraDelegate.swift create mode 100644 Sources/CameraKage/General/Delegates/Public/CameraDelegate.swift delete mode 100644 Sources/CameraKage/General/Delegates/Public/CameraKageDelegate.swift create mode 100644 Sources/CameraKage/General/Delegates/Public/PhotoCameraDelegate.swift create mode 100644 Sources/CameraKage/General/Delegates/Public/VideoCameraDelegate.swift delete mode 100644 Sources/CameraKage/General/Session/SessionComposerProtocol.swift create mode 100644 Sources/CameraKage/General/Settings/LayerVideoGravity.swift create mode 100644 Sources/CameraKage/General/Settings/PhotoQualityPrioritizationMode.swift create mode 100644 Sources/CameraKage/General/Settings/VideoOrientationMode.swift create mode 100644 Sources/CameraKage/General/Settings/VideoStabilizationMode.swift create mode 100644 Tests/CameraKageTests/BaseCameraViewTests.swift delete mode 100644 Tests/CameraKageTests/CameraKageDelegateMock.swift delete mode 100644 Tests/CameraKageTests/DelegatesManagerMock.swift create mode 100644 Tests/CameraKageTests/Mocks/BaseCameraDelegateStub.swift create mode 100644 Tests/CameraKageTests/Mocks/BaseCameraMock.swift create mode 100644 Tests/CameraKageTests/Mocks/CaptureSessionMock.swift create mode 100644 Tests/CameraKageTests/Mocks/DelegatesManagerMock.swift rename Tests/CameraKageTests/{ => Mocks}/PermissionManagerMock.swift (100%) create mode 100644 Tests/CameraKageTests/Mocks/VideoInputMock.swift create mode 100644 Tests/CameraKageTests/Mocks/VideoLayerMock.swift diff --git a/README.md b/README.md index 4445486..85069f0 100644 --- a/README.md +++ b/README.md @@ -17,42 +17,53 @@ CameraKage is a fully customizable, pure Swift, plug-and-play camera view. ## Functionalities -- [x] Fully customizable camera view. +- [x] Fully customizable and composable camera views. - [x] Premission handling. - [x] Delegate notifications. - [x] Photo and video capture. - [x] Camera flipping. - [x] Adjustments for exposure and focus of the camera. - [x] Capture session error and interruptions notifiers. +- [x] Flash usage for both photo and video. ### CameraKage Setup -Start setting up CameraKage by importing the package and creating a view instance, either from interface builder or via code. +Start setting up CameraKage by importing the package and creating a main module instance. ```swift import CameraKage -let cameraView = CameraKage() -// Add the camera to your view and adjust the layout as you want. +let cameraKage = CameraKage() ``` -After that, the embeding ViewController should be registered as a delegate in order to receive the wanted events from the camera via the CameraKageDelegate protocol. +Using the module instance you can handle camera and microphone permissions and create the type of camera view you would need. (Photo Camera, Video Camera or a full camera capable of both photo capture and video recordings) +```swift +let cameraPermissionGranted = await cameraKage.requestCameraPermission() +let microphonePermissionGranted = await cameraKage.requestMicrophonePermission() +if cameraPermissionGranted, microphonePermissionGranted { + let cameraCreationResult = cameraKage.createCameraView(with: CameraComponentParsedOptions([ + .cameraDevice(.backUltraWideCamera), + .flipCameraDevice(.frontCamera), + .maxVideoDuration(20.0), + .pinchToZoomEnabled(true) + ])) + switch cameraCreationResult { + case .success(let cameraView): + // Add camera to your view + case .failure(let error): + // Handle error that might occur + } +} +``` + +To receive notifications from the camera, you have to register as a listener and implement the delegate protocol of the specific camera. ```swift cameraView.registerDelegate(self) ``` -To startup the camera session, just call the startCameraSession method of the cameraView and provide the settings desired for the camera. +After the setup, the last thing to be done is to call the camera `startCamera()` method ```swift -// An example of camera settings when starting the camera session. -cameraView.startCameraSession(with: CameraComponentParsedOptions([ - .deviceType(.builtInDualWideCamera), - .devicePosition(.back), - .maxVideoDuration(CMTime(seconds: 15, preferredTimescale: .max)), - .photoQualityPrioritizationMode(.quality), - .pinchToZoomEnabled(true), - .videoStabilizationMode(.auto), - .cameraOrientation(.portrait) -])) +cameraView.startCamera() ``` With these steps you should have you camera up an running, what's left is just to call the capturePhoto or startVideoRecording methods. @@ -61,87 +72,78 @@ With these steps you should have you camera up an running, what's left is just t CameraKage provides a handful of useful notifications regarding the camera and the on-going camera session: ```swift -/** - Called when the camera has outputted a photo. - - - parameter camera: The camera composer which is sending the event. - - parameter data: The data representation of the photo. - */ - func camera(_ camera: CameraKage, didOutputPhotoWithData data: Data) - - /** - Called when the camera has started a video recording. - - - parameter camera: The camera composer which is sending the event. - - parameter url: The file location where the video will be stored when recording ends. - */ - func camera(_ camera: CameraKage, didStartRecordingVideoAtFileURL url: URL) - - /** - Called when the camera has outputted a video recording. - - - parameter camera: The camera composer which is sending the event. - - parameter url: The file location where the video is stored. - */ - func camera(_ camera: CameraKage, didOutputVideoAtFileURL url: URL) - /** - Called when a pinch to zoom action happened on the camera component. + Called when a pinch to zoom action happened on the camera. - - parameter camera: The camera composer which is sending the event. - parameter scale: The current zoom scale reported by the pinch gesture. - parameter maxScale: The maximum zoom scale of the camera. */ - func camera(_ camera: CameraKage, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) + func cameraDidZoom(atScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) /** - Called when the camera composer encountered an error. Could be an output, camera or a session related error. + Called when the camera encountered an error. - - parameter camera: The camera composer which is sending the event. - parameter error: The error that was encountered. */ - func camera(_ camera: CameraKage, didEncounterError error: CameraError) + func cameraDidEncounterError(error: CameraError) /** - Called when the camera session was interrupted. This can happen from various reason but most common - ones would be phone calls while using the camera, other apps taking control over the - phone camera or app moving to background. + Called when the camera session was interrupted. This can happen from various reason but most common ones would be phone calls while using the camera, other apps taking control over the phone camera or app moving to background. - - parameter camera: The camera composer which is sending the event. - parameter reason: The reason for the session interruption. - important: When this is called, the camera will freezee so some UI overlay might be necessary on the client side. */ - func camera(_ camera: CameraKage, sessionWasInterrupted reason: SessionInterruptionReason) + func cameraDidReceiveSessionInterruption(withReason reason: SessionInterruptionReason) /** Called when the camera session interruption has ended. When this is called the camera will resume working. - - - parameter camera: The camera composer which is sending the event. */ - func cameraSessionInterruptionEnded(_ camera: CameraKage) + func cameraDidFinishSessionInterruption() /** Called when the camera session was started and the actual camera will be visible on screen. - - - parameter camera: The camera composer which is sending the event. */ - func cameraSessionDidStart(_ camera: CameraKage) + func cameraDidStartCameraSession() /** Called when the camera session has stopped. + */ + func cameraDidStopCameraSession() + + /** + Called when the instance of AVCaptureDevice has detected a substantial change to the video subject area. This notification is only sent if you first set monitorSubjectAreaChange to `true` in the `focus()` camera method. + */ + func cameraDidChangeDeviceAreaOfInterest() +``` + +And also there are the camera specific delegate methods: + +### Photo Camera +```swift + /** + Called when the camera has outputted a photo. - - parameter camera: The camera composer which is sending the event. + - parameter data: The data representation of the photo. */ - func cameraSessionDidStop(_ camera: CameraKage) + func cameraDidCapturePhoto(withData data: Data) +``` + +### Video Camera +```swift + /** + Called when the camera has started a video recording. + + - parameter url: The URL file location where the video is being recorded. + */ + func cameraDidStartVideoRecording(atFileURL url: URL) /** - Posted when the instance of AVCaptureDevice has detected a substantial change to the video subject area. - This notification is only sent if you first set monitorSubjectAreaChange to `true` in the `focus()` camera method. + Called when the camera has outputted a video recording. - - parameter camera: The camera composer which is sending the event. + - parameter url: The URL of the video file location. */ - func cameraDeviceDidChangeSubjectArea(_ camera: CameraKage) + func cameraDidFinishVideoRecording(atFileURL url: URL) ``` ### Requirements diff --git a/Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift b/Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift deleted file mode 100644 index 4a43ef5..0000000 --- a/Sources/CameraKage/AVFoundationsInternals/CaptureSession.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// CaptureSession.swift -// -// -// Created by Lobont Andrei on 05.06.2023. -// - -import AVFoundation - -class CaptureSession: AVCaptureMultiCamSession { - func cleanupSession() { - defer { - commitConfiguration() - } - beginConfiguration() - - outputs.forEach { removeOutput($0) } - inputs.forEach { removeInput($0) } - connections.forEach { removeConnection($0) } - } -} diff --git a/Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift b/Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift deleted file mode 100644 index f0745a5..0000000 --- a/Sources/CameraKage/AVFoundationsInternals/PreviewLayer.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// PreviewLayer.swift -// -// -// Created by Lobont Andrei on 06.06.2023. -// - -import AVFoundation - -class PreviewLayer: AVCaptureVideoPreviewLayer { - private(set) var previewLayerConnection: AVCaptureConnection! - - func configurePreviewLayer(forSession session: CaptureSession, - andOptions options: CameraComponentParsedOptions, - videoDevice: VideoCaptureDevice) -> Bool { - setSessionWithNoConnection(session) - videoGravity = options.videoGravity - - let previewLayerConnection = AVCaptureConnection(inputPort: videoDevice.videoDevicePort, videoPreviewLayer: self) - previewLayerConnection.videoOrientation = options.cameraOrientation - guard session.canAddConnection(previewLayerConnection) else { return false } - session.addConnection(previewLayerConnection) - self.previewLayerConnection = previewLayerConnection - - return true - } -} diff --git a/Sources/CameraKage/CameraKage.swift b/Sources/CameraKage/CameraKage.swift index 024d5c8..93ce60b 100644 --- a/Sources/CameraKage/CameraKage.swift +++ b/Sources/CameraKage/CameraKage.swift @@ -3,44 +3,42 @@ import UIKit /// The main interface to use the `CameraKage` camera features. public class CameraKage: UIView { private var permissionManager: PermissionsManagerProtocol = PermissionsManager() - private var delegatesManager: DelegatesManagerProtocol = DelegatesManager() - private var cameraComposer: CameraComposerProtocol! - - /// Determines if the CaptureSession of `CameraKage` is running. - public var isSessionRunning: Bool { cameraComposer.isSessionRunning } - - /// Determines if `CameraKage` has a video recording in progress. - public var isRecording: Bool { cameraComposer.isRecording } + private var sessionComposer: SessionComposer = SessionComposer() /// Available cameras for the client's phone. public var availableCameraDevices: [CameraDevice] { CameraDevice.availableDevices } - public override init(frame: CGRect) { - super.init(frame: frame) - setupComposer() - } - - public required init?(coder: NSCoder) { - super.init(coder: coder) - setupComposer() + /** + Create a view with a full camera integrated, capable of capturing photos and creating video recordings. + + - parameter options: The options used in the camera setup. + + - returns: Returns a result containing either the camera view or an error that might have occured in the camera setup process. + */ + public func createCameraView(with options: CameraComponentParsedOptions = CameraComponentParsedOptions(nil)) -> Result { + sessionComposer.createCameraView(options: options) } /** - Register a listener for the `CameraKage` to receive notifications regarding the camera session. + Create a view with a video camera integrated, capable of creating video recordings. - - parameter delegate: The object that will receive the notifications. + - parameter options: The options used in the camera setup. + + - returns: Returns a result containing either the camera view or an error that might have occured in the camera setup process. */ - public func registerDelegate(_ delegate: CameraKageDelegate) { - delegatesManager.registerDelegate(delegate) + public func createVideoCameraView(with options: CameraComponentParsedOptions = CameraComponentParsedOptions(nil)) -> Result { + sessionComposer.createVideoCameraView(options: options) } /** - Unregisters a listener from receiving `CameraKage` notifications. + Create a view with a photo camera integrated, capable of capturing photos. + + - parameter options: The options used in the camera setup. - - parameter delegate: The object to be removed. + - returns: Returns a result containing either the camera view or an error that might have occured in the camera setup process. */ - public func unregisterDelegate(_ delegate: CameraKageDelegate) { - delegatesManager.unregisterDelegate(delegate) + public func createPhotoCameraView(with options: CameraComponentParsedOptions = CameraComponentParsedOptions(nil)) -> Result { + sessionComposer.createPhotoCameraView(options: options) } /** @@ -108,155 +106,12 @@ public class CameraKage: UIView { public func getMicrophonePermissionStatus() -> PermissionStatus { permissionManager.getAuthorizationStatus(for: .audio) } - - /** - Starts the camera session. - - - parameter options: Options used for the camera setup - - - important: Before calling `startCameraSession`, `requestCameraPermission()` and `requestMicrophonePermission()` methods can be called for custom UI usage. If permission requests aren't used, the system will call the alerts automatically. - */ - public func startCameraSession(with options: CameraComponentParsedOptions = CameraComponentParsedOptions(nil)) { - cameraComposer.startCameraSession(with: options) - } - - /** - Stops the camera session and destroys the camera component. - */ - public func stopCameraSession() { - cameraComposer.stopCameraSession() - } - - /** - Captures a photo from the camera. Resulted photo will be delivered via `CameraKageDelegate`. - - - parameter flashOption: Indicates what flash option should be used when capturing the photo. Default is `.off`. - - parameter redEyeCorrection: Determines if red eye correction should be applied or not. Default is `true`. - */ - public func capturePhoto(_ flashOption: FlashMode = .off, - redEyeCorrection: Bool = true) { - cameraComposer.capturePhoto(flashOption, redEyeCorrection: redEyeCorrection) - } - - /** - Starts a video recording for the camera. `CameraKageDelegate` sends a notification when the recording has started. - - - parameter flashOption: Indicates what flash option should be used for the video recording. Default is `.off`. - - - important: Front camera dosen't support video recordings with flash mode `.on`. - */ - public func startVideoRecording(_ flashOption: FlashMode) { - cameraComposer.startVideoRecording(flashOption) - } - - /** - Stops the video recording. `CameraKageDelegate` sends a notification containing the URL where the video file is stored. - */ - public func stopVideoRecording() { - cameraComposer.stopVideoRecording() - } - - /** - Flips the camera from back to front and vice-versa. - - - important: Camera can't be flipped while recording a video. Session is restarted when flipping the camera. - */ - public func flipCamera() { - cameraComposer.flipCamera() - } - - /** - Adjusts the focus and the exposure of the camera. - - - parameter focusMode: Focus mode of the camera. Default is `.autoFocus`. - - parameter exposureMode: Exposure mode of the camera. Default is `.autoExpose`. - - parameter devicePoint: The point of the camera where the focus should be switched to. - - parameter monitorSubjectAreaChange: If set `true`, it registers the camera to receive notifications about area changes for the user to re-focus if needed. Default is `true`. - */ - public func adjustFocusAndExposure(with focusMode: FocusMode = .autoFocus, - exposureMode: ExposureMode = .autoExpose, - at devicePoint: CGPoint, - monitorSubjectAreaChange: Bool = true) { - cameraComposer.adjustFocusAndExposure(with: focusMode, - exposureMode: exposureMode, - at: devicePoint, - monitorSubjectAreaChange: monitorSubjectAreaChange) - } - - private func setupComposer() { - cameraComposer = CameraComposer() - cameraComposer.delegate = self - addSubview(cameraComposer) - cameraComposer.layoutToFill(inView: self) - } -} - -// MARK: - CameraComposerDelegate -extension CameraKage: CameraComposerDelegate { - func cameraComposer(_ cameraComposer: CameraComposer, didCapturePhoto photo: Data) { - delegatesManager.invokeDelegates { $0.camera(self, didOutputPhotoWithData: photo) } - } - - func cameraComposer(_ cameraComposer: CameraComposer, didStartRecordingVideo atFileURL: URL) { - delegatesManager.invokeDelegates { $0.camera(self, didStartRecordingVideoAtFileURL: atFileURL) } - } - - func cameraComposer(_ cameraComposer: CameraComposer, didRecordVideo videoURL: URL) { - delegatesManager.invokeDelegates { $0.camera(self, didOutputVideoAtFileURL: videoURL) } - } - - func cameraComposer(_ cameraComposer: CameraComposer, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) { - delegatesManager.invokeDelegates { $0.camera(self, didZoomAtScale: scale, outOfMaximumScale: maxScale) } - } - - func cameraComposer(_ cameraComposer: CameraComposer, didReceiveError error: CameraError) { - delegatesManager.invokeDelegates { $0.camera(self, didEncounterError: error) } - } - - func cameraComposer(_ cameraComposer: CameraComposer, didReceiveSessionInterruption reason: SessionInterruptionReason) { - delegatesManager.invokeDelegates { $0.camera(self, sessionWasInterrupted: reason) } - } - - func cameraComposerDidFinishSessionInterruption(_ cameraComposer: CameraComposer) { - delegatesManager.invokeDelegates { $0.cameraSessionInterruptionEnded(self) } - } - - func cameraComposerDidStartCameraSession(_ cameraComposer: CameraComposer) { - delegatesManager.invokeDelegates { $0.cameraSessionDidStart(self) } - } - - func cameraComposerDidStopCameraSession(_ cameraComposer: CameraComposer) { - delegatesManager.invokeDelegates { $0.cameraSessionDidStop(self) } - } - - func cameraComposerDidChangeDeviceAreaOfInterest(_ cameraComposer: CameraComposer) { - delegatesManager.invokeDelegates { $0.cameraDeviceDidChangeSubjectArea(self) } - } } // MARK: - Internal tests inits extension CameraKage { - internal convenience init(permissionManager: PermissionsManagerProtocol, - delegatesManager: DelegatesManagerProtocol, - cameraComposer: CameraComposerProtocol) { - self.init(frame: .zero) - self.permissionManager = permissionManager - self.delegatesManager = delegatesManager - self.cameraComposer = cameraComposer - } - - internal convenience init(delegatesManager: DelegatesManagerProtocol) { - self.init(frame: .zero) - self.delegatesManager = delegatesManager - } - internal convenience init(permissionManager: PermissionsManagerProtocol) { self.init(frame: .zero) self.permissionManager = permissionManager } - - internal convenience init(cameraComposer: CameraComposerProtocol) { - self.init(frame: .zero) - self.cameraComposer = cameraComposer - } } diff --git a/Sources/CameraKage/AVFoundationsInternals/AudioCaptureDevice.swift b/Sources/CameraKage/Devices/Inputs/Audio/AudioCaptureDevice.swift similarity index 71% rename from Sources/CameraKage/AVFoundationsInternals/AudioCaptureDevice.swift rename to Sources/CameraKage/Devices/Inputs/Audio/AudioCaptureDevice.swift index 22c4328..7641e45 100644 --- a/Sources/CameraKage/AVFoundationsInternals/AudioCaptureDevice.swift +++ b/Sources/CameraKage/Devices/Inputs/Audio/AudioCaptureDevice.swift @@ -7,16 +7,22 @@ import AVFoundation -class AudioCaptureDevice { +class AudioCaptureDevice: AudioInput { + private let session: CaptureSession + private let options: CameraComponentParsedOptions private(set) var audioDevice: AVCaptureDevice! private(set) var audioDeviceInput: AVCaptureDeviceInput! private(set) var audioDevicePort: AVCaptureDeviceInput.Port! - func configureAudioDevice(forSession session: CaptureSession, - andOptions options: CameraComponentParsedOptions, - isFlipped: Bool) -> Bool { + init?(session: CaptureSession, + options: CameraComponentParsedOptions) { + self.session = session + self.options = options + guard configureAudioDevice() else { return nil } + } + + private func configureAudioDevice() -> Bool { do { - let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return false } self.audioDevice = audioDevice @@ -27,7 +33,7 @@ class AudioCaptureDevice { guard let audioPort = audioDeviceInput.ports(for: .audio, sourceDeviceType: .builtInMicrophone, - sourceDevicePosition: camera.avDevicePosition).first else { return false } + sourceDevicePosition: .back).first else { return false } self.audioDevicePort = audioPort return true } catch { diff --git a/Sources/CameraKage/Devices/Inputs/Audio/AudioInput.swift b/Sources/CameraKage/Devices/Inputs/Audio/AudioInput.swift new file mode 100644 index 0000000..576aede --- /dev/null +++ b/Sources/CameraKage/Devices/Inputs/Audio/AudioInput.swift @@ -0,0 +1,12 @@ +// +// AudioInput.swift +// +// +// Created by Lobont Andrei on 09.06.2023. +// + +import Foundation + +protocol AudioInput { + +} diff --git a/Sources/CameraKage/AVFoundationsInternals/VideoCaptureDevice.swift b/Sources/CameraKage/Devices/Inputs/Video/VideoCaptureDevice.swift similarity index 74% rename from Sources/CameraKage/AVFoundationsInternals/VideoCaptureDevice.swift rename to Sources/CameraKage/Devices/Inputs/Video/VideoCaptureDevice.swift index 3fd26fc..39fe944 100644 --- a/Sources/CameraKage/AVFoundationsInternals/VideoCaptureDevice.swift +++ b/Sources/CameraKage/Devices/Inputs/Video/VideoCaptureDevice.swift @@ -7,19 +7,44 @@ import AVFoundation -class VideoCaptureDevice: NSObject { +class VideoCaptureDevice: NSObject, VideoInput { + private let session: CaptureSession + private let options: CameraComponentParsedOptions @objc private(set) dynamic var videoDevice: AVCaptureDevice! @objc private(set) dynamic var videoDeviceInput: AVCaptureDeviceInput! private(set) var videoDevicePort: AVCaptureDeviceInput.Port! + private var isFlipped = false private var keyValueObservations = [NSKeyValueObservation]() var onVideoDeviceError: ((CameraError) -> Void)? + var isVideoMirrored: Bool { + let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice + return camera.avDevicePosition == .front + } + + init?(session: CaptureSession, + options: CameraComponentParsedOptions) { + self.session = session + self.options = options + super.init() + guard configureVideoDevice() else { return nil } + addObservers() + } + + deinit { + removeObservers() + } - func focus(with focusMode: FocusMode, - exposureMode: ExposureMode, - at point: CGPoint, - monitorSubjectAreaChange: Bool) throws { + func flip() throws { + session.removeInput(videoDeviceInput) + isFlipped.toggle() + if !configureVideoDevice() { + throw CameraError.cameraComponentError(reason: .failedToConfigureVideoDevice) + } + } + + func focus(focusMode: FocusMode, exposureMode: ExposureMode, point: CGPoint, monitorSubjectAreaChange: Bool) throws { do { try videoDevice.lockForConfiguration() if videoDevice.isFocusPointOfInterestSupported && @@ -49,16 +74,19 @@ class VideoCaptureDevice: NSObject { } } - func minMaxZoom(_ factor: CGFloat, - with options: CameraComponentParsedOptions) -> CGFloat { - let maxFactor = max(factor, options.minimumZoomScale) - return min(min(maxFactor, options.maximumZoomScale), videoDevice.activeFormat.videoMaxZoomFactor) + func minMaxZoom(_ factor: CGFloat, options: CameraComponentParsedOptions) -> CGFloat { + min(min(max(factor, options.minimumZoomScale), options.maximumZoomScale), videoDevice.activeFormat.videoMaxZoomFactor) } func configureFlash(_ flashMode: FlashMode) throws { guard videoDevice.isTorchModeSupported(flashMode.avTorchModeOption), + videoDevice.isTorchAvailable, videoDevice.torchMode != flashMode.avTorchModeOption else { - throw CameraError.cameraComponentError(reason: .torchModeNotSupported) + if flashMode == .off { + return + } else { + throw CameraError.cameraComponentError(reason: .torchModeNotSupported) + } } do { try videoDevice.lockForConfiguration() @@ -69,12 +97,9 @@ class VideoCaptureDevice: NSObject { } } - func configureVideoDevice(forSession session: CaptureSession, - andOptions options: CameraComponentParsedOptions, - isFlipped: Bool) -> Bool { + private func configureVideoDevice() -> Bool { do { let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice - guard let videoDevice = AVCaptureDevice.default(camera.avDeviceType, for: .video, position: camera.avDevicePosition) else { return false } @@ -94,12 +119,12 @@ class VideoCaptureDevice: NSObject { } } - func removeObserver() { + func removeObservers() { keyValueObservations.forEach { $0.invalidate() } keyValueObservations.removeAll() } - func addObserver() { + func addObservers() { let systemPressureStateObservation = observe(\.videoDevice.systemPressureState, options: .new) { _, change in guard let systemPressureState = change.newValue else { return } self.setRecommendedFrameRateRangeForPressureState(systemPressureState: systemPressureState) diff --git a/Sources/CameraKage/Devices/Inputs/Video/VideoInput.swift b/Sources/CameraKage/Devices/Inputs/Video/VideoInput.swift new file mode 100644 index 0000000..7c696cb --- /dev/null +++ b/Sources/CameraKage/Devices/Inputs/Video/VideoInput.swift @@ -0,0 +1,20 @@ +// +// VideoInput.swift +// +// +// Created by Lobont Andrei on 09.06.2023. +// + +import Foundation + +protocol VideoInput { + var onVideoDeviceError: ((CameraError) -> Void)? { get set } + + func flip() throws + func focus(focusMode: FocusMode, exposureMode: ExposureMode, point: CGPoint, monitorSubjectAreaChange: Bool) throws + func zoom(atScale: CGFloat) throws + func minMaxZoom(_ factor: CGFloat, options: CameraComponentParsedOptions) -> CGFloat + func configureFlash(_ flashMode: FlashMode) throws + func addObservers() + func removeObservers() +} diff --git a/Sources/CameraKage/Devices/Layers/Video/PreviewLayer.swift b/Sources/CameraKage/Devices/Layers/Video/PreviewLayer.swift new file mode 100644 index 0000000..84dd8ee --- /dev/null +++ b/Sources/CameraKage/Devices/Layers/Video/PreviewLayer.swift @@ -0,0 +1,60 @@ +// +// PreviewLayer.swift +// +// +// Created by Lobont Andrei on 06.06.2023. +// + +import AVFoundation + +class PreviewLayer: AVCaptureVideoPreviewLayer, VideoLayer { + private let captureSession: CaptureSession + private let options: CameraComponentParsedOptions + private let videoDevice: VideoCaptureDevice + private(set) var previewLayerConnection: AVCaptureConnection! + + init?(session: CaptureSession, + options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice) { + self.captureSession = session + self.options = options + self.videoDevice = videoDevice + super.init(sessionWithNoConnection: session) + guard configurePreviewLayer() else { return nil } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func embedPreviewLayer(in layer: CALayer) { + layer.addSublayer(self) + } + + func setPreviewLayerFrame(_ frame: CGRect) { + bounds = frame + position = CGPoint(x: frame.width / 2, y: frame.height / 2) + } + + func reloadPreviewLayer() throws { + let containsConnection = session?.connections.contains(where: { connection in + previewLayerConnection == connection + }) + if let containsConnection, containsConnection { + session?.removeConnection(previewLayerConnection) + } + if !configurePreviewLayer() { + throw CameraError.cameraComponentError(reason: .failedToAddPreviewLayer) + } + } + + private func configurePreviewLayer() -> Bool { + videoGravity = options.videoGravity.avLayerVideoGravity + let previewLayerConnection = AVCaptureConnection(inputPort: videoDevice.videoDevicePort, videoPreviewLayer: self) + previewLayerConnection.videoOrientation = options.cameraOrientation.avVideoOrientationMode + guard captureSession.canAddConnection(previewLayerConnection) else { return false } + captureSession.addConnection(previewLayerConnection) + self.previewLayerConnection = previewLayerConnection + return true + } +} diff --git a/Sources/CameraKage/Devices/Layers/Video/VideoLayer.swift b/Sources/CameraKage/Devices/Layers/Video/VideoLayer.swift new file mode 100644 index 0000000..a6b06dd --- /dev/null +++ b/Sources/CameraKage/Devices/Layers/Video/VideoLayer.swift @@ -0,0 +1,16 @@ +// +// VideoLayer.swift +// +// +// Created by Lobont Andrei on 09.06.2023. +// + +import UIKit + +protocol VideoLayer { + func embedPreviewLayer(in layer: CALayer) + func setPreviewLayerFrame(_ frame: CGRect) + func removeFromSuperlayer() + func reloadPreviewLayer() throws + func captureDevicePointConverted(fromLayerPoint: CGPoint) -> CGPoint +} diff --git a/Sources/CameraKage/Devices/Outputs/Movie/MovieCapturer.swift b/Sources/CameraKage/Devices/Outputs/Movie/MovieCapturer.swift new file mode 100644 index 0000000..fe22c69 --- /dev/null +++ b/Sources/CameraKage/Devices/Outputs/Movie/MovieCapturer.swift @@ -0,0 +1,19 @@ +// +// MovieCapturer.swift +// +// +// Created by Lobont Andrei on 09.06.2023. +// + +import Foundation + +protocol MovieCapturer { + var isRecording: Bool { get } + var onMovieCaptureSuccess: ((URL) -> Void)? { get set } + var onMovieCaptureStart: ((URL) -> Void)? { get set } + var onMovieCaptureError: ((CameraError) -> Void)? { get set } + + func startMovieRecording() + func stopMovieRecording() + func handleFlip() throws +} diff --git a/Sources/CameraKage/AVFoundationsInternals/MovieOutput.swift b/Sources/CameraKage/Devices/Outputs/Movie/MovieOutput.swift similarity index 61% rename from Sources/CameraKage/AVFoundationsInternals/MovieOutput.swift rename to Sources/CameraKage/Devices/Outputs/Movie/MovieOutput.swift index 1c4d1f8..bd2dfbb 100644 --- a/Sources/CameraKage/AVFoundationsInternals/MovieOutput.swift +++ b/Sources/CameraKage/Devices/Outputs/Movie/MovieOutput.swift @@ -7,7 +7,11 @@ import AVFoundation -class MovieOutput: AVCaptureMovieFileOutput { +class MovieOutput: AVCaptureMovieFileOutput, MovieCapturer { + private let session: CaptureSession + private let options: CameraComponentParsedOptions + private let videoDevice: VideoCaptureDevice + private let audioDevice: AudioCaptureDevice private(set) var videoPortConnection: AVCaptureConnection? private(set) var audioPortConnection: AVCaptureConnection? @@ -15,6 +19,18 @@ class MovieOutput: AVCaptureMovieFileOutput { var onMovieCaptureStart: ((URL) -> Void)? var onMovieCaptureError: ((CameraError) -> Void)? + init?(forSession session: CaptureSession, + andOptions options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice, + audioDevice: AudioCaptureDevice) { + self.session = session + self.options = options + self.videoDevice = videoDevice + self.audioDevice = audioDevice + super.init() + guard configureMovieFileOutput() else { return nil } + } + func startMovieRecording() { guard !isRecording else { return } startRecording(to: .makeTempUrl(for: .video), recordingDelegate: self) @@ -24,23 +40,37 @@ class MovieOutput: AVCaptureMovieFileOutput { stopRecording() } - func configureMovieFileOutput(forSession session: CaptureSession, - andOptions options: CameraComponentParsedOptions, - videoDevice: VideoCaptureDevice, - audioDevice: AudioCaptureDevice, - isFlipped: Bool) -> Bool { - let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice + func handleFlip() throws { + session.removeOutput(self) + let containsVideoConnection = session.connections.contains(where: { connection in + videoPortConnection == connection + }) + let containsAudioConnection = session.connections.contains(where: { connection in + audioPortConnection == connection + }) + if let videoPortConnection, containsVideoConnection { + session.removeConnection(videoPortConnection) + } + if let audioPortConnection, containsAudioConnection { + session.removeConnection(audioPortConnection) + } + if !configureMovieFileOutput() { + throw CameraError.cameraComponentError(reason: .failedToAddMovieOutput) + } + } + + private func configureMovieFileOutput() -> Bool { guard session.canAddOutput(self) else { return false } session.addOutputWithNoConnections(self) - maxRecordedDuration = options.maxVideoDuration + maxRecordedDuration = CMTime(seconds: options.maxVideoDuration, preferredTimescale: .max) let videoConnection = AVCaptureConnection(inputPorts: [videoDevice.videoDevicePort], output: self) guard session.canAddConnection(videoConnection) else { return false } session.addConnection(videoConnection) - videoConnection.isVideoMirrored = camera.avDevicePosition == .front - videoConnection.videoOrientation = options.cameraOrientation + videoConnection.isVideoMirrored = videoDevice.isVideoMirrored + videoConnection.videoOrientation = options.cameraOrientation.avVideoOrientationMode if videoConnection.isVideoStabilizationSupported { - videoConnection.preferredVideoStabilizationMode = options.videoStabilizationMode + videoConnection.preferredVideoStabilizationMode = options.videoStabilizationMode.avVideoStabilizationMode } self.videoPortConnection = videoConnection @@ -49,7 +79,7 @@ class MovieOutput: AVCaptureMovieFileOutput { session.addConnection(audioConnection) if availableVideoCodecTypes.contains(.hevc) { setOutputSettings([AVVideoCodecKey: AVVideoCodecType.hevc], - for: videoConnection) + for: videoConnection) } self.audioPortConnection = audioConnection diff --git a/Sources/CameraKage/Devices/Outputs/Photo/PhotoCapturer.swift b/Sources/CameraKage/Devices/Outputs/Photo/PhotoCapturer.swift new file mode 100644 index 0000000..ca1f615 --- /dev/null +++ b/Sources/CameraKage/Devices/Outputs/Photo/PhotoCapturer.swift @@ -0,0 +1,16 @@ +// +// PhotoCapturer.swift +// +// +// Created by Lobont Andrei on 09.06.2023. +// + +import Foundation + +protocol PhotoCapturer { + var onPhotoCaptureSuccess: ((Data) -> Void)? { get set } + var onPhotoCaptureError: ((CameraError) -> Void)? { get set } + + func capturePhoto(_ flashMode: FlashMode, redEyeCorrection: Bool) + func handleFlip() throws +} diff --git a/Sources/CameraKage/AVFoundationsInternals/PhotoOutput.swift b/Sources/CameraKage/Devices/Outputs/Photo/PhotoOutput.swift similarity index 65% rename from Sources/CameraKage/AVFoundationsInternals/PhotoOutput.swift rename to Sources/CameraKage/Devices/Outputs/Photo/PhotoOutput.swift index a3927af..bebe02a 100644 --- a/Sources/CameraKage/AVFoundationsInternals/PhotoOutput.swift +++ b/Sources/CameraKage/Devices/Outputs/Photo/PhotoOutput.swift @@ -7,7 +7,10 @@ import AVFoundation -class PhotoOutput: AVCapturePhotoOutput { +class PhotoOutput: AVCapturePhotoOutput, PhotoCapturer { + private let session: CaptureSession + private let options: CameraComponentParsedOptions + private let videoDevice: VideoCaptureDevice private var photoData: Data? private(set) var videoPortConnection: AVCaptureConnection? @@ -15,8 +18,17 @@ class PhotoOutput: AVCapturePhotoOutput { var onPhotoCaptureSuccess: ((Data) -> Void)? var onPhotoCaptureError: ((CameraError) -> Void)? - func capturePhoto(_ flashMode: FlashMode, - redEyeCorrection: Bool) { + init?(session: CaptureSession, + options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice) { + self.session = session + self.options = options + self.videoDevice = videoDevice + super.init() + guard configurePhotoOutput() else { return nil } + } + + func capturePhoto(_ flashMode: FlashMode, redEyeCorrection: Bool) { var photoSettings = AVCapturePhotoSettings() photoSettings.flashMode = flashMode.avFlashOption photoSettings.isAutoRedEyeReductionEnabled = redEyeCorrection @@ -31,20 +43,29 @@ class PhotoOutput: AVCapturePhotoOutput { capturePhoto(with: photoSettings, delegate: self) } - func configurePhotoOutput(forSession session: CaptureSession, - andOptions options: CameraComponentParsedOptions, - videoDevice: VideoCaptureDevice, - isFlipped: Bool) -> Bool { - let camera = isFlipped ? options.flipCameraDevice : options.cameraDevice + func handleFlip() throws { + session.removeOutput(self) + let containsVideoConnection = session.connections.contains(where: { connection in + videoPortConnection == connection + }) + if let videoPortConnection = videoPortConnection, containsVideoConnection { + session.removeConnection(videoPortConnection) + } + if !configurePhotoOutput() { + throw CameraError.cameraComponentError(reason: .failedToAddPhotoOutput) + } + } + + private func configurePhotoOutput() -> Bool { guard session.canAddOutput(self) else { return false } session.addOutputWithNoConnections(self) - maxPhotoQualityPrioritization = options.photoQualityPrioritizationMode + maxPhotoQualityPrioritization = options.photoQualityPrioritizationMode.avQualityPrioritizationMode let photoConnection = AVCaptureConnection(inputPorts: [videoDevice.videoDevicePort], output: self) guard session.canAddConnection(photoConnection) else { return false } session.addConnection(photoConnection) - photoConnection.videoOrientation = options.cameraOrientation - photoConnection.isVideoMirrored = camera.avDevicePosition == .front + photoConnection.videoOrientation = options.cameraOrientation.avVideoOrientationMode + photoConnection.isVideoMirrored = videoDevice.isVideoMirrored self.videoPortConnection = photoConnection return true diff --git a/Sources/CameraKage/Devices/Session/CaptureSession.swift b/Sources/CameraKage/Devices/Session/CaptureSession.swift new file mode 100644 index 0000000..5e03d65 --- /dev/null +++ b/Sources/CameraKage/Devices/Session/CaptureSession.swift @@ -0,0 +1,101 @@ +// +// CaptureSession.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import AVFoundation + +class CaptureSession: AVCaptureMultiCamSession, Session { + weak var delegate: SessionDelegate? + + func startSession() { + startRunning() + } + + func stopSession() { + stopRunning() + } + + func addObservers() { + NotificationCenter.default.addObserver(self, + selector: #selector(sessionRuntimeError), + name: .AVCaptureSessionRuntimeError, + object: self) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionWasInterrupted), + name: .AVCaptureSessionWasInterrupted, + object: self) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionInterruptionEnded), + name: .AVCaptureSessionInterruptionEnded, + object: self) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionDidStartRunning), + name: .AVCaptureSessionDidStartRunning, + object: self) + NotificationCenter.default.addObserver(self, + selector: #selector(sessionDidStopRunning), + name: .AVCaptureSessionDidStopRunning, + object: self) + NotificationCenter.default.addObserver(self, + selector: #selector(deviceSubjectAreaDidChange), + name: .AVCaptureDeviceSubjectAreaDidChange, + object: self) + } + + func removeObservers() { + NotificationCenter.default.removeObserver(self) + } + + @objc private func sessionRuntimeError(notification: NSNotification) { + guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return } + let cameraError = CameraError.cameraSessionError(reason: .runtimeError(error)) + delegate?.session(self, + didReceiveRuntimeError: cameraError, + shouldRestartCamera: error.code == .mediaServicesWereReset) + } + + @objc private func sessionDidStartRunning(notification: NSNotification) { + delegate?.sessionDidStartCameraSession(self) + } + + @objc private func sessionDidStopRunning(notification: NSNotification) { + delegate?.sessionDidStopCameraSession(self) + } + + /// This will be called anytime there is another app that tries to use the audio or video devices + /// Removing that device will unfreeze the camera but video will be corrupted (couldn't find a reason yet) + @objc private func sessionWasInterrupted(notification: NSNotification) { + guard let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?, + let reasonIntegerValue = userInfoValue.integerValue, + let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) else { return } + var interruptionReason: SessionInterruptionReason + switch reason { + case .videoDeviceNotAvailableInBackground: + interruptionReason = .videoDeviceNotAvailableInBackground + case .audioDeviceInUseByAnotherClient: + interruptionReason = .audioDeviceInUseByAnotherClient + case .videoDeviceInUseByAnotherClient: + interruptionReason = .videoDeviceInUseByAnotherClient + case .videoDeviceNotAvailableWithMultipleForegroundApps: + interruptionReason = .videoDeviceNotAvailableWithMultipleForegroundApps + case .videoDeviceNotAvailableDueToSystemPressure: + interruptionReason = .videoDeviceNotAvailableDueToSystemPressure + @unknown default: + interruptionReason = .unknown + } + delegate?.session(self, didReceiveSessionInterruption: interruptionReason) + } + + /// This will be called anytime the problem with the session was solved + /// Entering in this method dosen't necessarly means that the call or the other application that invoked the problem was closed + @objc private func sessionInterruptionEnded(notification: NSNotification) { + delegate?.sessionDidFinishSessionInterruption(self) + } + + @objc private func deviceSubjectAreaDidChange(notification: NSNotification) { + delegate?.sessionDidChangeDeviceAreaOfInterest(self) + } +} diff --git a/Sources/CameraKage/Devices/Session/Session.swift b/Sources/CameraKage/Devices/Session/Session.swift new file mode 100644 index 0000000..2a92cd6 --- /dev/null +++ b/Sources/CameraKage/Devices/Session/Session.swift @@ -0,0 +1,20 @@ +// +// Session.swift +// +// +// Created by Lobont Andrei on 09.06.2023. +// + +import Foundation + +protocol Session { + var isRunning: Bool { get } + var delegate: SessionDelegate? { get set } + + func startSession() + func stopSession() + func beginConfiguration() + func commitConfiguration() + func addObservers() + func removeObservers() +} diff --git a/Sources/CameraKage/Extensions/Internal/LayerVideoGravity+AVFoundation.swift b/Sources/CameraKage/Extensions/Internal/LayerVideoGravity+AVFoundation.swift new file mode 100644 index 0000000..84a045c --- /dev/null +++ b/Sources/CameraKage/Extensions/Internal/LayerVideoGravity+AVFoundation.swift @@ -0,0 +1,18 @@ +// +// LayerVideoGravity+AVFoundation.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import AVFoundation + +extension LayerVideoGravity { + var avLayerVideoGravity: AVLayerVideoGravity { + switch self { + case .resizeAspect: return .resizeAspect + case .resizeAspectFill: return .resizeAspectFill + case .resize: return .resize + } + } +} diff --git a/Sources/CameraKage/Extensions/Internal/PhotoQualityPrioritizationMode+AVFoundation.swift b/Sources/CameraKage/Extensions/Internal/PhotoQualityPrioritizationMode+AVFoundation.swift new file mode 100644 index 0000000..cdf2df0 --- /dev/null +++ b/Sources/CameraKage/Extensions/Internal/PhotoQualityPrioritizationMode+AVFoundation.swift @@ -0,0 +1,18 @@ +// +// PhotoQualityPrioritizationMode+AVFoundation.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import AVFoundation + +extension PhotoQualityPrioritizationMode { + var avQualityPrioritizationMode: AVCapturePhotoOutput.QualityPrioritization { + switch self { + case .speed: return .speed + case .balanced: return .balanced + case .quality: return .quality + } + } +} diff --git a/Sources/CameraKage/Extensions/Internal/VideoOrientationMode+AVFoundation.swift b/Sources/CameraKage/Extensions/Internal/VideoOrientationMode+AVFoundation.swift new file mode 100644 index 0000000..155191f --- /dev/null +++ b/Sources/CameraKage/Extensions/Internal/VideoOrientationMode+AVFoundation.swift @@ -0,0 +1,19 @@ +// +// VideoOrientationMode+AVFoundation.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import AVFoundation + +extension VideoOrientationMode { + var avVideoOrientationMode: AVCaptureVideoOrientation { + switch self { + case .portrait: return .portrait + case .portraitUpsideDown: return .portraitUpsideDown + case .landscapeRight: return .landscapeRight + case .landscapeLeft: return .landscapeLeft + } + } +} diff --git a/Sources/CameraKage/Extensions/Internal/VideoStabilizationMode+AVFoundation.swift b/Sources/CameraKage/Extensions/Internal/VideoStabilizationMode+AVFoundation.swift new file mode 100644 index 0000000..7d3920e --- /dev/null +++ b/Sources/CameraKage/Extensions/Internal/VideoStabilizationMode+AVFoundation.swift @@ -0,0 +1,20 @@ +// +// VideoStabilizationMode+AVFoundation.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import AVFoundation + +extension VideoStabilizationMode { + var avVideoStabilizationMode: AVCaptureVideoStabilizationMode { + switch self { + case .off: return .off + case .standard: return .standard + case .cinematic: return .cinematic + case .cinematicExtended: return .cinematicExtended + case .auto: return .auto + } + } +} diff --git a/Sources/CameraKage/General/Camera/Camera.swift b/Sources/CameraKage/General/Camera/Camera.swift deleted file mode 100644 index ea7c7a7..0000000 --- a/Sources/CameraKage/General/Camera/Camera.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// Camera.swift -// -// -// Created by Lobont Andrei on 30.05.2023. -// - -import Foundation -import QuartzCore.CALayer - -class Camera { - let session: CaptureSession - let options: CameraComponentParsedOptions - - private let videoDevice: VideoCaptureDevice - private let audioDevice: AudioCaptureDevice - private let photoOutput: PhotoOutput - private let movieOutput: MovieOutput - private let previewLayer: PreviewLayer - private var lastZoomFactor: CGFloat = 1.0 - private var isFlipped = false - - var allowsPinchZoom: Bool { options.pinchToZoomEnabled } - var isRecording: Bool { movieOutput.isRecording } - - weak var delegate: CameraDelegate? - - init?(session: CaptureSession, - options: CameraComponentParsedOptions, - videoDevice: VideoCaptureDevice = VideoCaptureDevice(), - audioDevice: AudioCaptureDevice = AudioCaptureDevice(), - photoOutput: PhotoOutput = PhotoOutput(), - movieOutput: MovieOutput = MovieOutput(), - previewLayer: PreviewLayer = PreviewLayer()) { - self.session = session - self.options = options - self.videoDevice = videoDevice - self.audioDevice = audioDevice - self.photoOutput = photoOutput - self.movieOutput = movieOutput - self.previewLayer = previewLayer - - do { - try configureSession() - } catch let error as CameraError { - delegate?.camera(self, didFail: error) - return nil - } catch { - delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToComposeCamera)) - return nil - } - } - - deinit { - videoDevice.removeObserver() - } - - func embedPreviewLayer(in layer: CALayer) { - layer.addSublayer(previewLayer) - } - - func setPreviewLayerFrame(frame: CGRect) { - previewLayer.bounds = frame - previewLayer.position = CGPoint(x: frame.width / 2, y: frame.height / 2) - } - - func capturePhoto(_ flashMode: FlashMode, redEyeCorrection: Bool) { - photoOutput.capturePhoto(flashMode, redEyeCorrection: redEyeCorrection) - } - - func startMovieRecording(_ flashMode: FlashMode) { - do { - try videoDevice.configureFlash(flashMode) - } catch let error as CameraError { - delegate?.camera(self, didFail: error) - } catch { - delegate?.camera(self, didFail: .cameraComponentError(reason: .torchModeNotSupported)) - } - movieOutput.startMovieRecording() - } - - func stopMovieRecording() { - movieOutput.stopMovieRecording() - do { - try videoDevice.configureFlash(.off) - } catch let error as CameraError { - delegate?.camera(self, didFail: error) - } catch { - delegate?.camera(self, didFail: .cameraComponentError(reason: .torchModeNotSupported)) - } - } - - func focus(with focusMode: FocusMode, - exposureMode: ExposureMode, - at devicePoint: CGPoint, - monitorSubjectAreaChange: Bool) { - let point = previewLayer.captureDevicePointConverted(fromLayerPoint: devicePoint) - do { - try videoDevice.focus(with: focusMode, - exposureMode: exposureMode, - at: point, - monitorSubjectAreaChange: monitorSubjectAreaChange) - } catch let error as CameraError { - delegate?.camera(self, didFail: error) - } catch { - delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToLockDevice)) - } - } - - func flipCamera() { - do { - isFlipped.toggle() - DispatchQueue.main.async { - self.previewLayer.removeFromSuperlayer() - } - videoDevice.removeObserver() - session.cleanupSession() - try configureSession() - videoDevice.addObserver() - } catch let error as CameraError { - delegate?.camera(self, didFail: error) - } catch { - delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToLockDevice)) - } - } - - func zoom(atScale: CGFloat) { - lastZoomFactor = videoDevice.minMaxZoom(atScale * lastZoomFactor, with: options) - do { - try videoDevice.zoom(atScale: lastZoomFactor) - } catch let error as CameraError { - delegate?.camera(self, didFail: error) - } catch { - delegate?.camera(self, didFail: .cameraComponentError(reason: .failedToLockDevice)) - } - } - - private func configureSession() throws { - defer { - session.commitConfiguration() - } - session.beginConfiguration() - - guard configureVideoDevice() else { - throw CameraError.cameraComponentError(reason: .failedToConfigureVideoDevice) - } - guard configureAudioDevice() else { - throw CameraError.cameraComponentError(reason: .failedToConfigureAudioDevice) - } - guard configureMovieOutput() else { - throw CameraError.cameraComponentError(reason: .failedToAddMovieOutput) - } - guard configurePhotoOutput() else { - throw CameraError.cameraComponentError(reason: .failedToAddPhotoOutput) - } - guard configurePreviewLayer() else { - throw CameraError.cameraComponentError(reason: .failedToAddPreviewLayer) - } - } - - private func configureVideoDevice() -> Bool { - let configurationResult = videoDevice.configureVideoDevice(forSession: session, - andOptions: options, - isFlipped: isFlipped) - videoDevice.addObserver() - videoDevice.onVideoDeviceError = { [weak self] error in - guard let self else { return } - self.delegate?.camera(self, didFail: error) - } - - return configurationResult - } - - private func configureAudioDevice() -> Bool { - audioDevice.configureAudioDevice(forSession: session, - andOptions: options, - isFlipped: isFlipped) - } - - private func configureMovieOutput() -> Bool { - let configurationResult = movieOutput.configureMovieFileOutput(forSession: session, - andOptions: options, - videoDevice: videoDevice, - audioDevice: audioDevice, - isFlipped: isFlipped) - - movieOutput.onMovieCaptureStart = { [weak self] url in - guard let self else { return } - self.delegate?.camera(self, didStartRecordingVideo: url) - } - movieOutput.onMovieCaptureSuccess = { [weak self] url in - guard let self else { return } - self.delegate?.camera(self, didRecordVideo: url) - } - movieOutput.onMovieCaptureError = { [weak self] error in - guard let self else { return } - self.delegate?.camera(self, didFail: error) - } - - return configurationResult - } - - private func configurePhotoOutput() -> Bool { - let configurationResult = photoOutput.configurePhotoOutput(forSession: session, - andOptions: options, - videoDevice: videoDevice, - isFlipped: isFlipped) - - photoOutput.onPhotoCaptureSuccess = { [weak self] data in - guard let self else { return } - self.delegate?.camera(self, didCapturePhoto: data) - } - photoOutput.onPhotoCaptureError = { [weak self] error in - guard let self else { return } - self.delegate?.camera(self, didFail: error) - } - - return configurationResult - } - - private func configurePreviewLayer() -> Bool { - previewLayer.configurePreviewLayer(forSession: session, - andOptions: options, - videoDevice: videoDevice) - } -} diff --git a/Sources/CameraKage/General/Camera/CameraComponent.swift b/Sources/CameraKage/General/Camera/CameraComponent.swift deleted file mode 100644 index 38a570e..0000000 --- a/Sources/CameraKage/General/Camera/CameraComponent.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// CameraComponent.swift -// CameraKage -// -// Created by Lobont Andrei on 10.05.2023. -// - -import UIKit -import AVFoundation - -class CameraComponent: UIView { - private let camera: Camera - private lazy var pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(pinch(_:))) - - var isRecording: Bool { camera.isRecording } - - init(camera: Camera) { - self.camera = camera - super.init(frame: .zero) - camera.embedPreviewLayer(in: layer) - configurePinchGesture() - } - - required init?(coder: NSCoder) { - fatalError("Use camera init") - } - - override func layoutSubviews() { - super.layoutSubviews() - camera.setPreviewLayerFrame(frame: frame) - } - - func capturePhoto(_ flashMode: FlashMode, redEyeCorrection: Bool) { - camera.capturePhoto(flashMode, redEyeCorrection: redEyeCorrection) - } - - func startMovieRecording(_ flashMode: FlashMode) { - camera.startMovieRecording(flashMode) - } - - func stopMovieRecording() { - camera.stopMovieRecording() - } - - func flipCamera() { - camera.flipCamera() - DispatchQueue.main.async { - self.camera.embedPreviewLayer(in: self.layer) - self.layoutSubviews() - } - } - - func focus(with focusMode: FocusMode, - exposureMode: ExposureMode, - at devicePoint: CGPoint, - monitorSubjectAreaChange: Bool) { - camera.focus(with: focusMode, - exposureMode: exposureMode, - at: devicePoint, - monitorSubjectAreaChange: monitorSubjectAreaChange) - } - - private func configurePinchGesture() { - if camera.allowsPinchZoom { - DispatchQueue.main.async { - self.addGestureRecognizer(self.pinchGestureRecognizer) - } - } - } - - @objc private func pinch(_ pinch: UIPinchGestureRecognizer) { - camera.zoom(atScale: pinch.scale) - } -} diff --git a/Sources/CameraKage/General/Camera/CameraComposer.swift b/Sources/CameraKage/General/Camera/CameraComposer.swift deleted file mode 100644 index d90d773..0000000 --- a/Sources/CameraKage/General/Camera/CameraComposer.swift +++ /dev/null @@ -1,170 +0,0 @@ -// -// CameraComposer.swift -// -// -// Created by Lobont Andrei on 30.05.2023. -// - -import UIKit - -final class CameraComposer: UIView, CameraComposerProtocol { - private var sessionComposer: SessionComposerProtocol - private let sessionQueue = DispatchQueue(label: "LA.cameraKage.sessionQueue") - private var cameraComponent: CameraComponent! - - var isSessionRunning: Bool { sessionComposer.isSessionRunning } - var isRecording: Bool { cameraComponent.isRecording } - - weak var delegate: CameraComposerDelegate? - - init(sessionComposer: SessionComposerProtocol = SessionComposer()) { - self.sessionComposer = sessionComposer - super.init(frame: .zero) - self.sessionComposer.delegate = self - } - - required init?(coder: NSCoder) { - fatalError("Using custom init") - } - - func startCameraSession(with options: CameraComponentParsedOptions) { - setupCameraComponent(with: options) - sessionQueue.async { [weak self] in - guard let self else { return } - self.sessionComposer.startSession() - } - } - - func stopCameraSession() { - destroyCameraComponent() - sessionQueue.async { [weak self] in - guard let self else { return } - self.sessionComposer.stopSession() - } - } - - func capturePhoto(_ flashOption: FlashMode, redEyeCorrection: Bool) { - sessionQueue.async { [weak self] in - guard let self else { return } - self.cameraComponent.capturePhoto(flashOption, redEyeCorrection: redEyeCorrection) - } - } - - func startVideoRecording(_ flashOption: FlashMode) { - sessionQueue.async { [weak self] in - guard let self, !self.isRecording else { return } - self.cameraComponent.startMovieRecording(flashOption) - } - } - - func stopVideoRecording() { - sessionQueue.async { [weak self] in - guard let self, self.isRecording else { return } - self.cameraComponent.stopMovieRecording() - } - } - - func flipCamera() { - sessionQueue.async { [weak self] in - guard let self, !self.isRecording else { return } - self.sessionComposer.pauseSession() - self.cameraComponent.flipCamera() - self.sessionComposer.resumeSession() - } - } - - func adjustFocusAndExposure(with focusMode: FocusMode, - exposureMode: ExposureMode, - at devicePoint: CGPoint, - monitorSubjectAreaChange: Bool) { - sessionQueue.async { [weak self] in - guard let self else { return } - self.cameraComponent.focus(with: focusMode, - exposureMode: exposureMode, - at: devicePoint, - monitorSubjectAreaChange: monitorSubjectAreaChange) - } - } - - private func setupCameraComponent(with options: CameraComponentParsedOptions) { - sessionQueue.async { [weak self] in - guard let self else { return } - let cameraCreationResult = self.sessionComposer.createCamera(options) - switch cameraCreationResult { - case .success(let camera): - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.cameraComponent = CameraComponent(camera: camera) - camera.delegate = self - self.addSubview(self.cameraComponent) - self.cameraComponent.layoutToFill(inView: self) - } - case .failure(let error): - self.delegate?.cameraComposer(self, didReceiveError: error) - } - } - } - - private func destroyCameraComponent() { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.cameraComponent.removeFromSuperview() - self.cameraComponent = nil - } - } -} - -// MARK: - SessionComposerDelegate -extension CameraComposer: SessionComposerDelegate { - func sessionComposer(_ sessionComposer: SessionComposerProtocol, didReceiveRuntimeError error: CameraError, shouldRestartCamera restart: Bool) { - if restart { - sessionQueue.async { - sessionComposer.resumeSession() - } - } - delegate?.cameraComposer(self, didReceiveError: error) - } - - func sessionComposer(_ sessionComposer: SessionComposerProtocol, didReceiveSessionInterruption reason: SessionInterruptionReason) { - delegate?.cameraComposer(self, didReceiveSessionInterruption: reason) - } - - func sessionComposerDidFinishSessionInterruption(_ sessionComposer: SessionComposerProtocol) { - delegate?.cameraComposerDidFinishSessionInterruption(self) - } - - func sessionComposerDidStartCameraSession(_ sessionComposer: SessionComposerProtocol) { - delegate?.cameraComposerDidStartCameraSession(self) - } - - func sessionComposerDidStopCameraSession(_ sessionComposer: SessionComposerProtocol) { - delegate?.cameraComposerDidStopCameraSession(self) - } - - func sessionComposerDidChangeDeviceAreaOfInterest(_ sessionComposer: SessionComposerProtocol) { - delegate?.cameraComposerDidChangeDeviceAreaOfInterest(self) - } -} - -// MARK: - CameraComponentDelegate -extension CameraComposer: CameraDelegate { - func camera(_ camera: Camera, didCapturePhoto photo: Data) { - delegate?.cameraComposer(self, didCapturePhoto: photo) - } - - func camera(_ camera: Camera, didStartRecordingVideo atFileURL: URL) { - delegate?.cameraComposer(self, didStartRecordingVideo: atFileURL) - } - - func camera(_ camera: Camera, didRecordVideo videoURL: URL) { - delegate?.cameraComposer(self, didRecordVideo: videoURL) - } - - func camera(_ camera: Camera, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) { - delegate?.cameraComposer(self, didZoomAtScale: scale, outOfMaximumScale: maxScale) - } - - func camera(_ camera: Camera, didFail withError: CameraError) { - delegate?.cameraComposer(self, didReceiveError: withError) - } -} diff --git a/Sources/CameraKage/General/Camera/CameraComposerProtocol.swift b/Sources/CameraKage/General/Camera/CameraComposerProtocol.swift deleted file mode 100644 index 4a9be2d..0000000 --- a/Sources/CameraKage/General/Camera/CameraComposerProtocol.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// CameraComposerProtocol.swift -// -// -// Created by Lobont Andrei on 08.06.2023. -// - -import UIKit - -protocol CameraComposerProtocol: UIView { - var isSessionRunning: Bool { get } - var isRecording: Bool { get } - var delegate: CameraComposerDelegate? { get set } - - func startCameraSession(with options: CameraComponentParsedOptions) - func stopCameraSession() - func capturePhoto(_ flashOption: FlashMode, redEyeCorrection: Bool) - func startVideoRecording(_ flashOption: FlashMode) - func stopVideoRecording() - func flipCamera() - func adjustFocusAndExposure(with focusMode: FocusMode, - exposureMode: ExposureMode, - at devicePoint: CGPoint, - monitorSubjectAreaChange: Bool) -} diff --git a/Sources/CameraKage/General/CameraViews/BaseCameraView.swift b/Sources/CameraKage/General/CameraViews/BaseCameraView.swift new file mode 100644 index 0000000..3335e55 --- /dev/null +++ b/Sources/CameraKage/General/CameraViews/BaseCameraView.swift @@ -0,0 +1,196 @@ +// +// BaseCameraView.swift +// +// +// Created by Lobont Andrei on 12.06.2023. +// + +import UIKit + +/// Base camera view containing camera displaying and basic features used on all types of cameras. +public class BaseCameraView: UIView { + private var baseCamera: BaseCameraInterface + private var lastZoomFactor: CGFloat = 1.0 + private lazy var pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(pinch(_:))) + + let sessionQueue: DispatchQueue + let delegatesManager: DelegatesManagerProtocol + + /// Determines if the camera session is running. + public var isSessionRunning: Bool { baseCamera.isSessionRunning } + + init(baseCamera: BaseCameraInterface, + delegatesManager: DelegatesManagerProtocol = DelegatesManager(), + sessionQueue: DispatchQueue = DispatchQueue(label: "LA.cameraKage.sessionQueue")) { + self.baseCamera = baseCamera + self.delegatesManager = delegatesManager + self.sessionQueue = sessionQueue + super.init(frame: .zero) + baseCamera.setSessionDelegate(self) + baseCamera.embedPreviewLayer(in: layer) + if baseCamera.isZoomAllowed { + addGestureRecognizer(pinchGestureRecognizer) + } + } + + required init?(coder: NSCoder) { + fatalError("Coder not usable.") + } + + public override func layoutSubviews() { + super.layoutSubviews() + baseCamera.setPreviewLayerFrame(frame) + } + + /** + Starts the camera session.. + */ + public func startCamera() { + sessionQueue.async { [weak self] in + guard let self else { return } + self.baseCamera.startCameraSession() + } + } + + /** + Stops the camera session. + */ + public func stopCamera() { + sessionQueue.async { [weak self] in + guard let self else { return } + self.baseCamera.stopCameraSession() + } + } + + /** + Flips the camera from back to front and vice-versa. + + - important: Camera can't be flipped while recording a video. Session is restarted when flipping the camera. + */ + public func flipCamera() { + sessionQueue.async { [weak self] in + guard let self else { return } + do { + self.lastZoomFactor = 1.0 + self.baseCamera.stopCameraSession() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.baseCamera.removePreviewLayer() + } + try self.baseCamera.flipCamera() + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.baseCamera.embedPreviewLayer(in: self.layer) + self.layoutSubviews() + } + self.baseCamera.startCameraSession() + } catch let error as CameraError { + invokeDelegates { $0.cameraDidEncounterError(error: error) } + } catch { + invokeDelegates { $0.cameraDidEncounterError(error: .cameraComponentError(reason: .failedToLockDevice)) } + } + } + } + + /** + Adjusts the focus and the exposure of the camera. + + - parameter focusMode: Focus mode of the camera. Default is `.autoFocus`. + - parameter exposureMode: Exposure mode of the camera. Default is `.autoExpose`. + - parameter devicePoint: The point of the camera where the focus should be switched to. + - parameter monitorSubjectAreaChange: If set `true`, it registers the camera to receive notifications about area changes for the user to re-focus if needed. Default is `true`. + */ + public func focus(with focusMode: FocusMode = .autoFocus, + exposureMode: ExposureMode = .autoExpose, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool = true) { + sessionQueue.async { [weak self] in + guard let self else { return } + do { + try self.baseCamera.focus(with: focusMode, + exposureMode: exposureMode, + at: devicePoint, + monitorSubjectAreaChange: monitorSubjectAreaChange) + } catch let error as CameraError { + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } catch { + self.invokeDelegates { $0.cameraDidEncounterError(error: .cameraComponentError(reason: .failedToLockDevice)) } + } + } + } + + func registerDelegate(_ delegate: any BaseCameraDelegate) { + delegatesManager.registerDelegate(delegate) + } + + func unregisterDelegate(_ delegate: any BaseCameraDelegate) { + delegatesManager.unregisterDelegate(delegate) + } + + private func invokeDelegates(_ execute: @escaping (BaseCameraDelegate) -> Void) { + baseCamera.delegateQueue.async { [weak self] in + guard let self else { return } + self.delegatesManager.invokeDelegates { delegate in + guard let delegate = delegate as? BaseCameraDelegate else { return } + execute(delegate) + } + } + } + + @objc private func pinch(_ pinch: UIPinchGestureRecognizer) { + sessionQueue.async { [weak self] in + guard let self else { return } + let newScaleFactor = self.baseCamera.minMaxZoom(pinch.scale * self.lastZoomFactor) + do { + switch pinch.state { + case .changed: + try self.baseCamera.zoom(atScale: newScaleFactor) + self.invokeDelegates { $0.cameraDidZoom(atScale: newScaleFactor, + outOfMaximumScale: self.baseCamera.maxZoomScale) } + case .ended: + self.lastZoomFactor = self.baseCamera.minMaxZoom(newScaleFactor) + try self.baseCamera.zoom(atScale: self.lastZoomFactor) + self.invokeDelegates { $0.cameraDidZoom(atScale: self.lastZoomFactor, + outOfMaximumScale: self.baseCamera.maxZoomScale) } + default: + break + } + } catch let error as CameraError { + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } catch { + self.invokeDelegates { $0.cameraDidEncounterError(error: .cameraComponentError(reason: .failedToLockDevice)) } + } + } + } +} + +// MARK: - SessionDelegate +extension BaseCameraView: SessionDelegate { + func session(_ sessionComposer: Session, didReceiveRuntimeError error: CameraError, shouldRestartCamera restart: Bool) { + sessionQueue.async { [weak self] in + guard let self else { return } + self.baseCamera.resumeCameraSession() + } + invokeDelegates { $0.cameraDidEncounterError(error: error) } + } + + func session(_ sessionComposer: Session, didReceiveSessionInterruption reason: SessionInterruptionReason) { + invokeDelegates { $0.cameraDidReceiveSessionInterruption(withReason: reason) } + } + + func sessionDidFinishSessionInterruption(_ sessionComposer: Session) { + invokeDelegates { $0.cameraDidFinishSessionInterruption() } + } + + func sessionDidStartCameraSession(_ sessionComposer: Session) { + invokeDelegates { $0.cameraDidStartCameraSession() } + } + + func sessionDidStopCameraSession(_ sessionComposer: Session) { + invokeDelegates { $0.cameraDidStopCameraSession() } + } + + func sessionDidChangeDeviceAreaOfInterest(_ sessionComposer: Session) { + invokeDelegates { $0.cameraDidChangeDeviceAreaOfInterest() } + } +} diff --git a/Sources/CameraKage/General/CameraViews/CameraView.swift b/Sources/CameraKage/General/CameraViews/CameraView.swift new file mode 100644 index 0000000..a0e7b22 --- /dev/null +++ b/Sources/CameraKage/General/CameraViews/CameraView.swift @@ -0,0 +1,133 @@ +// +// CameraView.swift +// +// +// Created by Lobont Andrei on 13.06.2023. +// + +import Foundation + +/// View capable of both video recordings and photo captures. +public class CameraView: BaseCameraView { + private var camera: CameraInterface + + /// Determines if the camera has a video recording in progress. + public var isRecording: Bool { camera.isRecording } + + init(camera: CameraInterface) { + self.camera = camera + super.init(baseCamera: camera) + setupPhotoCapturer() + setupVideoCapturer() + } + + required init?(coder: NSCoder) { + fatalError("Coder not usable.") + } + + /** + Register a delegate to receive notifications regarding the camera session. + + - parameter delegate: The object that will receive the notifications. + */ + public func registerDelegate(_ delegate: any CameraDelegate) { + super.registerDelegate(delegate) + } + + /** + Unregisters a delegate from receiving notifications. + + - parameter delegate: The object to be removed. + */ + public func unregisterDelegate(_ delegate: any CameraDelegate) { + super.unregisterDelegate(delegate) + } + + /** + Captures a photo from the camera. Resulted photo will be delivered via `PhotoCameraDelegate`. + + - parameter flashOption: Indicates what flash option should be used when capturing the photo. Default is `.off`. + - parameter redEyeCorrection: Determines if red eye correction should be applied or not. Default is `true`. + */ + public func capturePhoto(flashMode: FlashMode = .off, + redEyeCorrection: Bool = true) { + sessionQueue.async { [weak self] in + guard let self else { return } + self.camera.capturePhoto(flashMode, redEyeCorrection: redEyeCorrection) + } + } + + /** + Starts a video recording for the camera. `VideoCameraDelegate` sends a notification when the recording has started. + + - parameter flashOption: Indicates what flash option should be used for the video recording. Default is `.off`. + + - important: Front camera dosen't support video recordings with flash mode `.on`. + */ + public func startVideoRecording(flashOption: FlashMode = .off) { + sessionQueue.async { [weak self] in + guard let self, !self.isRecording else { return } + self.camera.startVideoRecording() + do { + try self.camera.configureFlash(flashOption) + } catch let error as CameraError { + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } catch { + self.invokeDelegates { $0.cameraDidEncounterError(error: .cameraComponentError(reason: .torchModeNotSupported)) } + } + } + } + + /** + Stops the video recording. `VideoCameraDelegate` sends a notification containing the URL of the video file. + */ + public func stopVideoRecording() { + sessionQueue.async { [weak self] in + guard let self, self.isRecording else { return } + self.camera.stopVideoRecording() + do { + try self.camera.configureFlash(.off) + } catch let error as CameraError { + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } catch { + self.invokeDelegates { $0.cameraDidEncounterError(error: .cameraComponentError(reason: .torchModeNotSupported)) } + } + } + } + + private func setupVideoCapturer() { + camera.onMovieCaptureStart = { [weak self] url in + guard let self else { return } + self.invokeDelegates { $0.cameraDidStartVideoRecording(atFileURL: url) } + } + camera.onMovieCaptureSuccess = { [weak self] url in + guard let self else { return } + self.invokeDelegates { $0.cameraDidFinishVideoRecording(atFileURL: url) } + } + camera.onMovieCaptureError = { [weak self] error in + guard let self else { return } + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } + } + + private func setupPhotoCapturer() { + camera.onPhotoCaptureSuccess = { [weak self] data in + guard let self else { return } + self.invokeDelegates { $0.cameraDidCapturePhoto(withData: data) } + } + camera.onPhotoCaptureError = { [weak self] error in + guard let self else { return } + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } + } + + private func invokeDelegates(_ execute: @escaping (any CameraDelegate) -> Void) { + camera.delegateQueue.async { [weak self] in + guard let self else { return } + self.delegatesManager.invokeDelegates { delegate in + guard let delegate = delegate as? (any CameraDelegate) else { return } + execute(delegate) + } + } + } +} diff --git a/Sources/CameraKage/General/CameraViews/PhotoCameraView.swift b/Sources/CameraKage/General/CameraViews/PhotoCameraView.swift new file mode 100644 index 0000000..595e14d --- /dev/null +++ b/Sources/CameraKage/General/CameraViews/PhotoCameraView.swift @@ -0,0 +1,76 @@ +// +// PhotoCameraView.swift +// +// +// Created by Lobont Andrei on 11.06.2023. +// + +import Foundation + +/// View capable only of photo captures. +public class PhotoCameraView: BaseCameraView { + private var photoCamera: PhotoCameraInterface + + init(photoCamera: PhotoCameraInterface) { + self.photoCamera = photoCamera + super.init(baseCamera: photoCamera) + setupPhotoCapturer() + } + + required init?(coder: NSCoder) { + fatalError("Coder not usable.") + } + + /** + Register a delegate to receive notifications regarding the camera session. + + - parameter delegate: The object that will receive the notifications. + */ + public func registerDelegate(_ delegate: any PhotoCameraDelegate) { + super.registerDelegate(delegate) + } + + /** + Unregisters a delegate from receiving notifications. + + - parameter delegate: The object to be removed. + */ + public func unregisterDelegate(_ delegate: any PhotoCameraDelegate) { + super.unregisterDelegate(delegate) + } + + /** + Captures a photo from the camera. Resulted photo will be delivered via `PhotoCameraDelegate`. + + - parameter flashOption: Indicates what flash option should be used when capturing the photo. Default is `.off`. + - parameter redEyeCorrection: Determines if red eye correction should be applied or not. Default is `true`. + */ + public func capturePhoto(flashMode: FlashMode = .off, + redEyeCorrection: Bool = true) { + sessionQueue.async { [weak self] in + guard let self else { return } + self.photoCamera.capturePhoto(flashMode, redEyeCorrection: redEyeCorrection) + } + } + + private func setupPhotoCapturer() { + photoCamera.onPhotoCaptureSuccess = { [weak self] data in + guard let self else { return } + self.invokeDelegates { $0.cameraDidCapturePhoto(withData: data) } + } + photoCamera.onPhotoCaptureError = { [weak self] error in + guard let self else { return } + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } + } + + private func invokeDelegates(_ execute: @escaping (any PhotoCameraDelegate) -> Void) { + photoCamera.delegateQueue.async { [weak self] in + guard let self else { return } + self.delegatesManager.invokeDelegates { delegate in + guard let delegate = delegate as? (any PhotoCameraDelegate) else { return } + execute(delegate) + } + } + } +} diff --git a/Sources/CameraKage/General/CameraViews/VideoCameraView.swift b/Sources/CameraKage/General/CameraViews/VideoCameraView.swift new file mode 100644 index 0000000..96bb991 --- /dev/null +++ b/Sources/CameraKage/General/CameraViews/VideoCameraView.swift @@ -0,0 +1,107 @@ +// +// VideoCameraView.swift +// +// +// Created by Lobont Andrei on 11.06.2023. +// + +import Foundation + +/// View capable only of video recordings. +public class VideoCameraView: BaseCameraView { + private var videoCamera: VideoCameraInterface + + /// Determines if the camera has a video recording in progress. + public var isRecording: Bool { videoCamera.isRecording } + + init(videoCamera: VideoCameraInterface) { + self.videoCamera = videoCamera + super.init(baseCamera: videoCamera) + setupVideoCapturer() + } + + required init?(coder: NSCoder) { + fatalError("Coder not usable.") + } + + /** + Register a delegate to receive notifications regarding the camera session. + + - parameter delegate: The object that will receive the notifications. + */ + public func registerDelegate(_ delegate: any VideoCameraDelegate) { + super.registerDelegate(delegate) + } + + /** + Unregisters a delegate from receiving notifications. + + - parameter delegate: The object to be removed. + */ + public func unregisterDelegate(_ delegate: any VideoCameraDelegate) { + super.unregisterDelegate(delegate) + } + + /** + Starts a video recording for the camera. `VideoCameraDelegate` sends a notification when the recording has started. + + - parameter flashOption: Indicates what flash option should be used for the video recording. Default is `.off`. + + - important: Front camera dosen't support video recordings with flash mode `.on`. + */ + public func startVideoRecording(flashOption: FlashMode = .off) { + sessionQueue.async { [weak self] in + guard let self, !self.isRecording else { return } + self.videoCamera.startVideoRecording() + do { + try self.videoCamera.configureFlash(flashOption) + } catch let error as CameraError { + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } catch { + self.invokeDelegates { $0.cameraDidEncounterError(error: .cameraComponentError(reason: .torchModeNotSupported)) } + } + } + } + + /** + Stops the video recording. `VideoCameraDelegate` sends a notification containing the URL of the video file. + */ + public func stopVideoRecording() { + sessionQueue.async { [weak self] in + guard let self, self.isRecording else { return } + self.videoCamera.stopVideoRecording() + do { + try self.videoCamera.configureFlash(.off) + } catch let error as CameraError { + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } catch { + self.invokeDelegates { $0.cameraDidEncounterError(error: .cameraComponentError(reason: .torchModeNotSupported)) } + } + } + } + + private func setupVideoCapturer() { + videoCamera.onMovieCaptureStart = { [weak self] url in + guard let self else { return } + self.invokeDelegates { $0.cameraDidStartVideoRecording(atFileURL: url) } + } + videoCamera.onMovieCaptureSuccess = { [weak self] url in + guard let self else { return } + self.invokeDelegates { $0.cameraDidFinishVideoRecording(atFileURL: url) } + } + videoCamera.onMovieCaptureError = { [weak self] error in + guard let self else { return } + self.invokeDelegates { $0.cameraDidEncounterError(error: error) } + } + } + + private func invokeDelegates(_ execute: @escaping (any VideoCameraDelegate) -> Void) { + videoCamera.delegateQueue.async { [weak self] in + guard let self else { return } + self.delegatesManager.invokeDelegates { delegate in + guard let delegate = delegate as? (any VideoCameraDelegate) else { return } + execute(delegate) + } + } + } +} diff --git a/Sources/CameraKage/General/Cameras/BaseCamera/BaseCamera.swift b/Sources/CameraKage/General/Cameras/BaseCamera/BaseCamera.swift new file mode 100644 index 0000000..d5a3049 --- /dev/null +++ b/Sources/CameraKage/General/Cameras/BaseCamera/BaseCamera.swift @@ -0,0 +1,104 @@ +// +// BaseCamera.swift +// +// +// Created by Lobont Andrei on 30.05.2023. +// + +import Foundation +import QuartzCore.CALayer + +class BaseCamera: BaseCameraInterface { + private let videoInput: VideoInput + private let options: CameraComponentParsedOptions + + var session: Session + let videoLayer: VideoLayer + var isZoomAllowed: Bool { options.pinchToZoomEnabled } + var delegateQueue: DispatchQueue { options.delegateQeueue } + var isSessionRunning: Bool { session.isRunning } + var maxZoomScale: CGFloat { options.maximumZoomScale } + + init(session: Session, + videoInput: VideoInput, + videoLayer: VideoLayer, + options: CameraComponentParsedOptions) { + self.session = session + self.videoInput = videoInput + self.videoLayer = videoLayer + self.options = options + } + + func startCameraSession() { + session.addObservers() + session.startSession() + } + + func stopCameraSession() { + session.stopSession() + session.removeObservers() + } + + func resumeCameraSession() { + session.startSession() + } + + func setSessionDelegate(_ delegate: SessionDelegate) { + session.delegate = delegate + } + + func flipCamera() throws { + do { + try videoInput.flip() + } catch let error { + throw error + } + } + + func focus(with focusMode: FocusMode, + exposureMode: ExposureMode, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool) throws { + do { + let point = videoLayer.captureDevicePointConverted(fromLayerPoint: devicePoint) + try videoInput.focus(focusMode: focusMode, + exposureMode: exposureMode, + point: point, + monitorSubjectAreaChange: monitorSubjectAreaChange) + } catch let error { + throw error + } + } + + func zoom(atScale scale: CGFloat) throws { + do { + try videoInput.zoom(atScale: scale) + } catch let error { + throw error + } + } + + func minMaxZoom(_ factor: CGFloat) -> CGFloat { + videoInput.minMaxZoom(factor, options: options) + } + + func configureFlash(_ flashMode: FlashMode) throws { + do { + try videoInput.configureFlash(flashMode) + } catch let error { + throw error + } + } + + func embedPreviewLayer(in layer: CALayer) { + videoLayer.embedPreviewLayer(in: layer) + } + + func setPreviewLayerFrame(_ frame: CGRect) { + videoLayer.setPreviewLayerFrame(frame) + } + + func removePreviewLayer() { + videoLayer.removeFromSuperlayer() + } +} diff --git a/Sources/CameraKage/General/Cameras/BaseCamera/BaseCameraInterface.swift b/Sources/CameraKage/General/Cameras/BaseCamera/BaseCameraInterface.swift new file mode 100644 index 0000000..a07f025 --- /dev/null +++ b/Sources/CameraKage/General/Cameras/BaseCamera/BaseCameraInterface.swift @@ -0,0 +1,32 @@ +// +// BaseCameraInterface.swift +// +// +// Created by Lobont Andrei on 11.06.2023. +// + +import Foundation +import QuartzCore.CALayer + +protocol BaseCameraInterface { + var isZoomAllowed: Bool { get } + var delegateQueue: DispatchQueue { get } + var isSessionRunning: Bool { get } + var maxZoomScale: CGFloat { get } + + func startCameraSession() + func stopCameraSession() + func resumeCameraSession() + func setSessionDelegate(_ delegate: SessionDelegate) + func flipCamera() throws + func focus(with focusMode: FocusMode, + exposureMode: ExposureMode, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool) throws + func zoom(atScale scale: CGFloat) throws + func minMaxZoom(_ factor: CGFloat) -> CGFloat + func configureFlash(_ flashMode: FlashMode) throws + func embedPreviewLayer(in layer: CALayer) + func setPreviewLayerFrame(_ frame: CGRect) + func removePreviewLayer() +} diff --git a/Sources/CameraKage/General/Cameras/Camera/Camera.swift b/Sources/CameraKage/General/Cameras/Camera/Camera.swift new file mode 100644 index 0000000..4f1af39 --- /dev/null +++ b/Sources/CameraKage/General/Cameras/Camera/Camera.swift @@ -0,0 +1,82 @@ +// +// Camera.swift +// +// +// Created by Lobont Andrei on 15.06.2023. +// + +import Foundation + +class Camera: BaseCamera, CameraInterface { + private var photoCapturer: PhotoCapturer + private var movieCapturer: MovieCapturer + + var isRecording: Bool { movieCapturer.isRecording } + + var onMovieCaptureSuccess: ((URL) -> Void)? { + didSet { + movieCapturer.onMovieCaptureSuccess = onMovieCaptureSuccess + } + } + var onMovieCaptureStart: ((URL) -> Void)? { + didSet { + movieCapturer.onMovieCaptureStart = onMovieCaptureStart + } + } + var onMovieCaptureError: ((CameraError) -> Void)? { + didSet { + movieCapturer.onMovieCaptureError = onMovieCaptureError + } + } + var onPhotoCaptureSuccess: ((Data) -> Void)? { + didSet { + photoCapturer.onPhotoCaptureSuccess = onPhotoCaptureSuccess + } + } + var onPhotoCaptureError: ((CameraError) -> Void)? { + didSet { + photoCapturer.onPhotoCaptureError = onPhotoCaptureError + } + } + + init(session: Session, + videoInput: VideoInput, + videoLayer: VideoLayer, + photoCapturer: PhotoCapturer, + movieCapturer: MovieCapturer, + options: CameraComponentParsedOptions) { + self.photoCapturer = photoCapturer + self.movieCapturer = movieCapturer + super.init(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + } + + override func flipCamera() throws { + defer { + session.commitConfiguration() + } + session.beginConfiguration() + do { + try super.flipCamera() + try movieCapturer.handleFlip() + try photoCapturer.handleFlip() + try videoLayer.reloadPreviewLayer() + } catch let error { + throw error + } + } + + func capturePhoto(_ flashOption: FlashMode, redEyeCorrection: Bool) { + photoCapturer.capturePhoto(flashOption, redEyeCorrection: redEyeCorrection) + } + + func startVideoRecording() { + movieCapturer.startMovieRecording() + } + + func stopVideoRecording() { + movieCapturer.stopMovieRecording() + } +} diff --git a/Sources/CameraKage/General/Cameras/Camera/CameraInterface.swift b/Sources/CameraKage/General/Cameras/Camera/CameraInterface.swift new file mode 100644 index 0000000..da6e24c --- /dev/null +++ b/Sources/CameraKage/General/Cameras/Camera/CameraInterface.swift @@ -0,0 +1,10 @@ +// +// CameraInterface.swift +// +// +// Created by Lobont Andrei on 15.06.2023. +// + +import Foundation + +protocol CameraInterface: BaseCameraInterface & PhotoCameraInterface & VideoCameraInterface {} diff --git a/Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCamera.swift b/Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCamera.swift new file mode 100644 index 0000000..cbf0983 --- /dev/null +++ b/Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCamera.swift @@ -0,0 +1,53 @@ +// +// PhotoCamera.swift +// +// +// Created by Lobont Andrei on 11.06.2023. +// + +import Foundation + +class PhotoCamera: BaseCamera, PhotoCameraInterface { + private var photoCapturer: PhotoCapturer + + var onPhotoCaptureSuccess: ((Data) -> Void)? { + didSet { + photoCapturer.onPhotoCaptureSuccess = onPhotoCaptureSuccess + } + } + var onPhotoCaptureError: ((CameraError) -> Void)? { + didSet { + photoCapturer.onPhotoCaptureError = onPhotoCaptureError + } + } + + init(session: Session, + videoInput: VideoInput, + videoLayer: VideoLayer, + photoCapturer: PhotoCapturer, + options: CameraComponentParsedOptions) { + self.photoCapturer = photoCapturer + super.init(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + } + + override func flipCamera() throws { + defer { + session.commitConfiguration() + } + session.beginConfiguration() + do { + try super.flipCamera() + try photoCapturer.handleFlip() + try videoLayer.reloadPreviewLayer() + } catch let error { + throw error + } + } + + func capturePhoto(_ flashOption: FlashMode, redEyeCorrection: Bool) { + photoCapturer.capturePhoto(flashOption, redEyeCorrection: redEyeCorrection) + } +} diff --git a/Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCameraInterface.swift b/Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCameraInterface.swift new file mode 100644 index 0000000..0a23619 --- /dev/null +++ b/Sources/CameraKage/General/Cameras/PhotoCamera/PhotoCameraInterface.swift @@ -0,0 +1,15 @@ +// +// PhotoCameraInterface.swift +// +// +// Created by Lobont Andrei on 11.06.2023. +// + +import Foundation + +protocol PhotoCameraInterface: BaseCameraInterface { + var onPhotoCaptureSuccess: ((Data) -> Void)? { get set } + var onPhotoCaptureError: ((CameraError) -> Void)? { get set } + + func capturePhoto(_ flashOption: FlashMode, redEyeCorrection: Bool) +} diff --git a/Sources/CameraKage/General/Cameras/VideoCamera/VideoCamera.swift b/Sources/CameraKage/General/Cameras/VideoCamera/VideoCamera.swift new file mode 100644 index 0000000..8ba7b7a --- /dev/null +++ b/Sources/CameraKage/General/Cameras/VideoCamera/VideoCamera.swift @@ -0,0 +1,63 @@ +// +// VideoCamera.swift +// +// +// Created by Lobont Andrei on 11.06.2023. +// + +import Foundation + +class VideoCamera: BaseCamera, VideoCameraInterface { + private var movieCapturer: MovieCapturer + + var isRecording: Bool { movieCapturer.isRecording } + var onMovieCaptureSuccess: ((URL) -> Void)? { + didSet { + movieCapturer.onMovieCaptureSuccess = onMovieCaptureSuccess + } + } + var onMovieCaptureStart: ((URL) -> Void)? { + didSet { + movieCapturer.onMovieCaptureStart = onMovieCaptureStart + } + } + var onMovieCaptureError: ((CameraError) -> Void)? { + didSet { + movieCapturer.onMovieCaptureError = onMovieCaptureError + } + } + + init(session: Session, + videoInput: VideoInput, + videoLayer: VideoLayer, + movieCapturer: MovieCapturer, + options: CameraComponentParsedOptions) { + self.movieCapturer = movieCapturer + super.init(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + } + + override func flipCamera() throws { + defer { + session.commitConfiguration() + } + session.beginConfiguration() + do { + try super.flipCamera() + try movieCapturer.handleFlip() + try videoLayer.reloadPreviewLayer() + } catch let error { + throw error + } + } + + func startVideoRecording() { + movieCapturer.startMovieRecording() + } + + func stopVideoRecording() { + movieCapturer.stopMovieRecording() + } +} diff --git a/Sources/CameraKage/General/Cameras/VideoCamera/VideoCameraInterface.swift b/Sources/CameraKage/General/Cameras/VideoCamera/VideoCameraInterface.swift new file mode 100644 index 0000000..3e47a3b --- /dev/null +++ b/Sources/CameraKage/General/Cameras/VideoCamera/VideoCameraInterface.swift @@ -0,0 +1,18 @@ +// +// VideoCameraInterface.swift +// +// +// Created by Lobont Andrei on 11.06.2023. +// + +import Foundation + +protocol VideoCameraInterface: BaseCameraInterface { + var isRecording: Bool { get } + var onMovieCaptureSuccess: ((URL) -> Void)? { get set } + var onMovieCaptureStart: ((URL) -> Void)? { get set } + var onMovieCaptureError: ((CameraError) -> Void)? { get set } + + func startVideoRecording() + func stopVideoRecording() +} diff --git a/Sources/CameraKage/General/Delegates/DelegatesManager.swift b/Sources/CameraKage/General/Delegates/DelegatesManager.swift index 04e32d7..da0b0e8 100644 --- a/Sources/CameraKage/General/Delegates/DelegatesManager.swift +++ b/Sources/CameraKage/General/Delegates/DelegatesManager.swift @@ -10,25 +10,24 @@ import Foundation protocol DelegatesManagerProtocol { var delegates: NSHashTable { get } - func registerDelegate(_ delegate: CameraKageDelegate) - func unregisterDelegate(_ delegate: CameraKageDelegate) - func invokeDelegates(_ execute: (CameraKageDelegate) -> Void) + func registerDelegate(_ delegate: AnyObject) + func unregisterDelegate(_ delegate: AnyObject) + func invokeDelegates(_ execute: (AnyObject) -> Void) } final class DelegatesManager: DelegatesManagerProtocol { let delegates: NSHashTable = NSHashTable.weakObjects() - func registerDelegate(_ delegate: CameraKageDelegate) { - delegates.add(delegate as AnyObject) + func registerDelegate(_ delegate: AnyObject) { + delegates.add(delegate) } - func unregisterDelegate(_ delegate: CameraKageDelegate) { - delegates.remove(delegate as AnyObject) + func unregisterDelegate(_ delegate: AnyObject) { + delegates.remove(delegate) } - func invokeDelegates(_ execute: (CameraKageDelegate) -> Void) { + func invokeDelegates(_ execute: (AnyObject) -> Void) { delegates.allObjects.forEach { delegate in - guard let delegate = delegate as? CameraKageDelegate else { return } execute(delegate) } } diff --git a/Sources/CameraKage/General/Delegates/Internal/CameraComposerDelegate.swift b/Sources/CameraKage/General/Delegates/Internal/CameraComposerDelegate.swift deleted file mode 100644 index 21f41fa..0000000 --- a/Sources/CameraKage/General/Delegates/Internal/CameraComposerDelegate.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// CameraComposerDelegate.swift -// -// -// Created by Lobont Andrei on 05.06.2023. -// - -import Foundation - -protocol CameraComposerDelegate: AnyObject { - func cameraComposer(_ cameraComposer: CameraComposer, - didCapturePhoto photo: Data) - func cameraComposer(_ cameraComposer: CameraComposer, - didStartRecordingVideo atFileURL: URL) - func cameraComposer(_ cameraComposer: CameraComposer, - didRecordVideo videoURL: URL) - func cameraComposer(_ cameraComposer: CameraComposer, - didZoomAtScale scale: CGFloat, - outOfMaximumScale maxScale: CGFloat) - func cameraComposer(_ cameraComposer: CameraComposer, - didReceiveError error: CameraError) - func cameraComposer(_ cameraComposer: CameraComposer, - didReceiveSessionInterruption reason: SessionInterruptionReason) - func cameraComposerDidFinishSessionInterruption(_ cameraComposer: CameraComposer) - func cameraComposerDidStartCameraSession(_ cameraComposer: CameraComposer) - func cameraComposerDidStopCameraSession(_ cameraComposer: CameraComposer) - func cameraComposerDidChangeDeviceAreaOfInterest(_ cameraComposer: CameraComposer) -} diff --git a/Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift b/Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift deleted file mode 100644 index e408c69..0000000 --- a/Sources/CameraKage/General/Delegates/Internal/CameraDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// CameraDelegate.swift -// -// -// Created by Lobont Andrei on 05.06.2023. -// - -import Foundation - -protocol CameraDelegate: AnyObject { - func camera(_ camera: Camera, didCapturePhoto photo: Data) - func camera(_ camera: Camera, didStartRecordingVideo atFileURL: URL) - func camera(_ camera: Camera, didRecordVideo videoURL: URL) - func camera(_ camera: Camera, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) - func camera(_ camera: Camera, didFail withError: CameraError) -} diff --git a/Sources/CameraKage/General/Delegates/Internal/SessionComposerDelegate.swift b/Sources/CameraKage/General/Delegates/Internal/SessionComposerDelegate.swift deleted file mode 100644 index 508309f..0000000 --- a/Sources/CameraKage/General/Delegates/Internal/SessionComposerDelegate.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// SessionComposerDelegate.swift -// -// -// Created by Lobont Andrei on 30.05.2023. -// - -import AVFoundation - -protocol SessionComposerDelegate: AnyObject { - func sessionComposer(_ sessionComposer: SessionComposerProtocol, - didReceiveRuntimeError error: CameraError, - shouldRestartCamera restart: Bool) - func sessionComposer(_ sessionComposer: SessionComposerProtocol, - didReceiveSessionInterruption reason: SessionInterruptionReason) - func sessionComposerDidFinishSessionInterruption(_ sessionComposer: SessionComposerProtocol) - func sessionComposerDidStartCameraSession(_ sessionComposer: SessionComposerProtocol) - func sessionComposerDidStopCameraSession(_ sessionComposer: SessionComposerProtocol) - func sessionComposerDidChangeDeviceAreaOfInterest(_ sessionComposer: SessionComposerProtocol) -} diff --git a/Sources/CameraKage/General/Delegates/Internal/SessionDelegate.swift b/Sources/CameraKage/General/Delegates/Internal/SessionDelegate.swift new file mode 100644 index 0000000..c72a7db --- /dev/null +++ b/Sources/CameraKage/General/Delegates/Internal/SessionDelegate.swift @@ -0,0 +1,20 @@ +// +// SessionDelegate.swift +// +// +// Created by Lobont Andrei on 30.05.2023. +// + +import AVFoundation + +protocol SessionDelegate: AnyObject { + func session(_ sessionComposer: Session, + didReceiveRuntimeError error: CameraError, + shouldRestartCamera restart: Bool) + func session(_ sessionComposer: Session, + didReceiveSessionInterruption reason: SessionInterruptionReason) + func sessionDidFinishSessionInterruption(_ sessionComposer: Session) + func sessionDidStartCameraSession(_ sessionComposer: Session) + func sessionDidStopCameraSession(_ sessionComposer: Session) + func sessionDidChangeDeviceAreaOfInterest(_ sessionComposer: Session) +} diff --git a/Sources/CameraKage/General/Delegates/Public/BaseCameraDelegate.swift b/Sources/CameraKage/General/Delegates/Public/BaseCameraDelegate.swift new file mode 100644 index 0000000..f5d8475 --- /dev/null +++ b/Sources/CameraKage/General/Delegates/Public/BaseCameraDelegate.swift @@ -0,0 +1,64 @@ +// +// BaseCameraDelegate.swift +// +// +// Created by Lobont Andrei on 05.06.2023. +// + +import Foundation + +public protocol BaseCameraDelegate: AnyObject { + /** + Called when a pinch to zoom action happened on the camera. + + - parameter scale: The current zoom scale reported by the pinch gesture. + - parameter maxScale: The maximum zoom scale of the camera. + */ + func cameraDidZoom(atScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) + + /** + Called when the camera encountered an error. + + - parameter error: The error that was encountered. + */ + func cameraDidEncounterError(error: CameraError) + + /** + Called when the camera session was interrupted. This can happen from various reason but most common ones would be phone calls while using the camera, other apps taking control over the phone camera or app moving to background. + + - parameter reason: The reason for the session interruption. + + - important: When this is called, the camera will freezee so some UI overlay might be necessary on the client side. + */ + func cameraDidReceiveSessionInterruption(withReason reason: SessionInterruptionReason) + + /** + Called when the camera session interruption has ended. When this is called the camera will resume working. + */ + func cameraDidFinishSessionInterruption() + + /** + Called when the camera session was started and the actual camera will be visible on screen. + */ + func cameraDidStartCameraSession() + + /** + Called when the camera session has stopped. + */ + func cameraDidStopCameraSession() + + /** + Called when the instance of AVCaptureDevice has detected a substantial change to the video subject area. This notification is only sent if you first set monitorSubjectAreaChange to `true` in the `focus()` camera method. + */ + func cameraDidChangeDeviceAreaOfInterest() +} + +public extension BaseCameraDelegate { + func cameraDidZoom(atScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) {} + func cameraDidEncounterError(error: CameraError) {} + func cameraDidReceiveSessionInterruption(withReason reason: SessionInterruptionReason) {} + func cameraDidFinishSessionInterruption() {} + func cameraDidStartCameraSession() {} + func cameraDidStopCameraSession() {} + func cameraDidChangeDeviceAreaOfInterest() {} +} diff --git a/Sources/CameraKage/General/Delegates/Public/CameraDelegate.swift b/Sources/CameraKage/General/Delegates/Public/CameraDelegate.swift new file mode 100644 index 0000000..11683ab --- /dev/null +++ b/Sources/CameraKage/General/Delegates/Public/CameraDelegate.swift @@ -0,0 +1,10 @@ +// +// CameraDelegate.swift +// +// +// Created by Lobont Andrei on 13.06.2023. +// + +import Foundation + +public protocol CameraDelegate: PhotoCameraDelegate & VideoCameraDelegate {} diff --git a/Sources/CameraKage/General/Delegates/Public/CameraKageDelegate.swift b/Sources/CameraKage/General/Delegates/Public/CameraKageDelegate.swift deleted file mode 100644 index c0309e3..0000000 --- a/Sources/CameraKage/General/Delegates/Public/CameraKageDelegate.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// CameraKageDelegate.swift -// CameraKage -// -// Created by Lobont Andrei on 24.05.2023. -// - -import Foundation - -/// Delegate protocol used to notify events and pass information in regards to them. -public protocol CameraKageDelegate: AnyObject { - /** - Called when the camera has outputted a photo. - - - parameter camera: The camera composer which is sending the event. - - parameter data: The data representation of the photo. - */ - func camera(_ camera: CameraKage, didOutputPhotoWithData data: Data) - - /** - Called when the camera has started a video recording. - - - parameter camera: The camera composer which is sending the event. - - parameter url: The file location where the video will be stored when recording ends. - */ - func camera(_ camera: CameraKage, didStartRecordingVideoAtFileURL url: URL) - - /** - Called when the camera has outputted a video recording. - - - parameter camera: The camera composer which is sending the event. - - parameter url: The file location where the video is stored. - */ - func camera(_ camera: CameraKage, didOutputVideoAtFileURL url: URL) - - /** - Called when a pinch to zoom action happened on the camera component. - - - parameter camera: The camera composer which is sending the event. - - parameter scale: The current zoom scale reported by the pinch gesture. - - parameter maxScale: The maximum zoom scale of the camera. - */ - func camera(_ camera: CameraKage, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) - - /** - Called when the camera composer encountered an error. Could be an output, camera or a session related error. - - - parameter camera: The camera composer which is sending the event. - - parameter error: The error that was encountered. - */ - func camera(_ camera: CameraKage, didEncounterError error: CameraError) - - /** - Called when the camera session was interrupted. This can happen from various reason but most common ones would be phone calls while using the camera, other apps taking control over the phone camera or app moving to background. - - - parameter camera: The camera composer which is sending the event. - - parameter reason: The reason for the session interruption. - - - important: When this is called, the camera will freezee so some UI overlay might be necessary on the client side. - */ - func camera(_ camera: CameraKage, sessionWasInterrupted reason: SessionInterruptionReason) - - /** - Called when the camera session interruption has ended. When this is called the camera will resume working. - - - parameter camera: The camera composer which is sending the event. - */ - func cameraSessionInterruptionEnded(_ camera: CameraKage) - - /** - Called when the camera session was started and the actual camera will be visible on screen. - - - parameter camera: The camera composer which is sending the event. - */ - func cameraSessionDidStart(_ camera: CameraKage) - - /** - Called when the camera session has stopped. - - - parameter camera: The camera composer which is sending the event. - */ - func cameraSessionDidStop(_ camera: CameraKage) - - /** - Posted when the instance of AVCaptureDevice has detected a substantial change to the video subject area. This notification is only sent if you first set monitorSubjectAreaChange to `true` in the `focus()` camera method. - - - parameter camera: The camera composer which is sending the event. - */ - func cameraDeviceDidChangeSubjectArea(_ camera: CameraKage) -} - -public extension CameraKageDelegate { - func camera(_ camera: CameraKage, didOutputPhotoWithData data: Data) {} - func camera(_ camera: CameraKage, didStartRecordingVideoAtFileURL url: URL) {} - func camera(_ camera: CameraKage, didOutputVideoAtFileURL url: URL) {} - func camera(_ camera: CameraKage, didZoomAtScale scale: CGFloat, outOfMaximumScale maxScale: CGFloat) {} - func camera(_ camera: CameraKage, didEncounterError error: CameraError) {} - func camera(_ camera: CameraKage, sessionWasInterrupted reason: SessionInterruptionReason) {} - func cameraSessionInterruptionEnded(_ camera: CameraKage) {} - func cameraSessionDidStart(_ camera: CameraKage) {} - func cameraSessionDidStop(_ camera: CameraKage) {} - func cameraDeviceDidChangeSubjectArea(_ camera: CameraKage) {} -} diff --git a/Sources/CameraKage/General/Delegates/Public/PhotoCameraDelegate.swift b/Sources/CameraKage/General/Delegates/Public/PhotoCameraDelegate.swift new file mode 100644 index 0000000..7ec37fe --- /dev/null +++ b/Sources/CameraKage/General/Delegates/Public/PhotoCameraDelegate.swift @@ -0,0 +1,18 @@ +// +// PhotoCameraDelegate.swift +// +// +// Created by Lobont Andrei on 12.06.2023. +// + +import Foundation + +/// Delegate protocol used by the `PhotoCameraView`. +public protocol PhotoCameraDelegate: BaseCameraDelegate { + /** + Called when the camera has outputted a photo. + + - parameter data: The data representation of the photo. + */ + func cameraDidCapturePhoto(withData data: Data) +} diff --git a/Sources/CameraKage/General/Delegates/Public/VideoCameraDelegate.swift b/Sources/CameraKage/General/Delegates/Public/VideoCameraDelegate.swift new file mode 100644 index 0000000..80a6a6a --- /dev/null +++ b/Sources/CameraKage/General/Delegates/Public/VideoCameraDelegate.swift @@ -0,0 +1,28 @@ +// +// File.swift +// +// +// Created by Lobont Andrei on 13.06.2023. +// + +import Foundation + +public protocol VideoCameraDelegate: BaseCameraDelegate { + /** + Called when the camera has started a video recording. + + - parameter url: The URL file location where the video is being recorded. + */ + func cameraDidStartVideoRecording(atFileURL url: URL) + + /** + Called when the camera has outputted a video recording. + + - parameter url: The URL of the video file location. + */ + func cameraDidFinishVideoRecording(atFileURL url: URL) +} + +public extension VideoCameraDelegate { + func cameraDidStartVideoRecording(atFileURL url: URL) {} +} diff --git a/Sources/CameraKage/General/Session/SessionComposer.swift b/Sources/CameraKage/General/Session/SessionComposer.swift index 3c93a64..5f72d66 100644 --- a/Sources/CameraKage/General/Session/SessionComposer.swift +++ b/Sources/CameraKage/General/Session/SessionComposer.swift @@ -7,123 +7,224 @@ import AVFoundation -final class SessionComposer: SessionComposerProtocol { +final class SessionComposer { private let session: CaptureSession - var isSessionRunning: Bool { session.isRunning } - - weak var delegate: SessionComposerDelegate? - init(session: CaptureSession = CaptureSession()) { self.session = session } - func startSession() { - addObservers() - session.startRunning() + func createCameraView(options: CameraComponentParsedOptions) -> Result { + let videoInputResult = createVideoInput(options: options) + switch videoInputResult { + case .success(let videoInput): + let audioInputResult = createAudioInput(options: options) + switch audioInputResult { + case .success(let audioInput): + let videoLayerResult = createVideoPreviewLayer(options: options, videoDevice: videoInput) + switch videoLayerResult { + case .success(let videoLayer): + let cameraResult = createCamera(options: options, + videoInput: videoInput, + audioInput: audioInput, + videoLayer: videoLayer) + switch cameraResult { + case .success(let camera): + return .success(CameraView(camera: camera)) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } } - func stopSession() { - session.stopRunning() - removeObservers() + func createPhotoCameraView(options: CameraComponentParsedOptions) -> Result { + let videoInputResult = createVideoInput(options: options) + switch videoInputResult { + case .success(let videoInput): + let videoLayerResult = createVideoPreviewLayer(options: options, videoDevice: videoInput) + switch videoLayerResult { + case .success(let videoLayer): + let photoCameraResult = createPhotoCamera(options: options, + videoInput: videoInput, + videoLayer: videoLayer) + switch photoCameraResult { + case .success(let photoCamera): + return .success(PhotoCameraView(photoCamera: photoCamera)) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } } - func pauseSession() { - session.stopRunning() + func createVideoCameraView(options: CameraComponentParsedOptions) -> Result { + let videoInputResult = createVideoInput(options: options) + switch videoInputResult { + case .success(let videoInput): + let audioInputResult = createAudioInput(options: options) + switch audioInputResult { + case .success(let audioInput): + let videoLayerResult = createVideoPreviewLayer(options: options, videoDevice: videoInput) + switch videoLayerResult { + case .success(let videoLayer): + let videoCameraResult = createVideoCamera(options: options, + videoInput: videoInput, + audioInput: audioInput, + videoLayer: videoLayer) + switch videoCameraResult { + case .success(let videoCamera): + return .success(VideoCameraView(videoCamera: videoCamera)) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } } - func resumeSession() { - session.startRunning() + private func createCamera(options: CameraComponentParsedOptions, + videoInput: VideoCaptureDevice, + audioInput: AudioCaptureDevice, + videoLayer: PreviewLayer) -> Result { + let movieCapturerResult = createMovieCapturer(options: options, + videoDevice: videoInput, + audioDevice: audioInput) + switch movieCapturerResult { + case .success(let movieCapturer): + let photoCapturerResult = createPhotoCapturer(options: options, videoDevice: videoInput) + switch photoCapturerResult { + case .success(let photoCapturer): + return .success(Camera(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + photoCapturer: photoCapturer, + movieCapturer: movieCapturer, + options: options)) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } } - func createCamera(_ options: CameraComponentParsedOptions) -> Result { - guard let camera = Camera(session: session, options: options) else { - return .failure(.cameraComponentError(reason: .failedToComposeCamera)) + private func createVideoCamera(options: CameraComponentParsedOptions, + videoInput: VideoCaptureDevice, + audioInput: AudioCaptureDevice, + videoLayer: PreviewLayer) -> Result { + let movieCapturerResult = createMovieCapturer(options: options, + videoDevice: videoInput, + audioDevice: audioInput) + switch movieCapturerResult { + case .success(let movieCapturer): + return .success(VideoCamera(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + movieCapturer: movieCapturer, + options: options)) + case .failure(let error): + return .failure(error) } - return .success(camera) - } -} - -// MARK: - Notifications -extension SessionComposer { - func addObservers() { - NotificationCenter.default.addObserver(self, - selector: #selector(sessionRuntimeError), - name: .AVCaptureSessionRuntimeError, - object: session) - NotificationCenter.default.addObserver(self, - selector: #selector(sessionWasInterrupted), - name: .AVCaptureSessionWasInterrupted, - object: session) - NotificationCenter.default.addObserver(self, - selector: #selector(sessionInterruptionEnded), - name: .AVCaptureSessionInterruptionEnded, - object: session) - NotificationCenter.default.addObserver(self, - selector: #selector(sessionDidStartRunning), - name: .AVCaptureSessionDidStartRunning, - object: session) - NotificationCenter.default.addObserver(self, - selector: #selector(sessionDidStopRunning), - name: .AVCaptureSessionDidStopRunning, - object: session) - NotificationCenter.default.addObserver(self, - selector: #selector(deviceSubjectAreaDidChange), - name: .AVCaptureDeviceSubjectAreaDidChange, - object: session) } - func removeObservers() { - NotificationCenter.default.removeObserver(self) + private func createPhotoCamera(options: CameraComponentParsedOptions, + videoInput: VideoCaptureDevice, + videoLayer: PreviewLayer) -> Result { + let photoCapturerResult = createPhotoCapturer(options: options, videoDevice: videoInput) + switch photoCapturerResult { + case .success(let photoCapturer): + return .success(PhotoCamera(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + photoCapturer: photoCapturer, + options: options)) + case .failure(let error): + return .failure(error) + } } - @objc private func sessionRuntimeError(notification: NSNotification) { - guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError else { return } - let cameraError = CameraError.cameraSessionError(reason: .runtimeError(error)) - delegate?.sessionComposer(self, - didReceiveRuntimeError: cameraError, - shouldRestartCamera: error.code == .mediaServicesWereReset) + private func createBaseCamera(options: CameraComponentParsedOptions) -> Result { + let videoInputResult = createVideoInput(options: options) + switch videoInputResult { + case .success(let videoInput): + let videoLayerResult = createVideoPreviewLayer(options: options, videoDevice: videoInput) + switch videoLayerResult { + case .success(let videoLayer): + return .success(BaseCamera(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options)) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } } - @objc private func sessionDidStartRunning(notification: NSNotification) { - delegate?.sessionComposerDidStartCameraSession(self) + private func createVideoInput(options: CameraComponentParsedOptions) -> Result { + guard let videoInput = VideoCaptureDevice(session: session, + options: options) else { + return .failure(.cameraComponentError(reason: .failedToConfigureVideoDevice)) + } + return .success(videoInput) } - @objc private func sessionDidStopRunning(notification: NSNotification) { - delegate?.sessionComposerDidStopCameraSession(self) + private func createAudioInput(options: CameraComponentParsedOptions) -> Result { + guard let audioInput = AudioCaptureDevice(session: session, + options: options) else { + return .failure(.cameraComponentError(reason: .failedToConfigureAudioDevice)) + } + return .success(audioInput) } - /// This will be called anytime there is another app that tries to use the audio or video devices - /// Removing that device will unfreeze the camera but video will be corrupted (couldn't find a reason yet) - @objc private func sessionWasInterrupted(notification: NSNotification) { - guard let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?, - let reasonIntegerValue = userInfoValue.integerValue, - let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) else { return } - var interruptionReason: SessionInterruptionReason - switch reason { - case .videoDeviceNotAvailableInBackground: - interruptionReason = .videoDeviceNotAvailableInBackground - case .audioDeviceInUseByAnotherClient: - interruptionReason = .audioDeviceInUseByAnotherClient - case .videoDeviceInUseByAnotherClient: - interruptionReason = .videoDeviceInUseByAnotherClient - case .videoDeviceNotAvailableWithMultipleForegroundApps: - interruptionReason = .videoDeviceNotAvailableWithMultipleForegroundApps - case .videoDeviceNotAvailableDueToSystemPressure: - interruptionReason = .videoDeviceNotAvailableDueToSystemPressure - @unknown default: - interruptionReason = .unknown + private func createPhotoCapturer(options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice) -> Result { + guard let photoCapturer = PhotoOutput(session: session, + options: options, + videoDevice: videoDevice) else { + return .failure(.cameraComponentError(reason: .failedToAddPhotoOutput)) } - delegate?.sessionComposer(self, didReceiveSessionInterruption: interruptionReason) + return .success(photoCapturer) } - /// This will be called anytime the problem with the session was solved - /// Entering in this method dosen't necessarly means that the call or the other application that invoked the problem was closed - @objc private func sessionInterruptionEnded(notification: NSNotification) { - delegate?.sessionComposerDidFinishSessionInterruption(self) + private func createMovieCapturer(options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice, + audioDevice: AudioCaptureDevice) -> Result { + guard let movieCapturer = MovieOutput(forSession: session, + andOptions: options, + videoDevice: videoDevice, + audioDevice: audioDevice) else { + return .failure(.cameraComponentError(reason: .failedToAddMovieOutput)) + } + return .success(movieCapturer) } - @objc private func deviceSubjectAreaDidChange(notification: NSNotification) { - delegate?.sessionComposerDidChangeDeviceAreaOfInterest(self) + private func createVideoPreviewLayer(options: CameraComponentParsedOptions, + videoDevice: VideoCaptureDevice) -> Result { + guard let videoLayer = PreviewLayer(session: session, + options: options, + videoDevice: videoDevice) else { + return .failure(.cameraComponentError(reason: .failedToAddPreviewLayer)) + } + return .success(videoLayer) } } diff --git a/Sources/CameraKage/General/Session/SessionComposerProtocol.swift b/Sources/CameraKage/General/Session/SessionComposerProtocol.swift deleted file mode 100644 index ac98a51..0000000 --- a/Sources/CameraKage/General/Session/SessionComposerProtocol.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// SessionComposerProtocol.swift -// CameraKage -// -// Created by Lobont Andrei on 26.05.2023. -// - -import AVFoundation - -protocol SessionComposerProtocol { - var delegate: SessionComposerDelegate? { get set } - var isSessionRunning: Bool { get } - - func startSession() - func stopSession() - func pauseSession() - func resumeSession() - func createCamera(_ options: CameraComponentParsedOptions) -> Result -} diff --git a/Sources/CameraKage/General/Settings/CameraComponentOptions.swift b/Sources/CameraKage/General/Settings/CameraComponentOptions.swift index 42f46a7..ce9f345 100644 --- a/Sources/CameraKage/General/Settings/CameraComponentOptions.swift +++ b/Sources/CameraKage/General/Settings/CameraComponentOptions.swift @@ -5,7 +5,7 @@ // Created by Lobont Andrei on 11.05.2023. // -import AVFoundation +import Foundation public typealias CameraComponentOptions = [CameraComponentOptionItem] @@ -13,15 +13,15 @@ public enum CameraComponentOptionItem { /// Quality prioritization mode for the photo output. /// Constants indicating how photo quality should be prioritized against speed. /// Default is `.balanced`. - case photoQualityPrioritizationMode(AVCapturePhotoOutput.QualityPrioritization) + case photoQualityPrioritizationMode(PhotoQualityPrioritizationMode) /// The mode of the video stabilization. /// Default is `.auto`. - case videoStabilizationMode(AVCaptureVideoStabilizationMode) + case videoStabilizationMode(VideoStabilizationMode) /// The orientation setting of the camera. /// Default is `.portrait`. - case cameraOrientation(AVCaptureVideoOrientation) + case cameraOrientation(VideoOrientationMode) /// The type of camera to be used. /// Default is `.backWideCamera`. @@ -33,41 +33,42 @@ public enum CameraComponentOptionItem { /// Will define how the layer will display the player's visual content. /// Default is `.resizeAspectFill`. - case videoGravity(AVLayerVideoGravity) + case videoGravity(LayerVideoGravity) - /// Maximum duration allowed for video recordings. - /// Default is `.positiveInfinity`. - case maxVideoDuration(CMTime) + /// Maximum duration allowed for video recordings represented in seconds. + /// Default is `.infinity`. + case maxVideoDuration(Double) - /// Indicates if the `CameraComponent` has pinch to zoom. - /// Each `CameraComponent` can have its own zoom setting. + /// Indicates if the camera has pinch to zoom. /// Default is `false`. case pinchToZoomEnabled(Bool) - /// The minimum zoom scale of the `CameraComponent`. - /// Each `CameraComponent` can have its own minimum scale. + /// The minimum zoom scale of the camera. /// Default is `1.0`. case minimumZoomScale(CGFloat) - /// The maximum zoom scale of the `CameraComponent`. - /// Each `CameraComponent` can have its own maximum scale. + /// The maximum zoom scale of the camera. /// Default is `5.0`. case maximumZoomScale(CGFloat) + + /// The queue that will be used to notify delegate events. + /// Default is `.main`. + case delegateQueue(DispatchQueue) } -/// Options used for the output settings of the camera component. -/// These should be set before the `startCameraSession()` is called. +/// Options used to configure a camera view. public class CameraComponentParsedOptions { - public var photoQualityPrioritizationMode: AVCapturePhotoOutput.QualityPrioritization = .balanced - public var videoStabilizationMode: AVCaptureVideoStabilizationMode = .auto - public var cameraOrientation: AVCaptureVideoOrientation = .portrait + public var photoQualityPrioritizationMode: PhotoQualityPrioritizationMode = .balanced + public var videoStabilizationMode: VideoStabilizationMode = .auto + public var cameraOrientation: VideoOrientationMode = .portrait public var cameraDevice: CameraDevice = .backWideCamera public var flipCameraDevice: CameraDevice = .frontCamera - public var videoGravity: AVLayerVideoGravity = .resizeAspectFill - public var maxVideoDuration: CMTime = .positiveInfinity + public var videoGravity: LayerVideoGravity = .resizeAspectFill + public var maxVideoDuration: Double = .infinity public var pinchToZoomEnabled: Bool = false public var minimumZoomScale: CGFloat = 1.0 public var maximumZoomScale: CGFloat = 5.0 + public var delegateQeueue: DispatchQueue = .main public init(_ options: CameraComponentOptions?) { guard let options else { return } @@ -93,6 +94,8 @@ public class CameraComponentParsedOptions { self.minimumZoomScale = minimumZoomScale case .maximumZoomScale(let maximumZoomScale): self.maximumZoomScale = maximumZoomScale + case .delegateQueue(let delegateQueue): + self.delegateQeueue = delegateQueue } } } diff --git a/Sources/CameraKage/General/Settings/LayerVideoGravity.swift b/Sources/CameraKage/General/Settings/LayerVideoGravity.swift new file mode 100644 index 0000000..cb911d5 --- /dev/null +++ b/Sources/CameraKage/General/Settings/LayerVideoGravity.swift @@ -0,0 +1,19 @@ +// +// LayerVideoGravity.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import Foundation + +public enum LayerVideoGravity { + /// Preserve aspect ratio, fit within layer bounds. + case resizeAspect + + /// Preserve aspect ratio, fill layer bounds. + case resizeAspectFill + + /// Stretch to fill layer bounds. + case resize +} diff --git a/Sources/CameraKage/General/Settings/PhotoQualityPrioritizationMode.swift b/Sources/CameraKage/General/Settings/PhotoQualityPrioritizationMode.swift new file mode 100644 index 0000000..cbdd571 --- /dev/null +++ b/Sources/CameraKage/General/Settings/PhotoQualityPrioritizationMode.swift @@ -0,0 +1,19 @@ +// +// PhotoQualityPrioritizationMode.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import Foundation + +public enum PhotoQualityPrioritizationMode { + /// Speed of the photo delivery will be prioritized, even at the expense of photo quality + case speed + + /// Speed and quality of the photo will be equally prioritized. + case balanced + + /// Quality of the photo will be prioritized, even at the expense of the delivery time of the photo. + case quality +} diff --git a/Sources/CameraKage/General/Settings/VideoOrientationMode.swift b/Sources/CameraKage/General/Settings/VideoOrientationMode.swift new file mode 100644 index 0000000..f0cf04b --- /dev/null +++ b/Sources/CameraKage/General/Settings/VideoOrientationMode.swift @@ -0,0 +1,22 @@ +// +// VideoOrientationMode.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import Foundation + +public enum VideoOrientationMode { + /// Indicates that video should be oriented vertically, home button on the bottom. + case portrait + + /// Indicates that video should be oriented vertically, home button on the top. + case portraitUpsideDown + + /// Indicates that video should be oriented horizontally, home button on the right. + case landscapeRight + + /// Indicates that video should be oriented horizontally, home button on the left. + case landscapeLeft +} diff --git a/Sources/CameraKage/General/Settings/VideoStabilizationMode.swift b/Sources/CameraKage/General/Settings/VideoStabilizationMode.swift new file mode 100644 index 0000000..8dcef44 --- /dev/null +++ b/Sources/CameraKage/General/Settings/VideoStabilizationMode.swift @@ -0,0 +1,25 @@ +// +// VideoStabilizationMode.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import Foundation + +public enum VideoStabilizationMode { + /// Provides no video stabilization + case off + + /// Indicates that video should be stabilized using the standard video stabilization algorithm. Standard video stabilization has a reduced field of view. Enabling video stabilization may introduce additional latency into the video capture pipeline. + case standard + + /// Indicates that video should be stabilized using the cinematic stabilization algorithm for more dramatic results. Cinematic video stabilization has a reduced field of view compared to standard video stabilization. Enabling cinematic video stabilization introduces much more latency into the video capture pipeline than standard video stabilization and consumes significantly more system memory. Use narrow or identical min and max frame durations in conjunction with this mode. + case cinematic + + /// Indicates that the video should be stabilized using the extended cinematic stabilization algorithm. Enabling extended cinematic stabilization introduces longer latency into the video capture pipeline compared to the `.cinematic` mode and consumes more memory, but yields improved stability. It is recommended to use identical or similar min and max frame durations in conjunction with this mode. + case cinematicExtended + + /// The camera device will determine the best mode to be used. + case auto +} diff --git a/Tests/CameraKageTests/BaseCameraViewTests.swift b/Tests/CameraKageTests/BaseCameraViewTests.swift new file mode 100644 index 0000000..8261bf5 --- /dev/null +++ b/Tests/CameraKageTests/BaseCameraViewTests.swift @@ -0,0 +1,202 @@ +// +// BaseCameraViewTests.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import XCTest +@testable import CameraKage + +final class BaseCameraViewTests: XCTestCase { + func test_session_startAndStop_addAndRemoveObservers() { + let session = makeSessionMock() + let options = CameraComponentParsedOptions(nil) + let videoInput = makeVideoInputMock(options: options) + let videoLayer = makeVideoLayerMock() + let delegatesManager = makeDelegatesManagerMock() + let baseCamera = makeBaseCameraMock(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + let sessionQueue = DispatchQueue(label: "testSessionQueue") + let sut = makeSUT(baseCamera: baseCamera, delegatesManager: delegatesManager, sessionQueue: sessionQueue) + + XCTAssertFalse(sut.isSessionRunning, "Session wasn't yet started") + XCTAssertFalse(session.isObservingSession, "Session not started, observers shouldn't be yet added.") + + sut.startCamera() + sessionQueue.sync {} + XCTAssertTrue(sut.isSessionRunning) + XCTAssertTrue(session.isObservingSession) + + sut.stopCamera() + sessionQueue.sync {} + XCTAssertFalse(sut.isSessionRunning) + XCTAssertFalse(session.isObservingSession) + } + + func test_session_setDelegate() { + let session = makeSessionMock() + let options = CameraComponentParsedOptions(nil) + let videoInput = makeVideoInputMock(options: options) + let videoLayer = makeVideoLayerMock() + let delegatesManager = makeDelegatesManagerMock() + let baseCamera = makeBaseCameraMock(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + let sessionQueue = DispatchQueue(label: "testSessionQueue") + XCTAssertNil(session.delegate, "No delegate was set yet.") + let sut = makeSUT(baseCamera: baseCamera, delegatesManager: delegatesManager, sessionQueue: sessionQueue) + + XCTAssertNotNil(session.delegate) + XCTAssert(session.delegate === sut) + } + + func test_videoInput_flipCamera() { + let session = makeSessionMock() + let options = CameraComponentParsedOptions([.cameraDevice(.backTripleCamera), + .flipCameraDevice(.frontCamera)]) + let videoInput = makeVideoInputMock(options: options) + let videoLayer = makeVideoLayerMock() + let delegatesManager = makeDelegatesManagerMock() + let baseCamera = makeBaseCameraMock(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + let sessionQueue = DispatchQueue(label: "testSessionQueue") + let sut = makeSUT(baseCamera: baseCamera, delegatesManager: delegatesManager, sessionQueue: sessionQueue) + + XCTAssertEqual(videoInput.currentCamera, options.cameraDevice, "Camera should start with the cameraDevice from the options object.") + + sut.flipCamera() + sessionQueue.sync {} + XCTAssertEqual(videoInput.currentCamera, options.flipCameraDevice) + + sut.flipCamera() + sessionQueue.sync {} + XCTAssertEqual(videoInput.currentCamera, options.cameraDevice) + } + + func test_videoInput_focus_videoLayer_convertedFocusPoint() { + let session = makeSessionMock() + let options = CameraComponentParsedOptions(nil) + let videoInput = makeVideoInputMock(options: options) + let videoLayer = makeVideoLayerMock() + let delegatesManager = makeDelegatesManagerMock() + let baseCamera = makeBaseCameraMock(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + let sessionQueue = DispatchQueue(label: "testSessionQueue") + let sut = makeSUT(baseCamera: baseCamera, delegatesManager: delegatesManager, sessionQueue: sessionQueue) + + XCTAssertEqual(videoInput.focusPoint, .zero, "Camera wasn't focused yet.") + + let focusPoint = CGPoint(x: 100, y: 100) + sut.focus(with: .locked, + exposureMode: .continuousAutoExposure, + at: focusPoint, + monitorSubjectAreaChange: true) + sessionQueue.sync {} + + let convertedFocusPoint = videoLayer.captureDevicePointConverted(fromLayerPoint: focusPoint) + XCTAssertEqual(videoInput.focusPoint, convertedFocusPoint) + XCTAssertEqual(videoInput.focusMode, .locked) + XCTAssertEqual(videoInput.exposureMode, .continuousAutoExposure) + } + + func test_delegatesManager_registerAndUnregisterDelegate() { + let session = makeSessionMock() + let options = CameraComponentParsedOptions(nil) + let videoInput = makeVideoInputMock(options: options) + let videoLayer = makeVideoLayerMock() + let delegatesManager = makeDelegatesManagerMock() + let delegateStub = makeBaseCameraDelegateStub() + let baseCamera = makeBaseCameraMock(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + let sessionQueue = DispatchQueue(label: "testSessionQueue") + let sut = makeSUT(baseCamera: baseCamera, delegatesManager: delegatesManager, sessionQueue: sessionQueue) + + XCTAssertEqual(sut.delegatesManager.delegates.count, 0, "No delegates added yet.") + + sut.registerDelegate(delegateStub) + XCTAssertEqual(sut.delegatesManager.delegates.count, 1) + + sut.unregisterDelegate(delegateStub) + XCTAssertEqual(sut.delegatesManager.delegates.count, 0) + } + + func test_delegatesManager_invokeDelegates() { + let session = makeSessionMock() + let options = CameraComponentParsedOptions(nil) + let videoInput = makeVideoInputMock(options: options) + let videoLayer = makeVideoLayerMock() + let delegatesManager = makeDelegatesManagerMock() + let delegateStub = makeBaseCameraDelegateStub() + let baseCamera = makeBaseCameraMock(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + let sessionQueue = DispatchQueue(label: "testSessionQueue") + let sut = makeSUT(baseCamera: baseCamera, delegatesManager: delegatesManager, sessionQueue: sessionQueue) + + XCTAssertFalse(delegateStub.invoked) + + sut.registerDelegate(delegateStub) + + delegatesManager.invokeDelegates { delegate in + guard let delegate = delegate as? BaseCameraDelegateStub else { + XCTFail("Failed to cast delegate") + return + } + delegate.invoked = true + } + XCTAssertTrue(delegateStub.invoked) + } +} + +extension BaseCameraViewTests { + private func makeSUT(baseCamera: BaseCameraInterface, + delegatesManager: DelegatesManagerProtocol, + sessionQueue: DispatchQueue) -> BaseCameraView { + let sut = BaseCameraView(baseCamera: baseCamera, delegatesManager: delegatesManager, sessionQueue: sessionQueue) + trackMemoryLeaks(sut) + return sut + } + + private func makeBaseCameraMock(session: Session, + videoInput: VideoInput, + videoLayer: VideoLayer, + options: CameraComponentParsedOptions) -> BaseCameraMock { + let mock = BaseCameraMock(session: session, + videoInput: videoInput, + videoLayer: videoLayer, + options: options) + trackMemoryLeaks(mock) + return mock + } + + private func makeSessionMock() -> CaptureSessionMock { + CaptureSessionMock() + } + + private func makeVideoInputMock(options: CameraComponentParsedOptions) -> VideoInputMock { + VideoInputMock(options: options) + } + + private func makeVideoLayerMock() -> VideoLayerMock { + VideoLayerMock() + } + + private func makeDelegatesManagerMock() -> DelegatesManagerMock { + DelegatesManagerMock() + } + + private func makeBaseCameraDelegateStub() -> BaseCameraDelegateStub { + BaseCameraDelegateStub() + } +} diff --git a/Tests/CameraKageTests/CameraKageDelegateMock.swift b/Tests/CameraKageTests/CameraKageDelegateMock.swift deleted file mode 100644 index e487910..0000000 --- a/Tests/CameraKageTests/CameraKageDelegateMock.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// File.swift -// -// -// Created by Lobont Andrei on 29.05.2023. -// - -import Foundation -@testable import CameraKage - -final class CameraKageDelegateMock: CameraKageDelegate { - var invoked = false -} diff --git a/Tests/CameraKageTests/CameraKageTests.swift b/Tests/CameraKageTests/CameraKageTests.swift index 21fa648..e7dd8f0 100644 --- a/Tests/CameraKageTests/CameraKageTests.swift +++ b/Tests/CameraKageTests/CameraKageTests.swift @@ -2,39 +2,6 @@ import XCTest @testable import CameraKage final class CameraKageTests: XCTestCase { - func test_delegatesManager_registerAndUnregisterDelegate() { - let managerMock = createDelegatesManagerMock() - let sut = makeSUT(delegatesManager: managerMock) - - XCTAssertEqual(managerMock.delegates.count, 0, "Mock was just created, so count should be 0.") - - let delegateMock = createDelegateMock() - sut.unregisterDelegate(delegateMock) - XCTAssertEqual(managerMock.delegates.count, 0, "Delegate mock wasn't registered as a delegate so count was never modified") - - sut.registerDelegate(delegateMock) - XCTAssertEqual(managerMock.delegates.count, 1) - - sut.unregisterDelegate(delegateMock) - XCTAssertEqual(managerMock.delegates.count, 0) - } - - func test_delegatesManager_delegatesInvocation() { - let managerMock = createDelegatesManagerMock() - let delegateMock = createDelegateMock() - let sut = makeSUT(delegatesManager: managerMock) - - XCTAssertFalse(delegateMock.invoked, "Delegate mock was just created, so it shouldn't have been invoked yet.") - - sut.registerDelegate(delegateMock) - managerMock.invokeDelegates { delegate in - let delegate = delegate as? CameraKageDelegateMock - XCTAssertEqual(delegate?.invoked, true) - } - - XCTAssertTrue(delegateMock.invoked) - } - func test_permissionsManager_requestVideoPermission_withCompletion() { let managerMock = createPermisssionsManagerMock() let sut = makeSUT(permissionsManager: managerMock) @@ -83,36 +50,12 @@ final class CameraKageTests: XCTestCase { } extension CameraKageTests { - func makeSUT(delegatesManager: DelegatesManagerMock, - permissionsManager: PermissionManagerMock, - cameraComposer: CameraComposer) -> CameraKage { - let sut = CameraKage(permissionManager: permissionsManager, - delegatesManager: delegatesManager, - cameraComposer: cameraComposer) - trackMemoryLeaks(sut) - return sut - } - - func makeSUT(delegatesManager: DelegatesManagerMock) -> CameraKage { - let sut = CameraKage(delegatesManager: delegatesManager) - trackMemoryLeaks(sut) - return sut - } - func makeSUT(permissionsManager: PermissionManagerMock) -> CameraKage { let sut = CameraKage(permissionManager: permissionsManager) trackMemoryLeaks(sut) return sut } - func createDelegatesManagerMock() -> DelegatesManagerMock { - DelegatesManagerMock() - } - - func createDelegateMock() -> CameraKageDelegateMock { - CameraKageDelegateMock() - } - func createPermisssionsManagerMock() -> PermissionManagerMock { PermissionManagerMock() } diff --git a/Tests/CameraKageTests/DelegatesManagerMock.swift b/Tests/CameraKageTests/DelegatesManagerMock.swift deleted file mode 100644 index bcf4980..0000000 --- a/Tests/CameraKageTests/DelegatesManagerMock.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// DelegatesManagerMock.swift -// -// -// Created by Lobont Andrei on 29.05.2023. -// - -import Foundation -@testable import CameraKage - -final class DelegatesManagerMock: DelegatesManagerProtocol { - var delegates: NSHashTable = NSHashTable.weakObjects() - - func registerDelegate(_ delegate: CameraKageDelegate) { - delegates.add(delegate as AnyObject) - } - - func unregisterDelegate(_ delegate: CameraKageDelegate) { - delegates.remove(delegate as AnyObject) - } - - func invokeDelegates(_ execute: (CameraKageDelegate) -> Void) { - delegates.allObjects.forEach { delegate in - guard let delegate = delegate as? CameraKageDelegateMock else { return } - delegate.invoked = true - execute(delegate) - } - } -} diff --git a/Tests/CameraKageTests/Mocks/BaseCameraDelegateStub.swift b/Tests/CameraKageTests/Mocks/BaseCameraDelegateStub.swift new file mode 100644 index 0000000..9006fc0 --- /dev/null +++ b/Tests/CameraKageTests/Mocks/BaseCameraDelegateStub.swift @@ -0,0 +1,13 @@ +// +// BaseCameraDelegateStub.swift +// +// +// Created by Lobont Andrei on 15.06.2023. +// + +import Foundation +@testable import CameraKage + +class BaseCameraDelegateStub: BaseCameraDelegate { + var invoked = false +} diff --git a/Tests/CameraKageTests/Mocks/BaseCameraMock.swift b/Tests/CameraKageTests/Mocks/BaseCameraMock.swift new file mode 100644 index 0000000..ceae8af --- /dev/null +++ b/Tests/CameraKageTests/Mocks/BaseCameraMock.swift @@ -0,0 +1,105 @@ +// +// BaseCameraMock.swift +// +// +// Created by Lobont Andrei on 15.06.2023. +// + +import Foundation +import QuartzCore.CALayer +@testable import CameraKage + +class BaseCameraMock: BaseCameraInterface { + private let videoInput: VideoInput + private let options: CameraComponentParsedOptions + + var session: Session + let videoLayer: VideoLayer + var isZoomAllowed: Bool { options.pinchToZoomEnabled } + var delegateQueue: DispatchQueue { options.delegateQeueue } + var isSessionRunning: Bool { session.isRunning } + var maxZoomScale: CGFloat { options.maximumZoomScale } + + init(session: Session, + videoInput: VideoInput, + videoLayer: VideoLayer, + options: CameraComponentParsedOptions) { + self.session = session + self.videoInput = videoInput + self.videoLayer = videoLayer + self.options = options + } + + func startCameraSession() { + session.addObservers() + session.startSession() + } + + func stopCameraSession() { + session.stopSession() + session.removeObservers() + } + + func resumeCameraSession() { + session.startSession() + } + + func setSessionDelegate(_ delegate: SessionDelegate) { + session.delegate = delegate + } + + func flipCamera() throws { + do { + try videoInput.flip() + } catch let error { + throw error + } + } + + func focus(with focusMode: FocusMode, + exposureMode: ExposureMode, + at devicePoint: CGPoint, + monitorSubjectAreaChange: Bool) throws { + do { + let point = videoLayer.captureDevicePointConverted(fromLayerPoint: devicePoint) + try videoInput.focus(focusMode: focusMode, + exposureMode: exposureMode, + point: point, + monitorSubjectAreaChange: monitorSubjectAreaChange) + } catch let error { + throw error + } + } + + func zoom(atScale scale: CGFloat) throws { + do { + try videoInput.zoom(atScale: scale) + } catch let error { + throw error + } + } + + func minMaxZoom(_ factor: CGFloat) -> CGFloat { + videoInput.minMaxZoom(factor, options: options) + } + + func configureFlash(_ flashMode: FlashMode) throws { + do { + try videoInput.configureFlash(flashMode) + } catch let error { + throw error + } + } + + func embedPreviewLayer(in layer: CALayer) { + videoLayer.embedPreviewLayer(in: layer) + } + + func setPreviewLayerFrame(_ frame: CGRect) { + videoLayer.setPreviewLayerFrame(frame) + } + + func removePreviewLayer() { + videoLayer.removeFromSuperlayer() + } +} diff --git a/Tests/CameraKageTests/Mocks/CaptureSessionMock.swift b/Tests/CameraKageTests/Mocks/CaptureSessionMock.swift new file mode 100644 index 0000000..83238d2 --- /dev/null +++ b/Tests/CameraKageTests/Mocks/CaptureSessionMock.swift @@ -0,0 +1,41 @@ +// +// CaptureSessionMock.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import Foundation +@testable import CameraKage + +class CaptureSessionMock: Session { + var isRunning = false + var isSessionConfigurating = false + var isObservingSession = false + + weak var delegate: SessionDelegate? + + func startSession() { + isRunning = true + } + + func stopSession() { + isRunning = false + } + + func beginConfiguration() { + isSessionConfigurating = true + } + + func commitConfiguration() { + isSessionConfigurating = false + } + + func addObservers() { + isObservingSession = true + } + + func removeObservers() { + isObservingSession = false + } +} diff --git a/Tests/CameraKageTests/Mocks/DelegatesManagerMock.swift b/Tests/CameraKageTests/Mocks/DelegatesManagerMock.swift new file mode 100644 index 0000000..6bb5367 --- /dev/null +++ b/Tests/CameraKageTests/Mocks/DelegatesManagerMock.swift @@ -0,0 +1,27 @@ +// +// DelegatesManagerMock.swift +// +// +// Created by Lobont Andrei on 15.06.2023. +// + +import Foundation +@testable import CameraKage + +class DelegatesManagerMock: DelegatesManagerProtocol { + var delegates: NSHashTable = NSHashTable.weakObjects() + + func registerDelegate(_ delegate: AnyObject) { + delegates.add(delegate) + } + + func unregisterDelegate(_ delegate: AnyObject) { + delegates.remove(delegate) + } + + func invokeDelegates(_ execute: (AnyObject) -> Void) { + delegates.allObjects.forEach { delegate in + execute(delegate) + } + } +} diff --git a/Tests/CameraKageTests/PermissionManagerMock.swift b/Tests/CameraKageTests/Mocks/PermissionManagerMock.swift similarity index 100% rename from Tests/CameraKageTests/PermissionManagerMock.swift rename to Tests/CameraKageTests/Mocks/PermissionManagerMock.swift diff --git a/Tests/CameraKageTests/Mocks/VideoInputMock.swift b/Tests/CameraKageTests/Mocks/VideoInputMock.swift new file mode 100644 index 0000000..9f9a965 --- /dev/null +++ b/Tests/CameraKageTests/Mocks/VideoInputMock.swift @@ -0,0 +1,59 @@ +// +// VideoInputMock.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import Foundation +@testable import CameraKage + +class VideoInputMock: VideoInput { + private let options: CameraComponentParsedOptions + var onVideoDeviceError: ((CameraError) -> Void)? + var zoomLevel = 1.0 + var flashMode: FlashMode = .off + var focusMode: FocusMode = .autoFocus + var exposureMode: ExposureMode = .autoExpose + var focusPoint: CGPoint = .zero + var isFlipped = false + var currentCamera: CameraDevice { + isFlipped ? options.flipCameraDevice : options.cameraDevice + } + + init(options: CameraComponentParsedOptions) { + self.options = options + } + + func flip() throws { + isFlipped.toggle() + } + + func focus(focusMode: FocusMode, + exposureMode: ExposureMode, + point: CGPoint, + monitorSubjectAreaChange: Bool) throws { + self.focusMode = focusMode + self.exposureMode = exposureMode + self.focusPoint = point + } + + func zoom(atScale: CGFloat) throws { + guard atScale <= options.maximumZoomScale else { return } + zoomLevel = atScale + } + + func minMaxZoom(_ factor: CGFloat, options: CameraComponentParsedOptions) -> CGFloat { + min(max(factor, options.minimumZoomScale), options.maximumZoomScale) + } + + func configureFlash(_ flashMode: FlashMode) throws { + guard currentCamera != .frontCamera else { + throw CameraError.cameraComponentError(reason: .torchModeNotSupported) + } + self.flashMode = flashMode + } + + func addObservers() {} + func removeObservers() {} +} diff --git a/Tests/CameraKageTests/Mocks/VideoLayerMock.swift b/Tests/CameraKageTests/Mocks/VideoLayerMock.swift new file mode 100644 index 0000000..2ced9c1 --- /dev/null +++ b/Tests/CameraKageTests/Mocks/VideoLayerMock.swift @@ -0,0 +1,33 @@ +// +// VideoLayerMock.swift +// +// +// Created by Lobont Andrei on 14.06.2023. +// + +import Foundation +import QuartzCore.CALayer +@testable import CameraKage + +class VideoLayerMock: VideoLayer { + var parentLayer: CALayer? + var layerFrame: CGRect = .zero + + func embedPreviewLayer(in layer: CALayer) { + parentLayer = layer + } + + func setPreviewLayerFrame(_ frame: CGRect) { + layerFrame = frame + } + + func removeFromSuperlayer() { + parentLayer = nil + } + + func reloadPreviewLayer() throws {} + + func captureDevicePointConverted(fromLayerPoint: CGPoint) -> CGPoint { + .zero + } +}