E)e-c?47??agT^vIy7~kHx>wDNhg!RGU^UVFJ%$r&J
zWgB^K@iuU3nP~`b@j0NKFDNXc<}qWzU51>fg+X84d$Z)@pVwRx6`FeK#XOTf8TEz^
zAn^EDwa;x|+P-J|_8G6*;QxJ}440`xo$vjyt?R8t)J*KZAIrbS&|0ve@JaqP8BRZU
z@s9jAuf@K_{IvS~wXNfvK)l7IstuVBUrPzjRn}wQeU|^wK5G{dYX(aP#tE7XMyw9Y
z7*6O3hVST^eP|o2i;K$=8%?#xGCA1-6Vsf(8{11unQVB?JaH+<1KF}#rA;D?F7{nY
zvlcQu{FDDO?n9NO$fBmT$J)J$zAT@`yVZiPCs3e!xnlbvpTy-qg+*5ITQgdSWOeJc3t`2A{~GJMEH&EY_b8$`xozbYyUDap5dsiaKQ=fPnrK%WS>kp
z-Y}=;LG1CE8CMnh8Fv~pzt=kx@5sAJW}2GOdvOzm%eMj`E?>hYFs<9>=dQ@B%1gF#
z9gpA3vn?n6Mv}GYlNSuns<)NCQPs6&=w80S_4N5oOB`qTAp9)CFq26km?1@s`9?E*Vhl0EFW0sy;#3=%L4v;wu$UNk5=R#Vfm!4qq}nAx}Ohs%RG^9H}MY9TJf`*
rccYE!+)uJXiO~x`)`B9w;{<=~hGt>scf~HiM8@Fh>gTe~DWM4fsCX7K
literal 0
HcmV?d00001
diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json
new file mode 100644
index 0000000..fb5b643
--- /dev/null
+++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "mijick-icon-photo.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/mijick-icon-photo.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-photo.imageset/mijick-icon-photo.png
new file mode 100644
index 0000000000000000000000000000000000000000..fe98320a74999c3d7eee93fc7226093cb237b1cb
GIT binary patch
literal 869
zcmV-r1DgDaP)@~0drDELIAGL9O(c600d`2O+f$vv5yPrz2AMn-49JPdjAVRDb
zz8aUaS`-W+;Jll^8^R2UULl=)7B(Ph{+n38p(w!DS*lSVkl-cKdzaFa}T<11Rh@%KH=R2K`mUd-OLn0d!Q3JI6H+
zH~JDj=b6+@eWCLK#|Y@>->o*=Z2(IQ7vO7gN6nNM88FU#;_(>3POUNC4AUE9HURC*
z&>M^=S4SNxr5(T>=n3rr-XTr$lAm?j0W=s-Zp;`!CIRC^;Og6dOu15MG5m~fCLYWD
zRZyy_FI4Vu!!yTd;BSYWnj9~-jrAG-2GPV?WjcoW0IV!YcK3g0@cj>WbU(Q!oEO)G
zy5EcBn(uqsBEjEr`gbN7(cyWo`(@2A2nZ4Ve^Ic5g^T%
zf40}Cl;si7#rlYfn=h;e(4jx`J57zWajnQ``#cy>W!FeaGJUT)uI2c%?sEk&2Mmg
z`uhrsr1@LclL}C_fbWLotR)JN;n>1&({fb{{)WJLHvd6@-v_Iwlzar6dZszx@ke|-
vHF9X(;_*j(PPGS#h=_=Yh=_=Yh)(4%xC;=rArYb?00000NkvXXu0mjfU676V
literal 0
HcmV?d00001
diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json
new file mode 100644
index 0000000..6ceb034
--- /dev/null
+++ b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "mijick-icon-video.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/mijick-icon-video.png b/Sources/Internal/Assets/Icons.xcassets/mijick-icon-video.imageset/mijick-icon-video.png
new file mode 100644
index 0000000000000000000000000000000000000000..78c892fe4c0e4504106906d126eb9b0cb33fc702
GIT binary patch
literal 884
zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$
zkh>GZx^prwfgF}}M_)$E)e-c?47??RdT^vIy7~jr)oqO9rq}9Kh?}6YA-aU?{
zP39ZS3d|;$d6;#W7g#r5bNV=uDDAvj@i|G(VNBX`vkX?PycTnyDqnz=O)j`rUK9XzWW4E<=K3Z
zXUYBSx_6V|Bf*El^}H{Y%;j$RH*IUIQ8)MSX|6b#-@1wS&TOmSJEtzFmst6=!{tiH
z^gow7K3Tix-VTv++&y!}PDiE~VS{J4Gg~&*&f2?nL9As)RPa)3Ee+F0D))~?X;eR7
zc$e!I+q-z3myL2$Hoi^nVcGd@+cA@Db@yE9{R=(XKb))=I~8;A{laXPzsCZ9Z{sm_
zQm^-E`X={l>VnwnjQed;3waHkDtp_@v@@hzq+M1C8vVR89}JlJaILDe=n(``KSeFLdK%i{L4dzmzp4>A+O6fR&r2
zmP#hvWWM-%jaw(1ghJI^_03Dd1#R!SU%a_tOSz-1QM3MIq0K7HZx*a`O4B*npqu8B
zxmTgtB0X}0xCzs4!NYm>p?5uGXA8Y!VBRllrd8gcEhYY$sl4n$W>H#hLoe6m4Z_Cm
zM}M8Y*PwhX@%Hx_&&71FF-V_xiq(2zC~FzboKs@){c(P^k?5yc6UvPi9C`ZE_MF?3
z8*Asx@qfq9!Tp_m3LCEn4-)EG&1ieV{og^xaOOE&vn~HWY*@`$?`Wqq*S}o+`os@k
zH|&l)YgBoG|D*7!A2PD`+ixfd`fHeHE?nfA6g6e)h6#$YyH~pP)~LCf96D#N{_`wD
vr_}R%4d*qV>~t&NxlQU#4I;q`1j*&A$L*B&SnS{g%p?q+u6{1-oD!M Angle { switch self {
+ case .portrait: .degrees(0)
+ case .landscapeLeft: .degrees(-90)
+ case .landscapeRight: .degrees(90)
+ case .portraitUpsideDown: .degrees(180)
+ default: .degrees(0)
+ }}
+}
+
+// MARK: To UIImageOrientation
+extension AVCaptureVideoOrientation {
+ func toImageOrientation() -> UIImage.Orientation { switch self {
+ case .portrait: .downMirrored
+ case .landscapeLeft: .leftMirrored
+ case .landscapeRight: .rightMirrored
+ case .portraitUpsideDown: .upMirrored
+ default: .up
+ }}
+}
+
+// MARK: To UIDeviceOrientation
+extension AVCaptureVideoOrientation {
+ func toDeviceOrientation() -> UIDeviceOrientation { switch self {
+ case .portrait: .portrait
+ case .portraitUpsideDown: .portraitUpsideDown
+ case .landscapeLeft: .landscapeLeft
+ case .landscapeRight: .landscapeRight
+ default: .portrait
+ }}
+}
diff --git a/Sources/Internal/Extensions/AVVideoComposition++.swift b/Sources/Internal/Extensions/AVVideoComposition++.swift
new file mode 100644
index 0000000..e88a654
--- /dev/null
+++ b/Sources/Internal/Extensions/AVVideoComposition++.swift
@@ -0,0 +1,20 @@
+//
+// AVVideoComposition++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+// MARK: Apply Filters
+extension AVVideoComposition {
+ static func applyFilters(to asset: AVAsset, applyFiltersAction: @Sendable @escaping (AVAsynchronousCIImageFilteringRequest) -> ()) async throws -> AVVideoComposition {
+ if #available(iOS 16.0, *) { return try await AVVideoComposition.videoComposition(with: asset, applyingCIFiltersWithHandler: applyFiltersAction) }
+ return AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: applyFiltersAction)
+ }
+}
diff --git a/Sources/Internal/Extensions/Animation++.swift b/Sources/Internal/Extensions/Animation++.swift
new file mode 100644
index 0000000..5fbf9da
--- /dev/null
+++ b/Sources/Internal/Extensions/Animation++.swift
@@ -0,0 +1,20 @@
+//
+// Animation++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Custom Animation
+extension Animation {
+ static var mSpring: Animation { .spring(duration: duration, bounce: 0, blendDuration: 0) }
+}
+extension Animation {
+ static var duration: CGFloat { 0.3 }
+}
diff --git a/Sources/Internal/Extensions/CIFilter++.swift b/Sources/Internal/Extensions/CIFilter++.swift
new file mode 100644
index 0000000..c1ff1ea
--- /dev/null
+++ b/Sources/Internal/Extensions/CIFilter++.swift
@@ -0,0 +1,14 @@
+//
+// CIFilter++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+extension CIFilter: @unchecked @retroactive Sendable {}
diff --git a/Sources/Internal/Extensions/CIImage++.swift b/Sources/Internal/Extensions/CIImage++.swift
new file mode 100644
index 0000000..0ded9bb
--- /dev/null
+++ b/Sources/Internal/Extensions/CIImage++.swift
@@ -0,0 +1,24 @@
+//
+// CIImage++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Applying Filters
+extension CIImage {
+ func applyingFilters(_ filters: [CIFilter]) -> CIImage {
+ var ciImage = self
+ filters.forEach {
+ $0.setValue(ciImage, forKey: kCIInputImageKey)
+ ciImage = $0.outputImage ?? ciImage
+ }
+ return ciImage
+ }
+}
diff --git a/Sources/Internal/Extensions/CameraUtilities++.swift b/Sources/Internal/Extensions/CameraUtilities++.swift
new file mode 100644
index 0000000..370a94f
--- /dev/null
+++ b/Sources/Internal/Extensions/CameraUtilities++.swift
@@ -0,0 +1,21 @@
+//
+// CameraUtilities++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+// MARK: To Device Flash Mode
+extension CameraFlashMode {
+ func toDeviceFlashMode() -> AVCaptureDevice.FlashMode { switch self {
+ case .off: .off
+ case .on: .on
+ case .auto: .auto
+ }}
+}
diff --git a/Sources/Internal/Extensions/CaseIterable++.swift b/Sources/Internal/Extensions/CaseIterable++.swift
new file mode 100644
index 0000000..30dd6bc
--- /dev/null
+++ b/Sources/Internal/Extensions/CaseIterable++.swift
@@ -0,0 +1,22 @@
+//
+// CaseIterable++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Foundation
+
+// MARK: Next
+extension CaseIterable where Self: Equatable {
+ func next() -> Self {
+ guard let index = Self.allCases.firstIndex(of: self) else { return self }
+
+ let nextIndex = Self.allCases.index(after: index)
+ return Self.allCases[nextIndex == Self.allCases.endIndex ? Self.allCases.startIndex : nextIndex]
+ }
+}
diff --git a/Sources/Internal/Extensions/FileManager++.swift b/Sources/Internal/Extensions/FileManager++.swift
new file mode 100644
index 0000000..50db55e
--- /dev/null
+++ b/Sources/Internal/Extensions/FileManager++.swift
@@ -0,0 +1,35 @@
+//
+// FileManager++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Prepare Place for Video Output
+extension FileManager {
+ static func prepareURLForVideoOutput() -> URL? {
+ guard let fileUrl = getFileUrl() else { return nil }
+
+ clearPlaceIfTaken(fileUrl)
+ return fileUrl
+ }
+}
+private extension FileManager {
+ static func getFileUrl() -> URL? {
+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
+ .first?
+ .appendingPathComponent(videoPath)
+ }
+ static func clearPlaceIfTaken(_ url: URL) {
+ try? FileManager.default.removeItem(at: url)
+ }
+}
+private extension FileManager {
+ static var videoPath: String { "mijick-camera-video-output.mp4" }
+}
diff --git a/Sources/Internal/Extensions/Task++.swift b/Sources/Internal/Extensions/Task++.swift
new file mode 100644
index 0000000..cffd1ef
--- /dev/null
+++ b/Sources/Internal/Extensions/Task++.swift
@@ -0,0 +1,19 @@
+//
+// Task++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Foundation
+
+// MARK: Sleep
+extension Task where Success == Never, Failure == Never {
+ static func sleep(seconds: CGFloat) async {
+ try! await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
+ }
+}
diff --git a/Sources/Internal/Extensions/UIImage.Orientation++.swift b/Sources/Internal/Extensions/UIImage.Orientation++.swift
new file mode 100644
index 0000000..77b91c5
--- /dev/null
+++ b/Sources/Internal/Extensions/UIImage.Orientation++.swift
@@ -0,0 +1,26 @@
+//
+// UIImage.Orientation++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: From CGImagePropertyOrientation
+extension UIImage.Orientation {
+ init(_ orientation: CGImagePropertyOrientation) { switch orientation {
+ case .down: self = .down
+ case .downMirrored: self = .downMirrored
+ case .left: self = .left
+ case .leftMirrored: self = .leftMirrored
+ case .right: self = .right
+ case .rightMirrored: self = .rightMirrored
+ case .up: self = .up
+ case .upMirrored: self = .upMirrored
+ }}
+}
diff --git a/Sources/Internal/Extensions/UIView++.swift b/Sources/Internal/Extensions/UIView++.swift
new file mode 100644
index 0000000..735c972
--- /dev/null
+++ b/Sources/Internal/Extensions/UIView++.swift
@@ -0,0 +1,43 @@
+//
+// UIView++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Add to Parent
+extension UIView {
+ func addToParent(_ view: UIView) {
+ view.addSubview(self)
+
+ translatesAutoresizingMaskIntoConstraints = false
+ leftAnchor.constraint(equalTo: view.leftAnchor, constant: 0).isActive = true
+ rightAnchor.constraint(equalTo: view.rightAnchor, constant: 0).isActive = true
+ topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true
+ bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0).isActive = true
+ }
+}
+
+// MARK: Apply Blur Effect
+extension UIView {
+ func applyBlurEffect(style: UIBlurEffect.Style) {
+ let blurEffectView = UIVisualEffectView()
+ blurEffectView.frame = bounds
+ blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
+ blurEffectView.effect = UIBlurEffect(style: style)
+
+ addSubview(blurEffectView)
+ }
+}
+
+// MARK: Tags
+extension Int {
+ static var blurViewTag: Int { 2137 }
+ static var focusIndicatorTag: Int { 29 }
+}
diff --git a/Sources/Internal/Extensions/View++.swift b/Sources/Internal/Extensions/View++.swift
new file mode 100644
index 0000000..7420855
--- /dev/null
+++ b/Sources/Internal/Extensions/View++.swift
@@ -0,0 +1,17 @@
+//
+// View++.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Erased
+extension View {
+ func erased() -> AnyView { .init(self) }
+}
diff --git a/Sources/Internal/Manager/CameraManager+Attributes.swift b/Sources/Internal/Manager/CameraManager+Attributes.swift
new file mode 100644
index 0000000..fd3aa65
--- /dev/null
+++ b/Sources/Internal/Manager/CameraManager+Attributes.swift
@@ -0,0 +1,40 @@
+//
+// CameraManager+Attributes.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+import UIKit
+
+struct CameraManagerAttributes {
+ var capturedMedia: MCameraMedia? = nil
+ var error: MCameraError? = nil
+
+ var outputType: CameraOutputType = .photo
+ var cameraPosition: CameraPosition = .back
+ var isAudioSourceAvailable: Bool = true
+ var zoomFactor: CGFloat = 1.0
+ var flashMode: CameraFlashMode = .off
+ var lightMode: CameraLightMode = .off
+ var resolution: AVCaptureSession.Preset = .hd1920x1080
+ var frameRate: Int32 = 30
+ var cameraExposure: CameraExposure = .init()
+ var hdrMode: CameraHDRMode = .auto
+ var cameraFilters: [CIFilter] = []
+ var mirrorOutput: Bool = false
+ var isGridVisible: Bool = true
+
+ /// Color for screen flash on front camera (nil = use default white)
+ var screenFlashColor: UIColor? = nil
+
+ var deviceOrientation: AVCaptureVideoOrientation = .portrait
+ var frameOrientation: CGImagePropertyOrientation = .right
+ var orientationLocked: Bool = false
+ var userBlockedScreenRotation: Bool = false
+}
diff --git a/Sources/Internal/Manager/CameraManager+MotionManager.swift b/Sources/Internal/Manager/CameraManager+MotionManager.swift
new file mode 100644
index 0000000..a1faaf9
--- /dev/null
+++ b/Sources/Internal/Manager/CameraManager+MotionManager.swift
@@ -0,0 +1,108 @@
+//
+// CameraManager+MotionManager.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import CoreMotion
+import AVKit
+
+@MainActor class CameraManagerMotionManager {
+ private(set) var parent: CameraManager!
+ private(set) var manager: CMMotionManager = .init()
+}
+
+// MARK: Setup
+extension CameraManagerMotionManager {
+ func setup(parent: CameraManager) {
+ self.parent = parent
+ manager.accelerometerUpdateInterval = 0.05
+ manager.startAccelerometerUpdates(to: .current ?? .init(), withHandler: handleAccelerometerUpdates)
+ }
+}
+private extension CameraManagerMotionManager {
+ func handleAccelerometerUpdates(_ data: CMAccelerometerData?, _ error: Error?) {
+ guard let data, error == nil else { return }
+
+ let newDeviceOrientation = getDeviceOrientation(data.acceleration)
+ updateDeviceOrientation(newDeviceOrientation)
+ updateUserBlockedScreenRotation()
+ updateFrameOrientation()
+ redrawGrid()
+ }
+}
+private extension CameraManagerMotionManager {
+ func getDeviceOrientation(_ acceleration: CMAcceleration) -> AVCaptureVideoOrientation { switch acceleration {
+ case let acceleration where acceleration.x >= 0.75: .landscapeLeft
+ case let acceleration where acceleration.x <= -0.75: .landscapeRight
+ case let acceleration where acceleration.y <= -0.75: .portrait
+ case let acceleration where acceleration.y >= 0.75: .portraitUpsideDown
+ default: parent.attributes.deviceOrientation
+ }}
+ func updateDeviceOrientation(_ newDeviceOrientation: AVCaptureVideoOrientation) { if newDeviceOrientation != parent.attributes.deviceOrientation {
+ parent.attributes.deviceOrientation = newDeviceOrientation
+ }}
+ func updateUserBlockedScreenRotation() {
+ let newUserBlockedScreenRotation = getNewUserBlockedScreenRotation()
+ if newUserBlockedScreenRotation != parent.attributes.userBlockedScreenRotation { parent.attributes.userBlockedScreenRotation = newUserBlockedScreenRotation }
+ }
+ func updateFrameOrientation() { if UIDevice.current.orientation != .portraitUpsideDown {
+ let newFrameOrientation = getNewFrameOrientation(parent.attributes.orientationLocked ? .portrait : UIDevice.current.orientation)
+ updateFrameOrientation(newFrameOrientation)
+ }}
+ func redrawGrid() { if !parent.attributes.orientationLocked {
+ parent.cameraGridView.draw(.zero)
+ }}
+}
+private extension CameraManagerMotionManager {
+ func getNewUserBlockedScreenRotation() -> Bool { switch parent.attributes.deviceOrientation.rawValue == UIDevice.current.orientation.rawValue {
+ case true: false
+ case false: !parent.attributes.orientationLocked
+ }}
+ func getNewFrameOrientation(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch parent.attributes.cameraPosition {
+ case .back: getNewFrameOrientationForBackCamera(orientation)
+ case .front: getNewFrameOrientationForFrontCamera(orientation)
+ }}
+ func updateFrameOrientation(_ newFrameOrientation: CGImagePropertyOrientation) { if newFrameOrientation != parent.attributes.frameOrientation {
+ let shouldAnimate = shouldAnimateFrameOrientationChange(newFrameOrientation)
+ updateFrameOrientation(withAnimation: shouldAnimate, newFrameOrientation: newFrameOrientation)
+ }}
+}
+private extension CameraManagerMotionManager {
+ func getNewFrameOrientationForBackCamera(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch orientation {
+ case .portrait: parent.attributes.mirrorOutput ? .leftMirrored : .right
+ case .landscapeLeft: parent.attributes.mirrorOutput ? .upMirrored : .up
+ case .landscapeRight: parent.attributes.mirrorOutput ? .downMirrored : .down
+ default: parent.attributes.mirrorOutput ? .leftMirrored : .right
+ }}
+ func getNewFrameOrientationForFrontCamera(_ orientation: UIDeviceOrientation) -> CGImagePropertyOrientation { switch orientation {
+ case .portrait: parent.attributes.mirrorOutput ? .right : .leftMirrored
+ case .landscapeLeft: parent.attributes.mirrorOutput ? .down : .downMirrored
+ case .landscapeRight: parent.attributes.mirrorOutput ? .up : .upMirrored
+ default: parent.attributes.mirrorOutput ? .right : .leftMirrored
+ }}
+ func shouldAnimateFrameOrientationChange(_ newFrameOrientation: CGImagePropertyOrientation) -> Bool {
+ let backCameraOrientations: [CGImagePropertyOrientation] = [.left, .right, .up, .down],
+ frontCameraOrientations: [CGImagePropertyOrientation] = [.leftMirrored, .rightMirrored, .upMirrored, .downMirrored]
+
+ return (backCameraOrientations.contains(newFrameOrientation) && backCameraOrientations.contains(parent.attributes.frameOrientation)) ||
+ (frontCameraOrientations.contains(parent.attributes.frameOrientation) && frontCameraOrientations.contains(newFrameOrientation))
+ }
+ func updateFrameOrientation(withAnimation shouldAnimate: Bool, newFrameOrientation: CGImagePropertyOrientation) { Task {
+ await parent.cameraMetalView.beginCameraOrientationAnimation(if: shouldAnimate)
+ parent.attributes.frameOrientation = newFrameOrientation
+ parent.cameraMetalView.finishCameraOrientationAnimation(if: shouldAnimate)
+ }}
+}
+
+// MARK: Reset
+extension CameraManagerMotionManager {
+ func reset() {
+ manager.stopAccelerometerUpdates()
+ }
+}
diff --git a/Sources/Internal/Manager/CameraManager+NotificationCenter.swift b/Sources/Internal/Manager/CameraManager+NotificationCenter.swift
new file mode 100644
index 0000000..4f9aebe
--- /dev/null
+++ b/Sources/Internal/Manager/CameraManager+NotificationCenter.swift
@@ -0,0 +1,37 @@
+//
+// CameraManager+NotificationCenter.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Foundation
+
+@MainActor class CameraManagerNotificationCenter {
+ private(set) var parent: CameraManager!
+}
+
+// MARK: Setup
+extension CameraManagerNotificationCenter {
+ func setup(parent: CameraManager) {
+ self.parent = parent
+ NotificationCenter.default.addObserver(self, selector: #selector(handleSessionWasInterrupted), name: .AVCaptureSessionWasInterrupted, object: parent.captureSession)
+ }
+}
+private extension CameraManagerNotificationCenter {
+ @objc func handleSessionWasInterrupted() {
+ parent.attributes.lightMode = .off
+ parent.videoOutput.reset()
+ }
+}
+
+// MARK: Reset
+extension CameraManagerNotificationCenter {
+ func reset() {
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionWasInterrupted, object: parent?.captureSession)
+ }
+}
diff --git a/Sources/Internal/Manager/CameraManager+PermissionsManager.swift b/Sources/Internal/Manager/CameraManager+PermissionsManager.swift
new file mode 100644
index 0000000..c292752
--- /dev/null
+++ b/Sources/Internal/Manager/CameraManager+PermissionsManager.swift
@@ -0,0 +1,46 @@
+//
+// CameraManager+PermissionsManager.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+@MainActor class CameraManagerPermissionsManager {}
+
+// MARK: Request Access
+extension CameraManagerPermissionsManager {
+ func requestAccess(parent: CameraManager) async throws(MCameraError) {
+ do {
+ try await getAuthorizationStatus(for: .video)
+ if parent.attributes.isAudioSourceAvailable { try await getAuthorizationStatus(for: .audio) }
+ }
+ catch {
+ parent.attributes.error = error
+ throw error
+ }
+ }
+}
+private extension CameraManagerPermissionsManager {
+ func getAuthorizationStatus(for mediaType: AVMediaType) async throws(MCameraError) { switch AVCaptureDevice.authorizationStatus(for: mediaType) {
+ case .denied, .restricted: throw getPermissionsError(mediaType)
+ case .notDetermined: try await requestAccess(for: mediaType)
+ default: return
+ }}
+}
+private extension CameraManagerPermissionsManager {
+ func requestAccess(for mediaType: AVMediaType) async throws(MCameraError) {
+ let isGranted = await AVCaptureDevice.requestAccess(for: mediaType)
+ if !isGranted { throw getPermissionsError(mediaType) }
+ }
+ func getPermissionsError(_ mediaType: AVMediaType) -> MCameraError { switch mediaType {
+ case .audio: .microphonePermissionsNotGranted
+ case .video: .cameraPermissionsNotGranted
+ default: fatalError()
+ }}
+}
diff --git a/Sources/Internal/Manager/CameraManager+PhotoOutput.swift b/Sources/Internal/Manager/CameraManager+PhotoOutput.swift
new file mode 100644
index 0000000..9d20346
--- /dev/null
+++ b/Sources/Internal/Manager/CameraManager+PhotoOutput.swift
@@ -0,0 +1,127 @@
+//
+// CameraManager+PhotoOutput.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+@MainActor class CameraManagerPhotoOutput: NSObject {
+ private(set) var parent: CameraManager!
+ private(set) var output: AVCapturePhotoOutput = .init()
+}
+
+// MARK: Setup
+extension CameraManagerPhotoOutput {
+ func setup(parent: CameraManager) throws(MCameraError) {
+ self.parent = parent
+ try self.parent.captureSession.add(output: output)
+ }
+}
+
+
+// MARK: - CAPTURE PHOTO
+
+
+
+// MARK: Capture
+extension CameraManagerPhotoOutput {
+ func capture() {
+ guard let parent else {
+ print("CameraManagerPhotoOutput: parent is nil, cannot capture")
+ return
+ }
+
+ configureOutput()
+
+ // Check if we should disable iOS Retina Flash (when using custom screen flash from SwiftUI layer)
+ let disableBuiltInFlash = parent.attributes.screenFlashColor != nil &&
+ parent.attributes.cameraPosition == .front &&
+ parent.attributes.flashMode != .off
+
+ let settings = getPhotoOutputSettings(disableFlash: disableBuiltInFlash)
+ output.capturePhoto(with: settings, delegate: self)
+ parent.cameraMetalView.performImageCaptureAnimation()
+ }
+}
+private extension CameraManagerPhotoOutput {
+ func getPhotoOutputSettings(disableFlash: Bool) -> AVCapturePhotoSettings {
+ let settings = AVCapturePhotoSettings()
+
+ // When using custom screen flash for front camera, disable iOS's built-in flash
+ // to prevent double-flash (our custom flash + iOS Retina Flash)
+ if disableFlash {
+ settings.flashMode = .off
+ } else {
+ // For back camera, use the requested flash mode if supported
+ let desiredFlashMode = parent.attributes.flashMode.toDeviceFlashMode()
+ if output.supportedFlashModes.contains(desiredFlashMode) {
+ settings.flashMode = desiredFlashMode
+ } else {
+ settings.flashMode = .off
+ }
+ }
+
+ return settings
+ }
+ func configureOutput() {
+ guard let connection = output.connection(with: .video), connection.isVideoMirroringSupported else { return }
+
+ connection.isVideoMirrored = parent.attributes.mirrorOutput ? parent.attributes.cameraPosition != .front : parent.attributes.cameraPosition == .front
+ connection.videoOrientation = parent.attributes.deviceOrientation
+ }
+}
+
+// MARK: Receive Data
+extension CameraManagerPhotoOutput: @preconcurrency AVCapturePhotoCaptureDelegate {
+ func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: (any Error)?) {
+ guard let imageData = photo.fileDataRepresentation(),
+ let ciImage = CIImage(data: imageData)
+ else { return }
+
+ let capturedCIImage = prepareCIImage(ciImage, parent.attributes.cameraFilters)
+ let capturedCGImage = prepareCGImage(capturedCIImage)
+ let capturedUIImage = prepareUIImage(capturedCGImage)
+ let capturedMedia = MCameraMedia(data: capturedUIImage)
+
+ parent.setCapturedMedia(capturedMedia)
+ }
+}
+private extension CameraManagerPhotoOutput {
+ func prepareCIImage(_ ciImage: CIImage, _ filters: [CIFilter]) -> CIImage {
+ ciImage.applyingFilters(filters)
+ }
+ func prepareCGImage(_ ciImage: CIImage) -> CGImage? {
+ CIContext().createCGImage(ciImage, from: ciImage.extent)
+ }
+ func prepareUIImage(_ cgImage: CGImage?) -> UIImage? {
+ guard let cgImage else { return nil }
+
+ let frameOrientation = getFixedFrameOrientation()
+ let orientation = UIImage.Orientation(frameOrientation)
+ let uiImage = UIImage(cgImage: cgImage, scale: 1.0, orientation: orientation)
+ return uiImage
+ }
+}
+private extension CameraManagerPhotoOutput {
+ func getFixedFrameOrientation() -> CGImagePropertyOrientation {
+ guard UIDevice.current.orientation != parent.attributes.deviceOrientation.toDeviceOrientation() else { return parent.attributes.frameOrientation }
+
+ return switch (parent.attributes.deviceOrientation, parent.attributes.cameraPosition) {
+ case (.portrait, .front): .left
+ case (.portrait, .back): .right
+ case (.landscapeLeft, .back): .down
+ case (.landscapeRight, .back): .up
+ case (.landscapeLeft, .front) where parent.attributes.mirrorOutput: .up
+ case (.landscapeLeft, .front): .upMirrored
+ case (.landscapeRight, .front) where parent.attributes.mirrorOutput: .down
+ case (.landscapeRight, .front): .downMirrored
+ default: .right
+ }
+ }
+}
diff --git a/Sources/Internal/Manager/CameraManager+VideoOutput.swift b/Sources/Internal/Manager/CameraManager+VideoOutput.swift
new file mode 100644
index 0000000..b5cb37a
--- /dev/null
+++ b/Sources/Internal/Manager/CameraManager+VideoOutput.swift
@@ -0,0 +1,158 @@
+//
+// CameraManager+VideoOutput.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+@preconcurrency import AVKit
+import SwiftUI
+import MijickTimer
+
+@MainActor class CameraManagerVideoOutput: NSObject {
+ private(set) var parent: CameraManager!
+ private(set) var output: AVCaptureMovieFileOutput = .init()
+ private(set) var timer: MTimer = .init(.camera)
+ private(set) var recordingTime: MTime = .zero
+ private(set) var firstRecordedFrame: UIImage?
+}
+
+// MARK: Setup
+extension CameraManagerVideoOutput {
+ func setup(parent: CameraManager) throws(MCameraError) {
+ self.parent = parent
+ try parent.captureSession.add(output: output)
+ }
+}
+
+// MARK: Reset
+extension CameraManagerVideoOutput {
+ func reset() {
+ timer.reset()
+ }
+}
+
+
+// MARK: - CAPTURE VIDEO
+
+
+
+// MARK: Toggle
+extension CameraManagerVideoOutput {
+ func toggleRecording() { switch output.isRecording {
+ case true: stopRecording()
+ case false: startRecording()
+ }}
+}
+
+// MARK: Start Recording
+private extension CameraManagerVideoOutput {
+ func startRecording() {
+ guard let url = prepareUrlForVideoRecording() else { return }
+
+ configureOutput()
+ storeLastFrame()
+ output.startRecording(to: url, recordingDelegate: self)
+ startRecordingTimer()
+ parent.objectWillChange.send()
+ }
+}
+private extension CameraManagerVideoOutput {
+ func prepareUrlForVideoRecording() -> URL? {
+ FileManager.prepareURLForVideoOutput()
+ }
+ func configureOutput() {
+ guard let connection = output.connection(with: .video), connection.isVideoMirroringSupported else { return }
+
+ connection.isVideoMirrored = parent.attributes.mirrorOutput ? parent.attributes.cameraPosition != .front : parent.attributes.cameraPosition == .front
+ connection.videoOrientation = parent.attributes.deviceOrientation
+ }
+ func storeLastFrame() {
+ guard let texture = parent.cameraMetalView.currentDrawable?.texture,
+ let ciImage = CIImage(mtlTexture: texture, options: nil),
+ let cgImage = parent.cameraMetalView.ciContext.createCGImage(ciImage, from: ciImage.extent)
+ else { return }
+
+ firstRecordedFrame = UIImage(cgImage: cgImage, scale: 1.0, orientation: parent.attributes.deviceOrientation.toImageOrientation())
+ }
+ func startRecordingTimer() { try? timer
+ .publish(every: 1) { [self] in
+ recordingTime = $0
+ parent.objectWillChange.send()
+ }
+ .start()
+ }
+}
+
+// MARK: Stop Recording
+private extension CameraManagerVideoOutput {
+ func stopRecording() {
+ presentLastFrame()
+ output.stopRecording()
+ timer.reset()
+ }
+}
+private extension CameraManagerVideoOutput {
+ func presentLastFrame() {
+ let firstRecordedFrame = MCameraMedia(data: firstRecordedFrame)
+ parent.setCapturedMedia(firstRecordedFrame)
+ }
+}
+
+// MARK: Receive Data
+extension CameraManagerVideoOutput: @preconcurrency AVCaptureFileOutputRecordingDelegate {
+ func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: (any Error)?) { Task {
+ let videoURL = try await prepareVideo(outputFileURL: outputFileURL, cameraFilters: parent.attributes.cameraFilters)
+ let capturedVideo = MCameraMedia(data: videoURL)
+
+ await Task.sleep(seconds: Animation.duration)
+ parent.setCapturedMedia(capturedVideo)
+ }}
+}
+private extension CameraManagerVideoOutput {
+ func prepareVideo(outputFileURL: URL, cameraFilters: [CIFilter]) async throws -> URL {
+ if cameraFilters.isEmpty { return outputFileURL }
+
+ let asset = AVAsset(url: outputFileURL)
+ let videoComposition = try await AVVideoComposition.applyFilters(to: asset) { self.applyFiltersToVideo($0, cameraFilters) }
+ let fileUrl = FileManager.prepareURLForVideoOutput()
+ let exportSession = prepareAssetExportSession(asset, fileUrl, videoComposition)
+
+ try await exportVideo(exportSession, fileUrl)
+ return fileUrl ?? outputFileURL
+ }
+}
+private extension CameraManagerVideoOutput {
+ nonisolated func applyFiltersToVideo(_ request: AVAsynchronousCIImageFilteringRequest, _ filters: [CIFilter]) {
+ let videoFrame = prepareVideoFrame(request, filters)
+ request.finish(with: videoFrame, context: nil)
+ }
+ nonisolated func exportVideo(_ exportSession: AVAssetExportSession?, _ fileUrl: URL?) async throws { if let fileUrl {
+ if #available(iOS 18, *) { try await exportSession?.export(to: fileUrl, as: .mov) }
+ else { await exportSession?.export() }
+ }}
+}
+private extension CameraManagerVideoOutput {
+ nonisolated func prepareVideoFrame(_ request: AVAsynchronousCIImageFilteringRequest, _ filters: [CIFilter]) -> CIImage { request
+ .sourceImage
+ .clampedToExtent()
+ .applyingFilters(filters)
+ }
+ nonisolated func prepareAssetExportSession(_ asset: AVAsset, _ fileUrl: URL?, _ composition: AVVideoComposition?) -> AVAssetExportSession? {
+ let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset1920x1080)
+ export?.outputFileType = .mov
+ export?.outputURL = fileUrl
+ export?.videoComposition = composition
+ return export
+ }
+}
+
+
+// MARK: - HELPERS
+fileprivate extension MTimerID {
+ static let camera: MTimerID = .init(rawValue: "mijick-camera")
+}
diff --git a/Sources/Internal/Manager/CameraManager.swift b/Sources/Internal/Manager/CameraManager.swift
new file mode 100644
index 0000000..36ba37f
--- /dev/null
+++ b/Sources/Internal/Manager/CameraManager.swift
@@ -0,0 +1,438 @@
+//
+// CameraManager.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+import AVKit
+
+@MainActor public class CameraManager: NSObject, ObservableObject {
+ @Published var attributes: CameraManagerAttributes = .init()
+
+ // MARK: Input
+ private(set) var captureSession: any CaptureSession
+ private(set) var frontCameraInput: (any CaptureDeviceInput)?
+ private(set) var backCameraInput: (any CaptureDeviceInput)?
+
+ // MARK: Output
+ private(set) var photoOutput: CameraManagerPhotoOutput = .init()
+ private(set) var videoOutput: CameraManagerVideoOutput = .init()
+
+ // MARK: UI Elements
+ private(set) var cameraView: UIView!
+ private(set) var cameraLayer: AVCaptureVideoPreviewLayer = .init()
+ private(set) var cameraMetalView: CameraMetalView = .init()
+ private(set) var cameraGridView: CameraGridView = .init()
+
+ // MARK: Others
+ private(set) var permissionsManager: CameraManagerPermissionsManager = .init()
+ private(set) var motionManager: CameraManagerMotionManager = .init()
+ private(set) var notificationCenterManager: CameraManagerNotificationCenter = .init()
+
+ // MARK: Initializer
+ init(captureSession: CS, captureDeviceInputType: CDI.Type) {
+ self.captureSession = captureSession
+ self.frontCameraInput = CDI.get(mediaType: .video, position: .front)
+ self.backCameraInput = CDI.get(mediaType: .video, position: .back)
+ }
+}
+
+// MARK: Initialize
+extension CameraManager {
+ func initialize(in view: UIView) {
+ cameraView = view
+ }
+}
+
+// MARK: Setup
+extension CameraManager {
+ func setup() async throws(MCameraError) {
+ try await permissionsManager.requestAccess(parent: self)
+
+ setupCameraLayer()
+ try setupDeviceInputs()
+ try setupDeviceOutput()
+ try setupFrameRecorder()
+ notificationCenterManager.setup(parent: self)
+ motionManager.setup(parent: self)
+ try cameraMetalView.setup(parent: self)
+ cameraGridView.setup(parent: self)
+
+ startSession()
+ }
+}
+private extension CameraManager {
+ func setupCameraLayer() {
+ captureSession.sessionPreset = attributes.resolution
+
+ cameraLayer.session = captureSession as? AVCaptureSession
+ cameraLayer.videoGravity = .resizeAspectFill
+ cameraLayer.isHidden = true
+ cameraView.layer.addSublayer(cameraLayer)
+ }
+ func setupDeviceInputs() throws(MCameraError) {
+ try captureSession.add(input: getCameraInput())
+ if let audioInput = getAudioInput() { try captureSession.add(input: audioInput) }
+ }
+ func setupDeviceOutput() throws(MCameraError) {
+ try photoOutput.setup(parent: self)
+ try videoOutput.setup(parent: self)
+ }
+ func setupFrameRecorder() throws(MCameraError) {
+ let captureVideoOutput = AVCaptureVideoDataOutput()
+ captureVideoOutput.setSampleBufferDelegate(cameraMetalView, queue: .main)
+
+ try captureSession.add(output: captureVideoOutput)
+ }
+ func startSession() { Task {
+ guard let device = getCameraInput()?.device else { return }
+
+ try await startCaptureSession()
+ try setupDevice(device)
+ resetAttributes(device: device)
+ cameraMetalView.performCameraEntranceAnimation()
+ }}
+}
+private extension CameraManager {
+ func getAudioInput() -> (any CaptureDeviceInput)? {
+ guard attributes.isAudioSourceAvailable,
+ let deviceInput = frontCameraInput ?? backCameraInput
+ else { return nil }
+
+ let captureDeviceInputType = type(of: deviceInput)
+ let audioInput = captureDeviceInputType.get(mediaType: .audio, position: .unspecified)
+ return audioInput
+ }
+ nonisolated func startCaptureSession() async throws {
+ await captureSession.startRunning()
+ }
+ func setupDevice(_ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setExposureMode(attributes.cameraExposure.mode, duration: attributes.cameraExposure.duration, iso: attributes.cameraExposure.iso)
+ device.setExposureTargetBias(attributes.cameraExposure.targetBias)
+ device.setFrameRate(attributes.frameRate)
+ device.setZoomFactor(attributes.zoomFactor)
+ device.setLightMode(attributes.lightMode)
+ device.hdrMode = attributes.hdrMode
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Cancel
+extension CameraManager {
+ func cancel() {
+ captureSession = captureSession.stopRunningAndReturnNewInstance()
+ motionManager.reset()
+ videoOutput.reset()
+ notificationCenterManager.reset()
+ }
+}
+
+
+// MARK: - LIVE ACTIONS
+
+
+
+// MARK: Capture Output
+extension CameraManager {
+ func captureOutput() {
+ guard !isChanging else { return }
+
+ switch attributes.outputType {
+ case .photo: photoOutput.capture()
+ case .video: videoOutput.toggleRecording()
+ }
+ }
+}
+
+// MARK: Set Captured Media
+public extension CameraManager {
+ func setCapturedMedia(_ capturedMedia: MCameraMedia?) { withAnimation(.mSpring) {
+ attributes.capturedMedia = capturedMedia
+ }}
+
+ var capturedMedia: MCameraMedia? {
+ attributes.capturedMedia
+ }
+}
+
+// MARK: Set Camera Output
+extension CameraManager {
+ func setOutputType(_ outputType: CameraOutputType) {
+ guard outputType != attributes.outputType, !isChanging else { return }
+ attributes.outputType = outputType
+ }
+}
+
+// MARK: Set Camera Position
+extension CameraManager {
+ func setCameraPosition(_ position: CameraPosition) async throws {
+ guard position != attributes.cameraPosition, !isChanging else { return }
+
+ await cameraMetalView.beginCameraFlipAnimation()
+ try changeCameraInput(position)
+ resetAttributesWhenChangingCamera(position)
+ await cameraMetalView.finishCameraFlipAnimation()
+ }
+}
+private extension CameraManager {
+ func changeCameraInput(_ position: CameraPosition) throws {
+ if let input = getCameraInput() { captureSession.remove(input: input) }
+ try captureSession.add(input: getCameraInput(position))
+ }
+ func resetAttributesWhenChangingCamera(_ position: CameraPosition) {
+ resetAttributes(device: getCameraInput(position)?.device)
+ attributes.cameraPosition = position
+ }
+}
+
+// MARK: Set Camera Zoom
+extension CameraManager {
+ func setCameraZoomFactor(_ zoomFactor: CGFloat) throws {
+ guard let device = getCameraInput()?.device, zoomFactor != attributes.zoomFactor, !isChanging else { return }
+
+ try setDeviceZoomFactor(zoomFactor, device)
+ attributes.zoomFactor = device.videoZoomFactor
+ }
+}
+private extension CameraManager {
+ func setDeviceZoomFactor(_ zoomFactor: CGFloat, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setZoomFactor(zoomFactor)
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set Camera Focus
+extension CameraManager {
+ func setCameraFocus(at touchPoint: CGPoint) throws {
+ guard let device = getCameraInput()?.device, !isChanging else { return }
+
+ let focusPoint = convertTouchPointToFocusPoint(touchPoint)
+ try setDeviceCameraFocus(focusPoint, device)
+ cameraMetalView.performCameraFocusAnimation(touchPoint: touchPoint)
+ }
+}
+private extension CameraManager {
+ func convertTouchPointToFocusPoint(_ touchPoint: CGPoint) -> CGPoint { .init(
+ x: touchPoint.y / cameraView.frame.height,
+ y: 1 - touchPoint.x / cameraView.frame.width
+ )}
+ func setDeviceCameraFocus(_ focusPoint: CGPoint, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setFocusPointOfInterest(focusPoint)
+ device.setExposurePointOfInterest(focusPoint)
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set Flash Mode
+extension CameraManager {
+ func setFlashMode(_ flashMode: CameraFlashMode) {
+ guard let device = getCameraInput()?.device, device.hasFlash, flashMode != attributes.flashMode, !isChanging else { return }
+ attributes.flashMode = flashMode
+ }
+}
+
+// MARK: Set Screen Flash Color
+extension CameraManager {
+ func setScreenFlashColor(_ color: UIColor?) {
+ attributes.screenFlashColor = color
+ }
+}
+
+// MARK: Set Light Mode
+extension CameraManager {
+ func setLightMode(_ lightMode: CameraLightMode) throws {
+ guard let device = getCameraInput()?.device, device.hasTorch, lightMode != attributes.lightMode, !isChanging else { return }
+
+ try setDeviceLightMode(lightMode, device)
+ attributes.lightMode = device.lightMode
+ }
+}
+private extension CameraManager {
+ func setDeviceLightMode(_ lightMode: CameraLightMode, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setLightMode(lightMode)
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set Mirror Output
+extension CameraManager {
+ func setMirrorOutput(_ mirrorOutput: Bool) {
+ guard mirrorOutput != attributes.mirrorOutput, !isChanging else { return }
+ attributes.mirrorOutput = mirrorOutput
+ }
+}
+
+// MARK: Set Grid Visibility
+extension CameraManager {
+ func setGridVisibility(_ isGridVisible: Bool) {
+ guard isGridVisible != attributes.isGridVisible, !isChanging else { return }
+ cameraGridView.setVisibility(isGridVisible)
+ }
+}
+
+// MARK: Set Camera Filters
+extension CameraManager {
+ func setCameraFilters(_ cameraFilters: [CIFilter]) {
+ guard cameraFilters != attributes.cameraFilters, !isChanging else { return }
+ attributes.cameraFilters = cameraFilters
+ }
+}
+
+// MARK: Set Exposure Mode
+extension CameraManager {
+ func setExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode) throws {
+ guard let device = getCameraInput()?.device, exposureMode != attributes.cameraExposure.mode, !isChanging else { return }
+
+ try setDeviceExposureMode(exposureMode, device)
+ attributes.cameraExposure.mode = device.exposureMode
+ }
+}
+private extension CameraManager {
+ func setDeviceExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setExposureMode(exposureMode, duration: attributes.cameraExposure.duration, iso: attributes.cameraExposure.iso)
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set Exposure Duration
+extension CameraManager {
+ func setExposureDuration(_ exposureDuration: CMTime) throws {
+ guard let device = getCameraInput()?.device, exposureDuration != attributes.cameraExposure.duration, !isChanging else { return }
+
+ try setDeviceExposureDuration(exposureDuration, device)
+ attributes.cameraExposure.duration = device.exposureDuration
+ }
+}
+private extension CameraManager {
+ func setDeviceExposureDuration(_ exposureDuration: CMTime, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setExposureMode(.custom, duration: exposureDuration, iso: attributes.cameraExposure.iso)
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set ISO
+extension CameraManager {
+ func setISO(_ iso: Float) throws {
+ guard let device = getCameraInput()?.device, iso != attributes.cameraExposure.iso, !isChanging else { return }
+
+ try setDeviceISO(iso, device)
+ attributes.cameraExposure.iso = device.iso
+ }
+}
+private extension CameraManager {
+ func setDeviceISO(_ iso: Float, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setExposureMode(.custom, duration: attributes.cameraExposure.duration, iso: iso)
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set Exposure Target Bias
+extension CameraManager {
+ func setExposureTargetBias(_ exposureTargetBias: Float) throws {
+ guard let device = getCameraInput()?.device, exposureTargetBias != attributes.cameraExposure.targetBias, !isChanging else { return }
+
+ try setDeviceExposureTargetBias(exposureTargetBias, device)
+ attributes.cameraExposure.targetBias = device.exposureTargetBias
+ }
+}
+private extension CameraManager {
+ func setDeviceExposureTargetBias(_ exposureTargetBias: Float, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setExposureTargetBias(exposureTargetBias)
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set HDR Mode
+extension CameraManager {
+ func setHDRMode(_ hdrMode: CameraHDRMode) throws {
+ guard let device = getCameraInput()?.device, hdrMode != attributes.hdrMode, !isChanging else { return }
+
+ try setDeviceHDRMode(hdrMode, device)
+ attributes.hdrMode = hdrMode
+ }
+}
+private extension CameraManager {
+ func setDeviceHDRMode(_ hdrMode: CameraHDRMode, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.hdrMode = hdrMode
+ device.unlockForConfiguration()
+ }
+}
+
+// MARK: Set Resolution
+extension CameraManager {
+ func setResolution(_ resolution: AVCaptureSession.Preset) {
+ guard resolution != attributes.resolution, resolution != attributes.resolution, !isChanging else { return }
+
+ captureSession.sessionPreset = resolution
+ attributes.resolution = resolution
+ }
+}
+
+// MARK: Set Frame Rate
+extension CameraManager {
+ func setFrameRate(_ frameRate: Int32) throws {
+ guard let device = getCameraInput()?.device, frameRate != attributes.frameRate, !isChanging else { return }
+
+ try setDeviceFrameRate(frameRate, device)
+ attributes.frameRate = device.activeVideoMaxFrameDuration.timescale
+ }
+}
+private extension CameraManager {
+ func setDeviceFrameRate(_ frameRate: Int32, _ device: any CaptureDevice) throws {
+ try device.lockForConfiguration()
+ device.setFrameRate(frameRate)
+ device.unlockForConfiguration()
+ }
+}
+
+
+// MARK: - HELPERS
+
+
+
+// MARK: Attributes
+extension CameraManager {
+ var hasFlash: Bool { getCameraInput()?.device.hasFlash ?? false }
+ var hasLight: Bool { getCameraInput()?.device.hasTorch ?? false }
+}
+private extension CameraManager {
+ var isChanging: Bool { cameraMetalView.isAnimating }
+}
+
+// MARK: Methods
+extension CameraManager {
+ func resetAttributes(device: (any CaptureDevice)?) {
+ guard let device else { return }
+
+ var newAttributes = attributes
+ newAttributes.cameraExposure.mode = device.exposureMode
+ newAttributes.cameraExposure.duration = device.exposureDuration
+ newAttributes.cameraExposure.iso = device.iso
+ newAttributes.cameraExposure.targetBias = device.exposureTargetBias
+ newAttributes.frameRate = device.activeVideoMaxFrameDuration.timescale
+ newAttributes.zoomFactor = device.videoZoomFactor
+ newAttributes.lightMode = device.lightMode
+ newAttributes.hdrMode = device.hdrMode
+
+ attributes = newAttributes
+ }
+ func getCameraInput(_ position: CameraPosition? = nil) -> (any CaptureDeviceInput)? { switch position ?? attributes.cameraPosition {
+ case .front: frontCameraInput
+ case .back: backCameraInput
+ }}
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+AVCaptureDeviceInput.swift b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+AVCaptureDeviceInput.swift
new file mode 100644
index 0000000..b6ba338
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+AVCaptureDeviceInput.swift
@@ -0,0 +1,26 @@
+//
+// CaptureDeviceInput+AVCaptureDeviceInput.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+extension AVCaptureDeviceInput: CaptureDeviceInput {
+ static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self? {
+ let device = { switch mediaType {
+ case .audio: AVCaptureDevice.default(for: .audio)
+ case .video where position == .front: AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front)
+ case .video where position == .back: AVCaptureDevice.default(for: .video)
+ default: fatalError()
+ }}()
+
+ guard let device, let deviceInput = try? Self(device: device) else { return nil }
+ return deviceInput
+ }
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+MockDeviceInput.swift b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+MockDeviceInput.swift
new file mode 100644
index 0000000..eaaf555
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput+MockDeviceInput.swift
@@ -0,0 +1,26 @@
+//
+// CaptureDeviceInput+MockDeviceInput.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+class MockDeviceInput: NSObject, CaptureDeviceInput { required override init() {}
+ var device: MockCaptureDevice = .init()
+}
+
+// MARK: Methods
+extension MockDeviceInput {
+ static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self? { .init() }
+}
+
+// MARK: Equatable
+extension MockDeviceInput {
+ static func == (lhs: MockDeviceInput, rhs: MockDeviceInput) -> Bool { lhs.device.uniqueID == rhs.device.uniqueID }
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput.swift b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput.swift
new file mode 100644
index 0000000..d590156
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Device Input/CaptureDeviceInput.swift
@@ -0,0 +1,21 @@
+//
+// CaptureDeviceInput.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+protocol CaptureDeviceInput: NSObject {
+ // MARK: Attributes
+ associatedtype CD: CaptureDevice
+ var device: CD { get }
+
+ // MARK: Methods
+ static func get(mediaType: AVMediaType, position: AVCaptureDevice.Position?) -> Self?
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+AVCaptureDevice.swift b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+AVCaptureDevice.swift
new file mode 100644
index 0000000..dacf5ca
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+AVCaptureDevice.swift
@@ -0,0 +1,41 @@
+//
+// CaptureDevice+AVCaptureDevice.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+// MARK: Getters
+extension AVCaptureDevice: CaptureDevice {
+ var minExposureDuration: CMTime { activeFormat.minExposureDuration }
+ var maxExposureDuration: CMTime { activeFormat.maxExposureDuration }
+ var minISO: Float { activeFormat.minISO }
+ var maxISO: Float { activeFormat.maxISO }
+ var minFrameRate: Float64? { activeFormat.videoSupportedFrameRateRanges.first?.minFrameRate }
+ var maxFrameRate: Float64? { activeFormat.videoSupportedFrameRateRanges.first?.maxFrameRate }
+}
+
+// MARK: Getters & Setters
+extension AVCaptureDevice {
+ var lightMode: CameraLightMode {
+ get { torchMode == .off ? .off : .on }
+ set { torchMode = newValue == .off ? .off : .on }
+ }
+ var hdrMode: CameraHDRMode {
+ get {
+ if automaticallyAdjustsVideoHDREnabled { return .auto }
+ else if isVideoHDREnabled { return .on }
+ else { return .off }
+ }
+ set {
+ automaticallyAdjustsVideoHDREnabled = newValue == .auto
+ if newValue != .auto { isVideoHDREnabled = newValue == .on }
+ }
+ }
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+MockCaptureDevice.swift b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+MockCaptureDevice.swift
new file mode 100644
index 0000000..ed56d15
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice+MockCaptureDevice.swift
@@ -0,0 +1,62 @@
+//
+// CaptureDevice+MockCaptureDevice.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+class MockCaptureDevice: NSObject, CaptureDevice {
+ // MARK: Getters
+ var uniqueID: String = UUID().uuidString
+ var exposureDuration: CMTime { _exposureDuration }
+ var exposureTargetBias: Float { _exposureTargetBias }
+ var iso: Float { _iso }
+ var minAvailableVideoZoomFactor: CGFloat { 1 }
+ var maxAvailableVideoZoomFactor: CGFloat { 3.876 }
+ var minExposureDuration: CMTime { .init(value: 1, timescale: 1000) }
+ var maxExposureDuration: CMTime { .init(value: 1, timescale: 5) }
+ var minISO: Float { 1 }
+ var maxISO: Float { 10 }
+ var minExposureTargetBias: Float { 0.1 }
+ var maxExposureTargetBias: Float { 199 }
+ var minFrameRate: Float64? { 15 }
+ var maxFrameRate: Float64? { 60 }
+ var hasFlash: Bool { true }
+ var hasTorch: Bool { true }
+ var isExposurePointOfInterestSupported: Bool { true }
+ var isFocusPointOfInterestSupported: Bool { true }
+
+ // MARK: Setters
+ var videoZoomFactor: CGFloat = 1
+ var focusMode: AVCaptureDevice.FocusMode = .autoFocus
+ var focusPointOfInterest: CGPoint = .zero
+ var exposurePointOfInterest: CGPoint = .zero
+ var lightMode: CameraLightMode = .off
+ var activeVideoMinFrameDuration: CMTime = .init()
+ var activeVideoMaxFrameDuration: CMTime = .init()
+ var exposureMode: AVCaptureDevice.ExposureMode = .continuousAutoExposure
+ var hdrMode: CameraHDRMode = .auto
+
+ // MARK: Methods
+ func lockForConfiguration() throws { return }
+ func unlockForConfiguration() { return }
+ func isExposureModeSupported(_ exposureMode: AVCaptureDevice.ExposureMode) -> Bool { true }
+ func setExposureModeCustom(duration: CMTime, iso: Float, completionHandler: ((CMTime) -> Void)?) {
+ _exposureDuration = duration
+ _iso = iso
+ }
+ func setExposureTargetBias(_ bias: Float, completionHandler handler: ((CMTime) -> ())?) {
+ _exposureTargetBias = bias
+ }
+
+ // MARK: Private Attributes
+ private var _exposureDuration: CMTime = .init()
+ private var _exposureTargetBias: Float = 0
+ private var _iso: Float = 0
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice.swift b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice.swift
new file mode 100644
index 0000000..9ff7c49
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Device/CaptureDevice.swift
@@ -0,0 +1,131 @@
+//
+// CaptureDevice.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+protocol CaptureDevice: NSObject {
+ // MARK: Getters
+ var uniqueID: String { get }
+ var exposureDuration: CMTime { get }
+ var exposureTargetBias: Float { get }
+ var iso: Float { get }
+ var minAvailableVideoZoomFactor: CGFloat { get }
+ var maxAvailableVideoZoomFactor: CGFloat { get }
+ var minExposureDuration: CMTime { get }
+ var maxExposureDuration: CMTime { get }
+ var minISO: Float { get }
+ var maxISO: Float { get }
+ var minExposureTargetBias: Float { get }
+ var maxExposureTargetBias: Float { get }
+ var minFrameRate: Float64? { get }
+ var maxFrameRate: Float64? { get }
+ var hasFlash: Bool { get }
+ var hasTorch: Bool { get }
+ var isExposurePointOfInterestSupported: Bool { get }
+ var isFocusPointOfInterestSupported: Bool { get }
+
+ // MARK: Getters & Setters
+ var videoZoomFactor: CGFloat { get set }
+ var focusMode: AVCaptureDevice.FocusMode { get set }
+ var focusPointOfInterest: CGPoint { get set }
+ var exposurePointOfInterest: CGPoint { get set }
+ var lightMode: CameraLightMode { get set }
+ var activeVideoMinFrameDuration: CMTime { get set }
+ var activeVideoMaxFrameDuration: CMTime { get set }
+ var exposureMode: AVCaptureDevice.ExposureMode { get set }
+ var hdrMode: CameraHDRMode { get set }
+
+ // MARK: Methods
+ func lockForConfiguration() throws
+ func unlockForConfiguration()
+ func isExposureModeSupported(_ exposureMode: AVCaptureDevice.ExposureMode) -> Bool
+ func setExposureModeCustom(duration: CMTime, iso: Float, completionHandler: (@Sendable (CMTime) -> Void)?)
+ func setExposureTargetBias(_ bias: Float, completionHandler handler: (@Sendable (CMTime) -> ())?)
+}
+
+
+// MARK: - METHODS
+
+
+
+// MARK: Set Zoom Factor
+extension CaptureDevice {
+ func setZoomFactor(_ factor: CGFloat) {
+ let factor = max(min(factor, min(maxAvailableVideoZoomFactor, 5)), minAvailableVideoZoomFactor)
+ videoZoomFactor = factor
+ }
+}
+
+// MARK: Set Focus Point Of Interest
+extension CaptureDevice {
+ func setFocusPointOfInterest(_ point: CGPoint) {
+ guard isFocusPointOfInterestSupported else { return }
+
+ focusPointOfInterest = point
+ focusMode = .autoFocus
+ }
+}
+
+// MARK: Set Exposure Point Of Interest
+extension CaptureDevice {
+ func setExposurePointOfInterest(_ point: CGPoint) {
+ guard isExposurePointOfInterestSupported else { return }
+
+ exposurePointOfInterest = point
+ exposureMode = .autoExpose
+ }
+}
+
+// MARK: Set Light Mode
+extension CaptureDevice {
+ func setLightMode(_ mode: CameraLightMode) {
+ guard hasTorch else { return }
+ lightMode = mode
+ }
+}
+
+// MARK: Set Frame Rate
+extension CaptureDevice {
+ func setFrameRate(_ frameRate: Int32) {
+ guard let minFrameRate, let maxFrameRate else { return }
+
+ let frameRate = max(min(frameRate, Int32(maxFrameRate)), Int32(minFrameRate))
+
+ activeVideoMinFrameDuration = CMTime(value: 1, timescale: frameRate)
+ activeVideoMaxFrameDuration = CMTime(value: 1, timescale: frameRate)
+ }
+}
+
+// MARK: Set Exposure Mode
+extension CaptureDevice {
+ func setExposureMode(_ mode: AVCaptureDevice.ExposureMode, duration: CMTime, iso: Float) {
+ guard isExposureModeSupported(mode) else { return }
+
+ exposureMode = mode
+
+ guard mode == .custom else { return }
+
+ let duration = max(min(duration, maxExposureDuration), minExposureDuration)
+ let iso = max(min(iso, maxISO), minISO)
+
+ setExposureModeCustom(duration: duration, iso: iso, completionHandler: nil)
+ }
+}
+
+// MARK: Set Exposure Target Bias
+extension CaptureDevice {
+ func setExposureTargetBias(_ bias: Float) {
+ guard isExposureModeSupported(.custom) else { return }
+
+ let bias = max(min(bias, maxExposureTargetBias), minExposureTargetBias)
+ setExposureTargetBias(bias, completionHandler: nil)
+ }
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+AVCaptureSession.swift b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+AVCaptureSession.swift
new file mode 100644
index 0000000..e9c14b6
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+AVCaptureSession.swift
@@ -0,0 +1,45 @@
+//
+// CaptureSession+AVCaptureSession.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+extension AVCaptureSession: @unchecked @retroactive Sendable {}
+extension AVCaptureSession: CaptureSession {
+ var deviceInputs: [any CaptureDeviceInput] { inputs as? [any CaptureDeviceInput] ?? [] }
+}
+
+
+// MARK: - METHODS
+
+
+
+extension AVCaptureSession {
+ func stopRunningAndReturnNewInstance() -> any CaptureSession {
+ self.stopRunning()
+ return AVCaptureSession()
+ }
+}
+extension AVCaptureSession {
+ func add(input: (any CaptureDeviceInput)?) throws(MCameraError) {
+ guard let input = input as? AVCaptureDeviceInput else { throw .cannotSetupInput }
+ if canAddInput(input) { addInput(input) }
+ }
+ func remove(input: (any CaptureDeviceInput)?) {
+ guard let input = input as? AVCaptureDeviceInput else { return }
+ removeInput(input)
+ }
+}
+extension AVCaptureSession {
+ func add(output: AVCaptureOutput?) throws(MCameraError) {
+ guard let output else { throw .cannotSetupOutput }
+ if canAddOutput(output) { addOutput(output) }
+ }
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+MockCaptureSession.swift b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+MockCaptureSession.swift
new file mode 100644
index 0000000..7ce47e5
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession+MockCaptureSession.swift
@@ -0,0 +1,57 @@
+//
+// CaptureSession+MockCaptureSession.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+extension MockCaptureSession: @unchecked Sendable {}
+class MockCaptureSession: NSObject, CaptureSession { required override init() {}
+ // MARK: Attributes
+ var isRunning: Bool { _isRunning }
+ var deviceInputs: [any CaptureDeviceInput] { _deviceInputs }
+ var outputs: [AVCaptureOutput] { _outputs }
+ var sessionPreset: AVCaptureSession.Preset = .cif352x288
+
+ // MARK: Private Attributes
+ private var _isRunning: Bool = false
+ private var _deviceInputs: [any CaptureDeviceInput] = []
+ private var _outputs: [AVCaptureOutput] = []
+}
+
+
+// MARK: - METHODS
+
+
+
+extension MockCaptureSession {
+ func startRunning() { Task { @MainActor in
+ _isRunning = true
+ }}
+ func stopRunningAndReturnNewInstance() -> any CaptureSession {
+ _isRunning = false
+ return MockCaptureSession()
+ }
+}
+extension MockCaptureSession {
+ func add(input: (any CaptureDeviceInput)?) throws(MCameraError) {
+ guard let input = input as? MockDeviceInput, !_deviceInputs.contains(where: { input == $0 }) else { throw .cannotSetupInput }
+ _deviceInputs.append(input)
+ }
+ func remove(input: (any CaptureDeviceInput)?) {
+ guard let input = input as? MockDeviceInput, let index = _deviceInputs.firstIndex(where: { $0.device.uniqueID == input.device.uniqueID }) else { return }
+ _deviceInputs.remove(at: index)
+ }
+}
+extension MockCaptureSession {
+ func add(output: AVCaptureOutput?) throws(MCameraError) {
+ guard let output, !outputs.contains(output) else { throw .cannotSetupOutput }
+ _outputs.append(output)
+ }
+}
diff --git a/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession.swift b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession.swift
new file mode 100644
index 0000000..4bf2587
--- /dev/null
+++ b/Sources/Internal/Manager/Helpers/Capture Session/CaptureSession.swift
@@ -0,0 +1,27 @@
+//
+// CaptureSession.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+protocol CaptureSession: Sendable {
+ // MARK: Attributes
+ var isRunning: Bool { get }
+ var deviceInputs: [any CaptureDeviceInput] { get }
+ var outputs: [AVCaptureOutput] { get }
+ var sessionPreset: AVCaptureSession.Preset { get set }
+
+ // MARK: Methods
+ func startRunning()
+ func stopRunningAndReturnNewInstance() -> CaptureSession
+ func add(input: (any CaptureDeviceInput)?) throws(MCameraError)
+ func remove(input: (any CaptureDeviceInput)?)
+ func add(output: AVCaptureOutput?) throws(MCameraError)
+}
diff --git a/Sources/Internal/Miscellaneous/Typealiases.swift b/Sources/Internal/Miscellaneous/Typealiases.swift
new file mode 100644
index 0000000..5675600
--- /dev/null
+++ b/Sources/Internal/Miscellaneous/Typealiases.swift
@@ -0,0 +1,16 @@
+//
+// Typealiases.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+public typealias CameraScreenBuilder = @MainActor (CameraManager, Namespace.ID, _ closeMCameraAction: @escaping () -> ()) -> any MCameraScreen
+public typealias CapturedMediaScreenBuilder = @MainActor (MCameraMedia, Namespace.ID, _ retakeAction: @escaping () -> (), _ acceptMediaAction: @escaping () -> ()) -> any MCapturedMediaScreen
+public typealias ErrorScreenBuilder = @MainActor (MCameraError, _ closeMCameraAction: @escaping () -> ()) -> any MCameraErrorScreen
diff --git a/Sources/Internal/Models/CameraExposure.swift b/Sources/Internal/Models/CameraExposure.swift
new file mode 100644
index 0000000..55fb42a
--- /dev/null
+++ b/Sources/Internal/Models/CameraExposure.swift
@@ -0,0 +1,19 @@
+//
+// CameraExposure.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import AVKit
+
+struct CameraExposure {
+ var duration: CMTime = .zero
+ var targetBias: Float = 0
+ var iso: Float = 0
+ var mode: AVCaptureDevice.ExposureMode = .autoExpose
+}
diff --git a/Sources/Internal/Models/MCameraMedia.swift b/Sources/Internal/Models/MCameraMedia.swift
new file mode 100644
index 0000000..bd055ff
--- /dev/null
+++ b/Sources/Internal/Models/MCameraMedia.swift
@@ -0,0 +1,28 @@
+//
+// MCameraMedia.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+public struct MCameraMedia: Sendable {
+ let image: UIImage?
+ let video: URL?
+
+ init?(data: Any?) {
+ if let image = data as? UIImage { self.image = image; self.video = nil }
+ else if let video = data as? URL { self.video = video; self.image = nil }
+ else { return nil }
+ }
+}
+
+// MARK: Equatable
+extension MCameraMedia: Equatable {
+ public static func == (lhs: MCameraMedia, rhs: MCameraMedia) -> Bool { lhs.image == rhs.image && lhs.video == rhs.video }
+}
diff --git a/Sources/Internal/UI/Camera View/CameraView+Bridge.swift b/Sources/Internal/UI/Camera View/CameraView+Bridge.swift
new file mode 100644
index 0000000..e3b2f6a
--- /dev/null
+++ b/Sources/Internal/UI/Camera View/CameraView+Bridge.swift
@@ -0,0 +1,68 @@
+//
+// CameraView+Bridge.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+struct CameraBridgeView: UIViewRepresentable {
+ let cameraManager: CameraManager
+ let inputView: UIView = .init()
+}
+extension CameraBridgeView {
+ func makeUIView(context: Context) -> some UIView {
+ cameraManager.initialize(in: inputView)
+ setupTapGesture(context)
+ setupPinchGesture(context)
+ return inputView
+ }
+ func updateUIView(_ uiView: UIViewType, context: Context) {}
+ func makeCoordinator() -> Coordinator { .init(self) }
+}
+private extension CameraBridgeView {
+ func setupTapGesture(_ context: Context) {
+ let tapRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.onTapGesture))
+ inputView.addGestureRecognizer(tapRecognizer)
+ }
+ func setupPinchGesture(_ context: Context) {
+ let pinchRecognizer = UIPinchGestureRecognizer(target: context.coordinator, action: #selector(context.coordinator.onPinchGesture))
+ inputView.addGestureRecognizer(pinchRecognizer)
+ }
+}
+
+// MARK: Equatable
+extension CameraBridgeView: Equatable {
+ nonisolated static func ==(lhs: Self, rhs: Self) -> Bool { true }
+}
+
+
+// MARK: - GESTURES
+extension CameraBridgeView { class Coordinator: NSObject { init(_ parent: CameraBridgeView) { self.parent = parent }
+ let parent: CameraBridgeView
+}}
+
+// MARK: On Tap
+extension CameraBridgeView.Coordinator {
+ @MainActor @objc func onTapGesture(_ tap: UITapGestureRecognizer) {
+ do {
+ let touchPoint = tap.location(in: parent.inputView)
+ try parent.cameraManager.setCameraFocus(at: touchPoint)
+ } catch {}
+ }
+}
+
+// MARK: On Pinch
+extension CameraBridgeView.Coordinator {
+ @MainActor @objc func onPinchGesture(_ pinch: UIPinchGestureRecognizer) { if pinch.state == .changed {
+ do {
+ let desiredZoomFactor = parent.cameraManager.attributes.zoomFactor + atan2(pinch.velocity, 33)
+ try parent.cameraManager.setCameraZoomFactor(desiredZoomFactor)
+ } catch {}
+ }}
+}
diff --git a/Sources/Internal/UI/Camera View/CameraView+FocusIndicator.swift b/Sources/Internal/UI/Camera View/CameraView+FocusIndicator.swift
new file mode 100644
index 0000000..5c68200
--- /dev/null
+++ b/Sources/Internal/UI/Camera View/CameraView+FocusIndicator.swift
@@ -0,0 +1,33 @@
+//
+// CameraView+FocusIndicator.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+@MainActor class CameraFocusIndicatorView {
+ var image: UIImage = .init(resource: .mijickIconCrosshair)
+ var tintColor: UIColor = .init(resource: .mijickBackgroundYellow)
+ var size: CGFloat = 96
+}
+
+// MARK: Create
+extension CameraFocusIndicatorView {
+ func create(at touchPoint: CGPoint) -> UIImageView {
+ let focusIndicator = UIImageView(image: image)
+ focusIndicator.contentMode = .scaleAspectFit
+ focusIndicator.tintColor = tintColor
+ focusIndicator.frame.size = .init(width: size, height: size)
+ focusIndicator.frame.origin.x = touchPoint.x - size / 2
+ focusIndicator.frame.origin.y = touchPoint.y - size / 2
+ focusIndicator.transform = .init(scaleX: 0, y: 0)
+ focusIndicator.tag = .focusIndicatorTag
+ return focusIndicator
+ }
+}
diff --git a/Sources/Internal/UI/Camera View/CameraView+Grid.swift b/Sources/Internal/UI/Camera View/CameraView+Grid.swift
new file mode 100644
index 0000000..2648d79
--- /dev/null
+++ b/Sources/Internal/UI/Camera View/CameraView+Grid.swift
@@ -0,0 +1,81 @@
+//
+// CameraView+Grid.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+class CameraGridView: UIView {
+ var parent: CameraManager!
+}
+
+// MARK: Setup
+extension CameraGridView {
+ func setup(parent: CameraManager) {
+ self.parent = parent
+ self.alpha = parent.attributes.isGridVisible ? 1 : 0
+ self.addToParent(parent.cameraView)
+ }
+}
+
+// MARK: Set Visibility
+extension CameraGridView {
+ func setVisibility(_ isVisible: Bool) {
+ UIView.animate(withDuration: 0.2) { self.alpha = isVisible ? 1 : 0 }
+ parent.attributes.isGridVisible = isVisible
+ }
+}
+
+// MARK: Draw
+extension CameraGridView {
+ override func draw(_ rect: CGRect) {
+ clearOldLayersBeforeDraw()
+
+ let firstColumnPath = UIBezierPath()
+ firstColumnPath.move(to: CGPoint(x: bounds.width / 3, y: 0))
+ firstColumnPath.addLine(to: CGPoint(x: bounds.width / 3, y: bounds.height))
+ let firstColumnLayer = createGridLayer()
+ firstColumnLayer.path = firstColumnPath.cgPath
+ layer.addSublayer(firstColumnLayer)
+
+ let secondColumnPath = UIBezierPath()
+ secondColumnPath.move(to: CGPoint(x: (2 * bounds.width) / 3, y: 0))
+ secondColumnPath.addLine(to: CGPoint(x: (2 * bounds.width) / 3, y: bounds.height))
+ let secondColumnLayer = createGridLayer()
+ secondColumnLayer.path = secondColumnPath.cgPath
+ layer.addSublayer(secondColumnLayer)
+
+ let firstRowPath = UIBezierPath()
+ firstRowPath.move(to: CGPoint(x: 0, y: bounds.height / 3))
+ firstRowPath.addLine(to: CGPoint(x: bounds.width, y: bounds.height / 3))
+ let firstRowLayer = createGridLayer()
+ firstRowLayer.path = firstRowPath.cgPath
+ layer.addSublayer(firstRowLayer)
+
+ let secondRowPath = UIBezierPath()
+ secondRowPath.move(to: CGPoint(x: 0, y: ( 2 * bounds.height) / 3))
+ secondRowPath.addLine(to: CGPoint(x: bounds.width, y: ( 2 * bounds.height) / 3))
+ let secondRowLayer = createGridLayer()
+ secondRowLayer.path = secondRowPath.cgPath
+ layer.addSublayer(secondRowLayer)
+ }
+}
+private extension CameraGridView {
+ func clearOldLayersBeforeDraw() {
+ layer.sublayers?.removeAll()
+ layer.backgroundColor = .none
+ }
+ func createGridLayer() -> CAShapeLayer {
+ let shapeLayer = CAShapeLayer()
+ shapeLayer.strokeColor = UIColor(white: 1.0, alpha: 0.2).cgColor
+ shapeLayer.frame = bounds
+ shapeLayer.fillColor = nil
+ return shapeLayer
+ }
+}
diff --git a/Sources/Internal/UI/Camera View/CameraView+Metal.swift b/Sources/Internal/UI/Camera View/CameraView+Metal.swift
new file mode 100644
index 0000000..ed65653
--- /dev/null
+++ b/Sources/Internal/UI/Camera View/CameraView+Metal.swift
@@ -0,0 +1,279 @@
+//
+// CameraView+Metal.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+import MetalKit
+import AVKit
+
+@MainActor class CameraMetalView: MTKView {
+ private(set) var parent: CameraManager!
+ private(set) var ciContext: CIContext!
+ private(set) var commandQueue: MTLCommandQueue!
+ private(set) var currentFrame: CIImage?
+ private(set) var focusIndicator: CameraFocusIndicatorView = .init()
+ private(set) var isAnimating: Bool = false
+}
+
+// MARK: Setup
+extension CameraMetalView {
+ func setup(parent: CameraManager) throws(MCameraError) {
+ guard let metalDevice = MTLCreateSystemDefaultDevice() else { throw .cannotSetupMetalDevice }
+
+ self.assignInitialValues(parent: parent, metalDevice: metalDevice)
+ self.configureMetalView(metalDevice: metalDevice)
+ self.addToParent(parent.cameraView)
+ }
+}
+private extension CameraMetalView {
+ func assignInitialValues(parent: CameraManager, metalDevice: MTLDevice) {
+ self.parent = parent
+ self.ciContext = CIContext(mtlDevice: metalDevice)
+ self.commandQueue = metalDevice.makeCommandQueue()
+ }
+ func configureMetalView(metalDevice: MTLDevice) {
+ self.parent.cameraView.alpha = 0
+
+ self.delegate = self
+ self.device = metalDevice
+ self.isPaused = true
+ self.enableSetNeedsDisplay = false
+ self.framebufferOnly = false
+ self.autoResizeDrawable = false
+ self.contentMode = .scaleAspectFill
+ self.clipsToBounds = true
+ }
+}
+
+
+// MARK: - ANIMATIONS
+
+
+
+// MARK: Camera Entrance
+extension CameraMetalView {
+ func performCameraEntranceAnimation() { UIView.animate(withDuration: 0.33) { [self] in
+ parent.cameraView.alpha = 1
+ }}
+}
+
+// MARK: Image Capture
+extension CameraMetalView {
+ func performImageCaptureAnimation() {
+ let blackMatte = createBlackMatte()
+
+ parent.cameraView.addSubview(blackMatte)
+ animateBlackMatte(blackMatte)
+ }
+
+ /// Shows screen flash for front camera, calls completion when ready to capture
+ func performScreenFlash(completion: @escaping @MainActor () -> Void) {
+ guard let cameraView = parent?.cameraView else {
+ completion()
+ return
+ }
+
+ let flashColor = parent.attributes.screenFlashColor ?? .white
+ let flashView = createScreenFlashView(color: flashColor)
+ let originalBrightness = UIScreen.main.brightness
+
+ // Add flash overlay to the window for full screen coverage
+ if let window = cameraView.window {
+ flashView.frame = window.bounds
+ window.addSubview(flashView)
+ } else {
+ flashView.frame = cameraView.bounds
+ cameraView.addSubview(flashView)
+ }
+
+ // Boost brightness and show flash
+ UIScreen.main.brightness = 1.0
+ UIView.animate(withDuration: 0.05, animations: {
+ flashView.alpha = 1.0
+ }) { _ in
+ // Small delay for camera to adjust to new lighting, then capture
+ Task { @MainActor in
+ try? await Task.sleep(for: .milliseconds(100))
+ completion()
+
+ // Fade out flash after capture is triggered
+ try? await Task.sleep(for: .milliseconds(150))
+ UIView.animate(withDuration: 0.2, animations: {
+ flashView.alpha = 0
+ }) { _ in
+ flashView.removeFromSuperview()
+ UIScreen.main.brightness = originalBrightness
+ }
+ }
+ }
+ }
+}
+private extension CameraMetalView {
+ func createBlackMatte() -> UIView {
+ let view = UIView()
+ view.frame = parent.cameraView.frame
+ view.backgroundColor = .init(resource: .mijickBackgroundPrimary)
+ view.alpha = 0
+ return view
+ }
+ func animateBlackMatte(_ view: UIView) {
+ UIView.animate(withDuration: 0.16, animations: { view.alpha = 1 }) { _ in
+ UIView.animate(withDuration: 0.16, animations: { view.alpha = 0 }) { _ in
+ view.removeFromSuperview()
+ }
+ }
+ }
+ func createScreenFlashView(color: UIColor) -> UIView {
+ let view = UIView()
+ view.frame = UIScreen.main.bounds
+ view.backgroundColor = color
+ view.alpha = 0
+ return view
+ }
+}
+
+// MARK: Camera Flip
+extension CameraMetalView {
+ func beginCameraFlipAnimation() async {
+ let snapshot = createSnapshot()
+ isAnimating = true
+ insertBlurView(snapshot)
+ animateBlurFlip()
+
+ await Task.sleep(seconds: 0.01)
+ }
+ func finishCameraFlipAnimation() async {
+ guard let blurView = parent.cameraView.viewWithTag(.blurViewTag) else { return }
+
+ await Task.sleep(seconds: 0.44)
+ UIView.animate(withDuration: 0.3, animations: { blurView.alpha = 0 }) { [self] _ in
+ blurView.removeFromSuperview()
+ isAnimating = false
+ }
+ }
+}
+private extension CameraMetalView {
+ func createSnapshot() -> UIImage? {
+ guard let currentFrame else { return nil }
+
+ let image = UIImage(ciImage: currentFrame)
+ return image
+ }
+ func insertBlurView(_ snapshot: UIImage?) {
+ let blurView = UIImageView(frame: parent.cameraView.frame)
+ blurView.image = snapshot
+ blurView.contentMode = .scaleAspectFill
+ blurView.clipsToBounds = true
+ blurView.tag = .blurViewTag
+ blurView.applyBlurEffect(style: .regular)
+
+ parent.cameraView.addSubview(blurView)
+ }
+ func animateBlurFlip() {
+ UIView.transition(with: parent.cameraView, duration: 0.44, options: cameraFlipAnimationTransition) {}
+ }
+}
+private extension CameraMetalView {
+ var cameraFlipAnimationTransition: UIView.AnimationOptions { parent.attributes.cameraPosition == .back ? .transitionFlipFromLeft : .transitionFlipFromRight }
+}
+
+// MARK: Camera Focus
+extension CameraMetalView {
+ func performCameraFocusAnimation(touchPoint: CGPoint) {
+ removeExistingFocusIndicatorAnimations()
+
+ let focusIndicator = focusIndicator.create(at: touchPoint)
+ parent.cameraView.addSubview(focusIndicator)
+ animateFocusIndicator(focusIndicator)
+ }
+}
+private extension CameraMetalView {
+ func removeExistingFocusIndicatorAnimations() { if let view = parent.cameraView.viewWithTag(.focusIndicatorTag) {
+ view.removeFromSuperview()
+ }}
+ func animateFocusIndicator(_ focusIndicator: UIImageView) {
+ UIView.animate(withDuration: 0.44, delay: 0, usingSpringWithDamping: 0.6, initialSpringVelocity: 0, animations: { focusIndicator.transform = .init(scaleX: 1, y: 1) }) { _ in
+ UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0.2 }) { _ in
+ UIView.animate(withDuration: 0.44, delay: 1.44, animations: { focusIndicator.alpha = 0 })
+ }
+ }
+ }
+}
+
+// MARK: Camera Orientation
+extension CameraMetalView {
+ func beginCameraOrientationAnimation(if shouldAnimate: Bool) async { if shouldAnimate {
+ parent.cameraView.alpha = 0
+ await Task.sleep(seconds: 0.1)
+ }}
+ func finishCameraOrientationAnimation(if shouldAnimate: Bool) { if shouldAnimate {
+ UIView.animate(withDuration: 0.2, delay: 0.1) { self.parent.cameraView.alpha = 1 }
+ }}
+}
+
+
+// MARK: - CAPTURING FRAMES
+
+
+
+// MARK: Capture
+extension CameraMetalView: @preconcurrency AVCaptureVideoDataOutputSampleBufferDelegate {
+ func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
+ guard let cvImageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
+
+ let currentFrame = captureCurrentFrame(cvImageBuffer)
+ let currentFrameWithFiltersApplied = applyingFiltersToCurrentFrame(currentFrame)
+ redrawCameraView(currentFrameWithFiltersApplied)
+ }
+}
+private extension CameraMetalView {
+ func captureCurrentFrame(_ cvImageBuffer: CVImageBuffer) -> CIImage {
+ let currentFrame = CIImage(cvImageBuffer: cvImageBuffer)
+ return currentFrame.oriented(parent.attributes.frameOrientation)
+ }
+ func applyingFiltersToCurrentFrame(_ currentFrame: CIImage) -> CIImage {
+ currentFrame.applyingFilters(parent.attributes.cameraFilters)
+ }
+ func redrawCameraView(_ frame: CIImage) {
+ currentFrame = frame
+ draw()
+ }
+}
+
+// MARK: Draw
+extension CameraMetalView: MTKViewDelegate {
+ func draw(in view: MTKView) {
+ guard let commandBuffer = commandQueue.makeCommandBuffer(),
+ let ciImage = currentFrame,
+ let currentDrawable = view.currentDrawable
+ else { return }
+
+ changeDrawableSize(view, ciImage)
+ renderView(view, currentDrawable, commandBuffer, ciImage)
+ commitBuffer(currentDrawable, commandBuffer)
+ }
+ func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
+}
+private extension CameraMetalView {
+ func changeDrawableSize(_ view: MTKView, _ ciImage: CIImage) {
+ view.drawableSize = ciImage.extent.size
+ }
+ func renderView(_ view: MTKView, _ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer, _ ciImage: CIImage) { ciContext.render(
+ ciImage,
+ to: currentDrawable.texture,
+ commandBuffer: commandBuffer,
+ bounds: .init(origin: .zero, size: view.drawableSize),
+ colorSpace: CGColorSpaceCreateDeviceRGB()
+ )}
+ func commitBuffer(_ currentDrawable: any CAMetalDrawable, _ commandBuffer: any MTLCommandBuffer) {
+ commandBuffer.present(currentDrawable)
+ commandBuffer.commit()
+ }
+}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+BottomBar.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+BottomBar.swift
new file mode 100644
index 0000000..089c7e9
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+BottomBar.swift
@@ -0,0 +1,97 @@
+//
+// DefaultCameraScreen+BottomBar.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+extension DefaultCameraScreen { struct BottomBar: View {
+ let parent: DefaultCameraScreen
+
+
+ var body: some View {
+ ZStack(alignment: .top) {
+ createOutputTypeSwitch()
+ createButtons()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.bottom, 44)
+ .padding(.horizontal, 32)
+ }
+}}
+private extension DefaultCameraScreen.BottomBar {
+ @ViewBuilder func createOutputTypeSwitch() -> some View { if isOutputTypeSwitchActive {
+ DefaultCameraScreen.CameraOutputSwitch(parent: parent)
+ .offset(y: -80)
+ }}
+ func createButtons() -> some View {
+ ZStack {
+ createLightButton()
+ createCaptureButton()
+ createChangeCameraPositionButton()
+ }.frame(height: 72)
+ }
+}
+private extension DefaultCameraScreen.BottomBar {
+ @ViewBuilder func createLightButton() -> some View { if isLightButtonActive {
+ BottomButton(
+ icon: .mijickIconLight,
+ iconColor: lightButtonIconColor,
+ backgroundColor: .init(.mijickBackgroundSecondary),
+ rotationAngle: parent.iconAngle,
+ action: changeLightMode
+ )
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .transition(.scale)
+ }}
+ @ViewBuilder func createCaptureButton() -> some View { if isCaptureButtonActive {
+ DefaultCameraScreen.CaptureButton(
+ outputType: parent.cameraOutputType,
+ isRecording: parent.isRecording,
+ action: parent.captureOutput
+ )
+ .transition(.scale)
+ }}
+ @ViewBuilder func createChangeCameraPositionButton() -> some View { if isChangeCameraPositionButtonActive {
+ BottomButton(
+ icon: .mijickIconChangeCamera,
+ iconColor: changeCameraPositionButtonIconColor,
+ backgroundColor: .init(.mijickBackgroundSecondary),
+ rotationAngle: parent.iconAngle,
+ action: changeCameraPosition
+ )
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ .transition(.scale)
+ }}
+}
+
+private extension DefaultCameraScreen.BottomBar {
+ func changeLightMode() {
+ do { try parent.setLightMode(parent.lightMode.next()) }
+ catch {}
+ }
+ func changeCameraPosition() { Task {
+ do { try await parent.setCameraPosition(parent.cameraPosition.next()) }
+ catch {}
+ }}
+}
+
+private extension DefaultCameraScreen.BottomBar {
+ var lightButtonIconColor: Color { switch parent.lightMode {
+ case .on: .init(.mijickBackgroundYellow)
+ case .off: .init(.mijickBackgroundInverted)
+ }}
+ var changeCameraPositionButtonIconColor: Color { .init(.mijickBackgroundInverted) }
+}
+private extension DefaultCameraScreen.BottomBar {
+ var isOutputTypeSwitchActive: Bool { parent.config.cameraOutputSwitchAllowed && parent.cameraManager.captureSession.isRunning && !parent.isRecording }
+ var isLightButtonActive: Bool { parent.config.lightButtonAllowed && parent.hasLight && parent.cameraManager.captureSession.isRunning && !parent.isRecording }
+ var isCaptureButtonActive: Bool { parent.config.captureButtonAllowed && parent.cameraManager.captureSession.isRunning }
+ var isChangeCameraPositionButtonActive: Bool { parent.config.cameraPositionButtonAllowed && parent.cameraManager.captureSession.isRunning && !parent.isRecording }
+}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+ButtonScaleStyle.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+ButtonScaleStyle.swift
new file mode 100644
index 0000000..be11197
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+ButtonScaleStyle.swift
@@ -0,0 +1,19 @@
+//
+// DefaultCameraScreen+ButtonScaleStyle.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+struct ButtonScaleStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View { configuration
+ .label
+ .scaleEffect(configuration.isPressed ? 0.96 : 1)
+ }
+}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CameraOutputSwitch.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CameraOutputSwitch.swift
new file mode 100644
index 0000000..1ff1822
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CameraOutputSwitch.swift
@@ -0,0 +1,79 @@
+//
+// DefaultCameraScreen+CameraOutputSwitch.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+extension DefaultCameraScreen { struct CameraOutputSwitch: View {
+ let parent: DefaultCameraScreen
+
+
+ var body: some View {
+ HStack(spacing: 4) {
+ createOutputTypeButton(.video)
+ createOutputTypeButton(.photo)
+ }
+ .padding(8)
+ .background(Color(.mijickBackgroundPrimary50))
+ .mask(Capsule())
+ }
+}}
+private extension DefaultCameraScreen.CameraOutputSwitch {
+ func createOutputTypeButton(_ outputType: CameraOutputType) -> some View {
+ Button(icon: getOutputTypeButtonIcon(outputType), active: isOutputTypeButtonActive(outputType)) {
+ parent.setOutputType(outputType)
+ }
+ .rotationEffect(parent.iconAngle)
+ }
+}
+
+private extension DefaultCameraScreen.CameraOutputSwitch {
+ func getOutputTypeButtonIcon(_ outputType: CameraOutputType) -> ImageResource { switch outputType {
+ case .photo: return .mijickIconPhoto
+ case .video: return .mijickIconVideo
+ }}
+ func isOutputTypeButtonActive(_ outputType: CameraOutputType) -> Bool {
+ outputType == parent.cameraOutputType
+ }
+}
+
+
+// MARK: Button
+fileprivate struct Button: View {
+ let icon: ImageResource
+ let active: Bool
+ let action: () -> ()
+
+
+ var body: some View {
+ SwiftUI.Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle())
+ }
+}
+private extension Button {
+ func createButtonLabel() -> some View {
+ Image(icon)
+ .resizable()
+ .frame(width: iconSize, height: iconSize)
+ .foregroundColor(iconColor)
+ .padding(8)
+ .background(Color(.mijickBackgroundSecondary))
+ .mask(Circle())
+ }
+}
+private extension Button {
+ var iconSize: CGFloat { switch active {
+ case true: 28
+ case false: 20
+ }}
+ var iconColor: Color { switch active {
+ case true: .init(.mijickBackgroundYellow)
+ case false: .init(.mijickTextTertiary)
+ }}
+}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CaptureButton.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CaptureButton.swift
new file mode 100644
index 0000000..9ced158
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+CaptureButton.swift
@@ -0,0 +1,55 @@
+//
+// DefaultCameraScreen+CaptureButton.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+extension DefaultCameraScreen { struct CaptureButton: View {
+ let outputType: CameraOutputType
+ let isRecording: Bool
+ let action: () -> ()
+
+
+ var body: some View {
+ Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle())
+ }
+}}
+private extension DefaultCameraScreen.CaptureButton {
+ func createButtonLabel() -> some View {
+ ZStack {
+ createBackground()
+ createBorders()
+ }.frame(width: 72, height: 72)
+ }
+}
+private extension DefaultCameraScreen.CaptureButton {
+ func createBackground() -> some View {
+ RoundedRectangle(cornerRadius: backgroundCornerRadius, style: .continuous)
+ .fill(backgroundColor)
+ .padding(backgroundPadding)
+ }
+ func createBorders() -> some View {
+ Circle().stroke(Color(.mijickBackgroundInverted), lineWidth: 2.5)
+ }
+}
+private extension DefaultCameraScreen.CaptureButton {
+ var backgroundColor: Color { switch outputType {
+ case .photo: .init(.mijickBackgroundInverted)
+ case .video: .init(.mijickBackgroundRed)
+ }}
+ var backgroundCornerRadius: CGFloat { switch isRecording {
+ case true: 6
+ case false: 36
+ }}
+ var backgroundPadding: CGFloat { switch isRecording {
+ case true: 20
+ case false: 4
+ }}
+}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+Config.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+Config.swift
new file mode 100644
index 0000000..17c19f1
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+Config.swift
@@ -0,0 +1,23 @@
+//
+// DefaultCameraScreen+Config.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Foundation
+
+extension DefaultCameraScreen { class Config {
+ var captureButtonAllowed: Bool = true
+ var cameraOutputSwitchAllowed: Bool = true
+ var cameraPositionButtonAllowed: Bool = true
+ var flashButtonAllowed: Bool = true
+ var lightButtonAllowed: Bool = true
+ var flipButtonAllowed: Bool = true
+ var gridButtonAllowed: Bool = true
+ var closeButtonAllowed: Bool = true
+}}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopBar.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopBar.swift
new file mode 100644
index 0000000..f4d1940
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopBar.swift
@@ -0,0 +1,116 @@
+//
+// DefaultCameraScreen+TopBar.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+extension DefaultCameraScreen { struct TopBar: View {
+ let parent: DefaultCameraScreen
+
+
+ var body: some View { if isTopBarActive {
+ ZStack {
+ createCloseButton()
+ createCentralView()
+ createRightSideView()
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.top, topPadding)
+ .padding(.bottom, 8)
+ .padding(.horizontal, 20)
+ .background(Color(.mijickBackgroundPrimary80))
+ .transition(.move(edge: .top))
+ }}
+}}
+private extension DefaultCameraScreen.TopBar {
+ @ViewBuilder func createCloseButton() -> some View { if isCloseButtonActive {
+ CloseButton(action: parent.closeMCameraAction)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }}
+ @ViewBuilder func createCentralView() -> some View { if isCentralViewActive {
+ Text(parent.recordingTime.toString())
+ .font(.system(size: 20, weight: .medium))
+ .foregroundColor(.init(.mijickTextPrimary))
+ }}
+ @ViewBuilder func createRightSideView() -> some View { if isRightSideViewActive {
+ HStack(spacing: 12) {
+ createGridButton()
+ createFlipOutputButton()
+ createFlashButton()
+ }
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }}
+}
+private extension DefaultCameraScreen.TopBar {
+ @ViewBuilder func createGridButton() -> some View { if isGridButtonActive {
+ DefaultCameraScreen.TopButton(
+ icon: gridButtonIcon,
+ iconRotationAngle: parent.iconAngle,
+ action: changeGridVisibility
+ )
+ }}
+ @ViewBuilder func createFlipOutputButton() -> some View { if isFlipOutputButtonActive {
+ DefaultCameraScreen.TopButton(
+ icon: flipButtonIcon,
+ iconRotationAngle: parent.iconAngle,
+ action: changeMirrorOutput
+ )
+ }}
+ @ViewBuilder func createFlashButton() -> some View { if isFlashButtonActive {
+ DefaultCameraScreen.TopButton(
+ icon: flashButtonIcon,
+ iconRotationAngle: parent.iconAngle,
+ action: changeFlashMode
+ )
+ }}
+}
+
+private extension DefaultCameraScreen.TopBar {
+ func changeGridVisibility() {
+ parent.setGridVisibility(!parent.isGridVisible)
+ }
+ func changeMirrorOutput() {
+ parent.setMirrorOutput(!parent.isOutputMirrored)
+ }
+ func changeFlashMode() {
+ parent.setFlashMode(parent.flashMode.next())
+ }
+}
+
+private extension DefaultCameraScreen.TopBar {
+ var topPadding: CGFloat { switch parent.deviceOrientation {
+ case .portrait, .portraitUpsideDown: return 40
+ default: return 20
+ }}
+}
+private extension DefaultCameraScreen.TopBar {
+ var gridButtonIcon: ImageResource { switch parent.isGridVisible {
+ case true: .mijickIconGridOn
+ case false: .mijickIconGridOff
+ }}
+ var flipButtonIcon: ImageResource { switch parent.isOutputMirrored {
+ case true: .mijickIconFlipOn
+ case false: .mijickIconFlipOff
+ }}
+ var flashButtonIcon: ImageResource { switch parent.flashMode {
+ case .off: .mijickIconFlashOff
+ case .on: .mijickIconFlashOn
+ case .auto: .mijickIconFlashAuto
+ }}
+}
+private extension DefaultCameraScreen.TopBar {
+ var isTopBarActive: Bool { parent.cameraManager.captureSession.isRunning }
+ var isCloseButtonActive: Bool { parent.config.closeButtonAllowed && !parent.isRecording }
+ var isCentralViewActive: Bool { parent.isRecording }
+ var isRightSideViewActive: Bool { !parent.isRecording }
+ var isGridButtonActive: Bool { parent.config.gridButtonAllowed }
+ var isFlipOutputButtonActive: Bool { parent.config.flipButtonAllowed && parent.cameraPosition == .front }
+ var isFlashButtonActive: Bool { parent.config.flashButtonAllowed && parent.hasFlash && parent.cameraOutputType == .photo }
+}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopButton.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopButton.swift
new file mode 100644
index 0000000..bf3037b
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen+TopButton.swift
@@ -0,0 +1,35 @@
+//
+// DefaultCameraScreen+TopButton.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+extension DefaultCameraScreen { struct TopButton: View {
+ let icon: ImageResource
+ let iconRotationAngle: Angle
+ let action: () -> ()
+
+
+ var body: some View {
+ Button(action: action, label: createButtonLabel)
+ }
+}}
+private extension DefaultCameraScreen.TopButton {
+ func createButtonLabel() -> some View {
+ Image(icon)
+ .resizable()
+ .frame(width: 16, height: 16)
+ .foregroundColor(Color(.mijickBackgroundInverted))
+ .rotationEffect(iconRotationAngle)
+ .frame(width: 32, height: 32)
+ .background(Color(.mijickBackgroundSecondary))
+ .mask(Circle())
+ }
+}
diff --git a/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen.swift b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen.swift
new file mode 100644
index 0000000..3a3313a
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Camera/DefaultCameraScreen.swift
@@ -0,0 +1,54 @@
+//
+// DefaultCameraScreen.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+public struct DefaultCameraScreen: MCameraScreen {
+ @ObservedObject public var cameraManager: CameraManager
+ public let namespace: Namespace.ID
+ public let closeMCameraAction: () -> ()
+ var config: Config = .init()
+
+
+ public var body: some View {
+ ZStack {
+ createContentView()
+ createTopBar()
+ createBottomBar()
+ }
+ .ignoresSafeArea()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(.mijickBackgroundPrimary).ignoresSafeArea())
+ .statusBarHidden()
+ .animation(.mSpring)
+ }
+}
+private extension DefaultCameraScreen {
+ func createTopBar() -> some View {
+ DefaultCameraScreen.TopBar(parent: self)
+ .frame(maxHeight: .infinity, alignment: .top)
+ }
+ func createContentView() -> some View {
+ createCameraOutputView()
+ .ignoresSafeArea()
+ }
+ func createBottomBar() -> some View {
+ DefaultCameraScreen.BottomBar(parent: self)
+ .frame(maxHeight: .infinity, alignment: .bottom)
+ }
+}
+
+extension DefaultCameraScreen {
+ var iconAngle: Angle { switch isOrientationLocked {
+ case true: deviceOrientation.getAngle()
+ case false: .zero
+ }}
+}
diff --git a/Sources/Internal/UI/Default Screens/Captured Media/DefaultCapturedMediaScreen.swift b/Sources/Internal/UI/Default Screens/Captured Media/DefaultCapturedMediaScreen.swift
new file mode 100644
index 0000000..1c3fbfa
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Captured Media/DefaultCapturedMediaScreen.swift
@@ -0,0 +1,90 @@
+//
+// DefaultCapturedMediaScreen.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+import AVKit
+
+struct DefaultCapturedMediaScreen: MCapturedMediaScreen {
+ let capturedMedia: MCameraMedia
+ let namespace: Namespace.ID
+ let retakeAction: () -> ()
+ let acceptMediaAction: () -> ()
+ @State private var player: AVPlayer = .init()
+ @State private var isInitialized: Bool = false
+
+
+ var body: some View {
+ ZStack {
+ createContentView()
+ createButtons()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(.mijickBackgroundPrimary).ignoresSafeArea())
+ .animation(.mSpring, value: isInitialized)
+ .onAppear { isInitialized = true }
+ }
+}
+private extension DefaultCapturedMediaScreen {
+ @ViewBuilder func createContentView() -> some View { if isInitialized {
+ if let image = capturedMedia.getImage() { createImageView(image) }
+ else if let video = capturedMedia.getVideo() { createVideoView(video) }
+ }}
+ func createButtons() -> some View {
+ HStack(spacing: 32) {
+ createRetakeButton()
+ createSaveButton()
+ }
+ .padding(.top, 12)
+ .padding(.bottom, 4)
+ .frame(maxHeight: .infinity, alignment: .bottom)
+ .padding(.bottom, 8)
+ }
+}
+private extension DefaultCapturedMediaScreen {
+ func createImageView(_ image: UIImage) -> some View {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .ignoresSafeArea()
+ .transition(.scale(scale: 1.1))
+ }
+ func createVideoView(_ video: URL) -> some View {
+ VideoPlayer(player: player)
+ .onAppear { onVideoAppear(video) }
+ }
+ @ViewBuilder func createRetakeButton() -> some View { if isInitialized {
+ BottomButton(
+ icon: .mijickIconCancel,
+ iconColor: .init(.mijickBackgroundInverted),
+ backgroundColor: .init(.mijickBackgroundSecondary),
+ rotationAngle: .zero,
+ action: retakeAction
+ )
+ .transition(.scale)
+ }}
+ @ViewBuilder func createSaveButton() -> some View { if isInitialized {
+ BottomButton(
+ icon: .mijickIconCheck,
+ iconColor: .init(.mijickBackgroundPrimary),
+ backgroundColor: .init(.mijickBackgroundInverted),
+ rotationAngle: .zero,
+ action: acceptMediaAction
+ )
+ .transition(.scale)
+ }}
+}
+
+private extension DefaultCapturedMediaScreen {
+ func onVideoAppear(_ url: URL) {
+ player = .init(url: url)
+ player.play()
+ }
+}
diff --git a/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+BottomButton.swift b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+BottomButton.swift
new file mode 100644
index 0000000..db3a1c1
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+BottomButton.swift
@@ -0,0 +1,37 @@
+//
+// DefaultScreen+BottomButton.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+struct BottomButton: View {
+ let icon: ImageResource
+ let iconColor: Color
+ let backgroundColor: Color
+ let rotationAngle: Angle
+ let action: () -> ()
+
+
+ var body: some View {
+ Button(action: action, label: createButtonLabel).buttonStyle(ButtonScaleStyle())
+ }
+}
+private extension BottomButton {
+ func createButtonLabel() -> some View {
+ Image(icon)
+ .resizable()
+ .frame(width: 26, height: 26)
+ .foregroundColor(iconColor)
+ .rotationEffect(rotationAngle)
+ .frame(width: 52, height: 52)
+ .background(backgroundColor)
+ .mask(Circle())
+ }
+}
diff --git a/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+CloseButton.swift b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+CloseButton.swift
new file mode 100644
index 0000000..7b7746e
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Common Components/DefaultScreen+CloseButton.swift
@@ -0,0 +1,29 @@
+//
+// DefaultScreen+CloseButton.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+struct CloseButton: View {
+ let action: () -> ()
+
+
+ var body: some View {
+ Button(action: action, label: createButtonLabel)
+ }
+}
+private extension CloseButton {
+ func createButtonLabel() -> some View {
+ Image(.mijickIconCancel)
+ .resizable()
+ .frame(width: 24, height: 24)
+ .foregroundColor(Color(.mijickBackgroundInverted))
+ }
+}
diff --git a/Sources/Internal/UI/Default Screens/Error/DefaultCameraErrorScreen.swift b/Sources/Internal/UI/Default Screens/Error/DefaultCameraErrorScreen.swift
new file mode 100644
index 0000000..1f3aaa2
--- /dev/null
+++ b/Sources/Internal/UI/Default Screens/Error/DefaultCameraErrorScreen.swift
@@ -0,0 +1,79 @@
+//
+// DefaultCameraErrorScreen.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+struct DefaultCameraErrorScreen: MCameraErrorScreen {
+ let error: MCameraError
+ let closeMCameraAction: () -> ()
+
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Spacer().frame(height: 8)
+ createCloseButton()
+ Spacer()
+ createTitle()
+ Spacer().frame(height: 16)
+ createDescription()
+ Spacer().frame(height: 32)
+ createOpenSettingsButton()
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color(.mijickBackgroundPrimary).ignoresSafeArea())
+ }
+}
+private extension DefaultCameraErrorScreen {
+ func createCloseButton() -> some View {
+ CloseButton(action: closeMCameraAction)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.leading, 20)
+ }
+ func createTitle() -> some View {
+ Text(title)
+ .font(.system(size: 20, weight: .bold))
+ .foregroundColor(.init(.mijickTextPrimary))
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, 64)
+ }
+ func createDescription() -> some View {
+ Text(description)
+ .font(.system(size: 16, weight: .regular))
+ .foregroundColor(.init(.mijickTextSecondary))
+ .lineSpacing(4)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.horizontal, 32)
+ }
+ func createOpenSettingsButton() -> some View {
+ Button(action: openAppSettings) {
+ Text(openSettingsButton)
+ .font(.system(size: 16, weight: .bold))
+ .foregroundColor(Color(.mijickTextBrand))
+ }
+ }
+}
+
+private extension DefaultCameraErrorScreen {
+ var title: String { switch error {
+ case .microphonePermissionsNotGranted: NSLocalizedString("Enable Microphone Access", comment: "")
+ case .cameraPermissionsNotGranted: NSLocalizedString("Enable Camera Access", comment: "")
+ default: ""
+ }}
+ var description: String { switch error {
+ case .microphonePermissionsNotGranted: Bundle.main.infoDictionary?["NSMicrophoneUsageDescription"] as? String ?? ""
+ case .cameraPermissionsNotGranted: Bundle.main.infoDictionary?["NSCameraUsageDescription"] as? String ?? ""
+ default: ""
+ }}
+ var openSettingsButton: String { NSLocalizedString("Open Settings", comment: "") }
+}
diff --git a/Sources/Internal/UI/MCamera/MCamera+Config.swift b/Sources/Internal/UI/MCamera/MCamera+Config.swift
new file mode 100644
index 0000000..3e1f3b9
--- /dev/null
+++ b/Sources/Internal/UI/MCamera/MCamera+Config.swift
@@ -0,0 +1,28 @@
+//
+// MCamera+Config.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+extension MCamera { @MainActor class Config {
+ // MARK: Screens
+ var cameraScreen: CameraScreenBuilder = DefaultCameraScreen.init
+ var capturedMediaScreen: CapturedMediaScreenBuilder? = DefaultCapturedMediaScreen.init
+ var errorScreen: ErrorScreenBuilder = DefaultCameraErrorScreen.init
+
+ // MARK: Actions
+ var imageCapturedAction: (UIImage, MCamera.Controller) -> () = { _,_ in }
+ var videoCapturedAction: (URL, MCamera.Controller) -> () = { _,_ in }
+ var closeMCameraAction: () -> () = {}
+
+ // MARK: Others
+ var appDelegate: MApplicationDelegate.Type? = nil
+ var isCameraConfigured: Bool = false
+}}
diff --git a/Sources/Internal/UI/MCamera/MCamera+Controller.swift b/Sources/Internal/UI/MCamera/MCamera+Controller.swift
new file mode 100644
index 0000000..5c07b57
--- /dev/null
+++ b/Sources/Internal/UI/MCamera/MCamera+Controller.swift
@@ -0,0 +1,16 @@
+//
+// MCamera+Controller.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Foundation
+
+extension MCamera { @MainActor public struct Controller {
+ let mCamera: MCamera
+}}
diff --git a/Sources/Internal/UI/MCamera/MCamera.swift b/Sources/Internal/UI/MCamera/MCamera.swift
new file mode 100644
index 0000000..b807377
--- /dev/null
+++ b/Sources/Internal/UI/MCamera/MCamera.swift
@@ -0,0 +1,181 @@
+//
+// MCamera.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+/**
+ A view that displays a camera with state-specific screens.
+
+ By default, it includes three screens that change depending on the status of the camera; **Error Screen**, **Camera Screen** and **Captured Media Screen**.
+
+ Handles issues related to asking for permissions, and if permissions are not granted, it displays the **Error Screen**.
+
+ Optionally shows the **Captured Media Screen**, which is displayed after the user captures an image or video.
+
+
+ # Customization
+ All of the MCamera's default settings can be changed during initialisation.
+ - important: To start a camera session, simply call the ``startSession()`` method. For more details, see the **Usage** section.
+
+ ## Camera Screens
+ Use one of the methods below to change the default screens:
+ - ``setCameraScreen(_:)``
+ - ``setCapturedMediaScreen(_:)``
+ - ``setErrorScreen(_:)``
+
+ - tip: To disable displaying captured media, call the ``setCapturedMediaScreen(_:)`` method with a nil value.
+
+ ## Actions after capturing media
+ Use one of the methods below to set actions that will be called after capturing media:
+ - ``onImageCaptured(_:)``
+ - ``onVideoCaptured(_:)``
+ - note: If there is no **Captured Media Screen**, the action is called immediately after the media is captured, otherwise it is triggered after the user accepts the captured media in the **Captured Media Screen**.
+
+ ## Camera Configuration
+ To change the initial camera settings, use the following methods:
+ - ``setCameraOutputType(_:)``
+ - ``setCameraPosition(_:)``
+ - ``setAudioAvailability(_:)``
+ - ``setZoomFactor(_:)``
+ - ``setFlashMode(_:)``
+ - ``setLightMode(_:)``
+ - ``setResolution(_:)``
+ - ``setFrameRate(_:)``
+ - ``setCameraExposureDuration(_:)``
+ - ``setCameraTargetBias(_:)``
+ - ``setCameraISO(_:)``
+ - ``setCameraExposureMode(_:)``
+ - ``setCameraHDRMode(_:)``
+ - ``setCameraFilters(_:)``
+ - ``setMirrorOutput(_:)``
+ - ``setGridVisibility(_:)``
+ - ``setFocusImage(_:)``
+ - ``setFocusImageColor(_:)``
+ - ``setFocusImageSize(_:)``
+ - important: Note that if you try to set a value that exceeds the camera's capabilities, the camera will automatically set the closest possible value and show you which value has been set.
+
+ ## Other
+ There are other methods that you can use to customize your experience:
+ - ``setCloseMCameraAction(_:)``
+ - ``lockCameraInPortraitOrientation(_:)``
+
+ # Usage
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setCameraFilters([.init(name: "CISepiaTone")!])
+ .setCameraPosition(.back)
+ .setCameraOutputType(.video)
+ .setAudioAvailability(false)
+ .setResolution(.hd4K3840x2160)
+ .setFrameRate(30)
+ .setZoomFactor(1.2)
+ .setCameraISO(3)
+ .setCameraTargetBias(1.2)
+ .setLightMode(.on)
+ .setFlashMode(.auto)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+public struct MCamera: View {
+ @ObservedObject var manager: CameraManager
+ @Namespace var namespace
+ var config: Config = .init()
+
+
+ public var body: some View { if config.isCameraConfigured {
+ ZStack(content: createContent)
+ .onDisappear(perform: onDisappear)
+ .onChange(of: manager.attributes.capturedMedia, perform: onCapturedMediaChange)
+ }}
+}
+private extension MCamera {
+ @ViewBuilder func createContent() -> some View {
+ if let error = manager.attributes.error { createErrorScreen(error) }
+ else if let capturedMedia = manager.attributes.capturedMedia, config.capturedMediaScreen != nil { createCapturedMediaScreen(capturedMedia) }
+ else { createCameraScreen() }
+ }
+}
+private extension MCamera {
+ func createErrorScreen(_ error: MCameraError) -> some View {
+ config.errorScreen(error, config.closeMCameraAction).erased()
+ }
+ func createCapturedMediaScreen(_ media: MCameraMedia) -> some View {
+ config.capturedMediaScreen?(media, namespace, onCapturedMediaRejected, onCapturedMediaAccepted)
+ .erased()
+ .onAppear(perform: onCaptureMediaScreenAppear)
+ }
+ func createCameraScreen() -> some View {
+ config.cameraScreen(manager, namespace, config.closeMCameraAction)
+ .erased()
+ .onAppear(perform: onCameraAppear)
+ .onDisappear(perform: onCameraDisappear)
+ }
+}
+
+
+// MARK: - ACTIONS
+
+
+
+// MARK: MCamera
+private extension MCamera {
+ func onDisappear() {
+ lockScreenOrientation(nil)
+ manager.cancel()
+ }
+ func onCapturedMediaChange(_ capturedMedia: MCameraMedia?) {
+ guard let capturedMedia, config.capturedMediaScreen == nil else { return }
+ notifyUserOfMediaCaptured(capturedMedia)
+ }
+}
+private extension MCamera {
+ func lockScreenOrientation(_ orientation: UIInterfaceOrientationMask?) {
+ config.appDelegate?.orientationLock = orientation ?? .all
+ UINavigationController.attemptRotationToDeviceOrientation()
+ }
+ func notifyUserOfMediaCaptured(_ capturedMedia: MCameraMedia) {
+ if let image = capturedMedia.getImage() { config.imageCapturedAction(image, .init(mCamera: self)) }
+ else if let video = capturedMedia.getVideo() { config.videoCapturedAction(video, .init(mCamera: self)) }
+ }
+}
+
+// MARK: Camera Screen
+private extension MCamera {
+ func onCameraAppear() { Task {
+ do {
+ try await manager.setup()
+ lockScreenOrientation(.portrait)
+ } catch { print("(MijickCamera) ERROR DURING SETUP: \(error)") }
+ }}
+ func onCameraDisappear() {
+ manager.cancel()
+ }
+}
+
+// MARK: Captured Media Screen
+private extension MCamera {
+ func onCaptureMediaScreenAppear() {
+ lockScreenOrientation(nil)
+ }
+ func onCapturedMediaRejected() {
+ manager.setCapturedMedia(nil)
+ }
+ func onCapturedMediaAccepted() {
+ guard let capturedMedia = manager.attributes.capturedMedia else { return }
+ notifyUserOfMediaCaptured(capturedMedia)
+ }
+}
diff --git a/Sources/Public/Camera Settings/Public+CameraSettings+MApplicationDelegate.swift b/Sources/Public/Camera Settings/Public+CameraSettings+MApplicationDelegate.swift
new file mode 100644
index 0000000..5522624
--- /dev/null
+++ b/Sources/Public/Camera Settings/Public+CameraSettings+MApplicationDelegate.swift
@@ -0,0 +1,53 @@
+//
+// Public+CameraSettings+MApplicationDelegate.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+/**
+ Locks the screen in portrait mode when the Camera Screen is active.
+
+ See ``MCamera/lockCameraInPortraitOrientation(_:)`` for more details.
+ - note: Blocks the rotation of the entire screen on which the **MCamera** is located.
+
+ ## Usage
+ ```swift
+ @main struct App_Main: App {
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ WindowGroup(content: ContentView.init)
+ }
+ }
+
+// MARK: App Delegate
+ class AppDelegate: NSObject, MApplicationDelegate {
+ static var orientationLock = UIInterfaceOrientationMask.all
+
+ func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { AppDelegate.orientationLock }
+ }
+
+// MARK: Content View
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .lockCameraInPortraitOrientation(AppDelegate.self)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+public protocol MApplicationDelegate: UIApplicationDelegate {
+ static var orientationLock: UIInterfaceOrientationMask { get set }
+
+ func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask
+}
diff --git a/Sources/Public/Camera Settings/Public+CameraSettings+MCamera.swift b/Sources/Public/Camera Settings/Public+CameraSettings+MCamera.swift
new file mode 100644
index 0000000..a46d7a7
--- /dev/null
+++ b/Sources/Public/Camera Settings/Public+CameraSettings+MCamera.swift
@@ -0,0 +1,405 @@
+//
+// Public+CameraSettings+MCamera.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+import AVKit
+
+// MARK: Initializer
+public extension MCamera {
+ init() { self.init(manager: .init(
+ captureSession: AVCaptureSession(),
+ captureDeviceInputType: AVCaptureDeviceInput.self
+ ))}
+}
+
+
+// MARK: - METHODS
+
+
+
+// MARK: Changing Default Screens
+public extension MCamera {
+ /**
+ Changes the camera screen to a selected one.
+
+ For more details and tips on creating your own **Camera Screen**, see the ``MCameraScreen`` documentation.
+
+ - tip: To hide selected buttons and controls on the screen, use the method with DefaultCameraScreen as argument. For a code example, please refer to Usage -> Default Camera Screen Customization section.
+
+
+ # Usage
+
+ ## New Camera Screen
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setCameraScreen(CustomCameraScreen.init)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+
+ ## Default Camera Screen Customization
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setCameraScreen {
+ DefaultCameraScreen(cameraManager: $0, namespace: $1, closeMCameraAction: $2)
+ .captureButtonAllowed(false)
+ .cameraOutputSwitchAllowed(false)
+ .lightButtonAllowed(false)
+ }
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+ func setCameraScreen(_ builder: @escaping CameraScreenBuilder) -> Self { config.cameraScreen = builder; return self }
+
+ /**
+ Changes the captured media screen to a selected one.
+
+ For more details and tips on creating your own **Captured Media Screen**, see the ``MCapturedMediaScreen`` documentation.
+
+ - tip: To disable displaying captured media, call the method with a nil value.
+
+
+ # Usage
+
+ ## New Captured Media Screen
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setCapturedMediaScreen(DefaultCapturedMediaScreen.init)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+
+ ## No Captured Media Screen
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setCapturedMediaScreen(nil)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+ func setCapturedMediaScreen(_ builder: CapturedMediaScreenBuilder?) -> Self { config.capturedMediaScreen = builder; return self }
+
+ /**
+ Changes the error screen to a selected one.
+
+ For more details and tips on creating your own **Error Screen**, see the ``MCameraErrorScreen`` documentation.
+
+
+ ## Usage
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setErrorScreen(CustomCameraErrorScreen.init)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+ func setErrorScreen(_ builder: @escaping ErrorScreenBuilder) -> Self { config.errorScreen = builder; return self }
+}
+
+// MARK: Changing Initial Values
+public extension MCamera {
+ /**
+ Changes the initial camera output type.
+
+ For available options, please refer to the ``CameraOutputType`` documentation.
+ */
+ func setCameraOutputType(_ cameraOutputType: CameraOutputType) -> Self { manager.attributes.outputType = cameraOutputType; return self }
+
+ /**
+ Changes the initial camera position.
+
+ For available options, please refer to the ``CameraPosition`` documentation.
+
+ - note: If the selected camera position is not available, the camera will not be changed.
+ */
+ func setCameraPosition(_ cameraPosition: CameraPosition) -> Self { manager.attributes.cameraPosition = cameraPosition; return self }
+
+ /**
+ Definies whether the audio source is available.
+
+ If disabled, the camera will not record audio, and will not ask for permission to access the microphone.
+ */
+ func setAudioAvailability(_ isAvailable: Bool) -> Self { manager.attributes.isAudioSourceAvailable = isAvailable; return self }
+
+ /**
+ Changes the initial camera zoom level.
+
+ - note: If the zoom factor is out of bounds, it will be set to the closest available value.
+ */
+ func setZoomFactor(_ zoomFactor: CGFloat) -> Self { manager.attributes.zoomFactor = zoomFactor; return self }
+
+ /**
+ Changes the initial camera flash mode.
+
+ For available options, please refer to the ``CameraFlashMode`` documentation.
+
+ - note: If the selected flash mode is not available, the flash mode will not be changed.
+ */
+ func setFlashMode(_ flashMode: CameraFlashMode) -> Self { manager.attributes.flashMode = flashMode; return self }
+
+ /**
+ Sets the screen flash color for front camera captures.
+
+ When taking photos with the front camera with flash enabled, the screen will illuminate
+ with this color to light up the subject's face. If not set, defaults to white.
+
+ - parameter color: The UIColor to use for screen flash illumination.
+ */
+ func setScreenFlashColor(_ color: UIColor?) -> Self { manager.attributes.screenFlashColor = color; return self }
+
+ /**
+ Changes the initial light (torch / flashlight) mode.
+
+ For available options, please refer to the ``CameraLightMode`` documentation.
+
+ - note: If the selected light mode is not available, the light mode will not be changed.
+ */
+ func setLightMode(_ lightMode: CameraLightMode) -> Self { manager.attributes.lightMode = lightMode; return self }
+
+ /**
+ Changes the initial camera resolution.
+
+ - important: Changing the resolution may affect the maximum frame rate that can be set.
+ */
+ func setResolution(_ resolution: AVCaptureSession.Preset) -> Self { manager.attributes.resolution = resolution; return self }
+
+ /**
+ Changes the initial camera frame rate.
+
+ - note: Depending on the resolution of the camera and the current specifications of the device, there are some restrictions on the frame rate that can be set.
+ If you set a frame rate that exceeds the camera's capabilities, the library will automatically set the closest possible value and show you which value has been set (``MCameraScreen/frameRate``).
+ */
+ func setFrameRate(_ frameRate: Int32) -> Self { manager.attributes.frameRate = frameRate; return self }
+
+ /**
+ Changes the initial camera exposure duration.
+
+ - note: If the exposure duration is out of bounds, it will be set to the closest available value.
+ */
+ func setCameraExposureDuration(_ duration: CMTime) -> Self { manager.attributes.cameraExposure.duration = duration; return self }
+
+ /**
+ Changes the initial camera target bias.
+
+ - note: If the target bias is out of bounds, it will be set to the closest available value.
+ */
+ func setCameraTargetBias(_ targetBias: Float) -> Self { manager.attributes.cameraExposure.targetBias = targetBias; return self }
+
+ /**
+ Changes the initial camera ISO.
+
+ - note: If the ISO is out of bounds, it will be set to the closest available value.
+ */
+ func setCameraISO(_ iso: Float) -> Self { manager.attributes.cameraExposure.iso = iso; return self }
+
+ /**
+ Changes the initial camera exposure mode.
+
+ - note: If the exposure mode is not supported, the exposure mode will not be changed.
+ */
+ func setCameraExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode) -> Self { manager.attributes.cameraExposure.mode = exposureMode; return self }
+
+ /**
+ Changes the initial camera HDR mode.
+
+ For available options, please refer to the ``CameraHDRMode`` documentation.
+ */
+ func setCameraHDRMode(_ hdrMode: CameraHDRMode) -> Self { manager.attributes.hdrMode = hdrMode; return self }
+
+ /**
+ Changes the initial camera filters.
+
+ - important: Setting multiple filters simultaneously can affect the performance of the camera.
+ */
+ func setCameraFilters(_ filters: [CIFilter]) -> Self { manager.attributes.cameraFilters = filters; return self }
+
+ /**
+ Changes the initial mirror output setting.
+ */
+ func setMirrorOutput(_ shouldMirror: Bool) -> Self { manager.attributes.mirrorOutput = shouldMirror; return self }
+
+ /**
+ Changes the initial grid visibility setting.
+ */
+ func setGridVisibility(_ shouldShowGrid: Bool) -> Self { manager.attributes.isGridVisible = shouldShowGrid; return self }
+
+ /**
+ Changes the shape of the focus indicator visible when touching anywhere on the camera screen.
+ */
+ func setFocusImage(_ image: UIImage) -> Self { manager.cameraMetalView.focusIndicator.image = image; return self }
+
+ /**
+ Changes the color of the focus indicator visible when touching anywhere on the camera screen.
+ */
+ func setFocusImageColor(_ color: UIColor) -> Self { manager.cameraMetalView.focusIndicator.tintColor = color; return self }
+
+ /**
+ Changes the size of the focus indicator visible when touching anywhere on the camera.
+ */
+ func setFocusImageSize(_ size: CGFloat) -> Self { manager.cameraMetalView.focusIndicator.size = size; return self }
+}
+
+// MARK: Actions
+public extension MCamera {
+ /**
+ Indicates how the MCamera can be closed.
+
+ ## Usage
+ ```swift
+ struct ContentView: View {
+ @State private var isSheetPresented: Bool = false
+
+
+ var body: some View {
+ Button(action: { isSheetPresented = true }) {
+ Text("Click me!")
+ }
+ .fullScreenCover(isPresented: $isSheetPresented) {
+ MCamera()
+ .setResolution(.hd1920x1080)
+ .setCloseMCameraAction { isSheetPresented = false }
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ }
+ ```
+ */
+ func setCloseMCameraAction(_ action: @escaping () -> ()) -> Self { config.closeMCameraAction = action; return self }
+
+ /**
+ Defines action that is called when an image is captured.
+
+ MCameraController can be used to perform additional actions related to MCamera, such as closing MCamera or returning to the camera screen.
+ See ``Controller`` for more information.
+
+ - note: The action is called immediately if **Captured Media Screen** is nil, otherwise after the user accepts the photo.
+
+
+ ## Usage
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .onImageCaptured { image, controller in
+ saveImageInGallery(image)
+ controller.reopenCameraScreen()
+ }
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+ func onImageCaptured(_ action: @escaping (UIImage, MCamera.Controller) -> ()) -> Self { config.imageCapturedAction = action; return self }
+
+ /**
+ Defines action that is called when a video is captured.
+
+ MCameraController can be used to perform additional actions related to MCamera, such as closing MCamera or returning to the camera screen.
+ See ``Controller`` for more information.
+
+ - note: The action is called immediately if **Captured Media Screen** is nil, otherwise after the user accepts the video.
+
+
+ ## Usage
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .onVideoCaptured { video, controller in
+ saveVideoInGallery(video)
+ controller.reopenCameraScreen()
+ }
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+ func onVideoCaptured(_ action: @escaping (URL, MCamera.Controller) -> ()) -> Self { config.videoCapturedAction = action; return self }
+}
+
+// MARK: Others
+public extension MCamera {
+ /**
+ Locks the screen in portrait mode when the Camera Screen is active.
+
+ See ``MApplicationDelegate`` for more details.
+ - note: Blocks the rotation of the entire screen on which the **MCamera** is located.
+
+ ## Usage
+ ```swift
+ @main struct App_Main: App {
+ @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ WindowGroup(content: ContentView.init)
+ }
+ }
+
+ // MARK: App Delegate
+ class AppDelegate: NSObject, MApplicationDelegate {
+ static var orientationLock = UIInterfaceOrientationMask.all
+
+ func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { AppDelegate.orientationLock }
+ }
+
+ // MARK: Content View
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .lockCameraInPortraitOrientation(AppDelegate.self)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+ ```
+ */
+ func lockCameraInPortraitOrientation(_ appDelegate: MApplicationDelegate.Type) -> Self { config.appDelegate = appDelegate; manager.attributes.orientationLocked = true; return self }
+
+ /**
+ Starts the camera session.
+
+ - important: This method must be called to start the camera.
+ */
+ func startSession() -> some View { config.isCameraConfigured = true; return self }
+}
diff --git a/Sources/Public/Camera Settings/Public+CameraSettings+MCameraController.swift b/Sources/Public/Camera Settings/Public+CameraSettings+MCameraController.swift
new file mode 100644
index 0000000..015b828
--- /dev/null
+++ b/Sources/Public/Camera Settings/Public+CameraSettings+MCameraController.swift
@@ -0,0 +1,27 @@
+//
+// Public+CameraSettings+MCameraController.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Foundation
+
+// MARK: Available Actions
+public extension MCamera.Controller {
+ /**
+ Closes the MCamera.
+
+ See ``MCamera/setCloseMCameraAction(_:)`` for more details.
+ */
+ func closeMCamera() { mCamera.config.closeMCameraAction() }
+
+ /**
+ Opens the Camera Screen.
+ */
+ func reopenCameraScreen() { mCamera.manager.setCapturedMedia(nil) }
+}
diff --git a/Sources/Public/Models/Public+Model+CameraUtilities.swift b/Sources/Public/Models/Public+Model+CameraUtilities.swift
new file mode 100644
index 0000000..54e18d8
--- /dev/null
+++ b/Sources/Public/Models/Public+Model+CameraUtilities.swift
@@ -0,0 +1,44 @@
+//
+// Public+Model+CameraUtilities.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Camera Output Type
+public enum CameraOutputType: CaseIterable {
+ case photo
+ case video
+}
+
+// MARK: Camera Position
+public enum CameraPosition: CaseIterable {
+ case back
+ case front
+}
+
+// MARK: Camera Flash Mode
+public enum CameraFlashMode: CaseIterable {
+ case off
+ case on
+ case auto
+}
+
+// MARK: Camera Light Mode
+public enum CameraLightMode: CaseIterable {
+ case off
+ case on
+}
+
+// MARK: Camera HDR Mode
+public enum CameraHDRMode: CaseIterable {
+ case off
+ case on
+ case auto
+}
diff --git a/Sources/Public/Models/Public+Model+MCameraError.swift b/Sources/Public/Models/Public+Model+MCameraError.swift
new file mode 100644
index 0000000..49c5d17
--- /dev/null
+++ b/Sources/Public/Models/Public+Model+MCameraError.swift
@@ -0,0 +1,17 @@
+//
+// Public+Model+MCameraError.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Foundation
+
+public enum MCameraError: Error {
+ case microphonePermissionsNotGranted, cameraPermissionsNotGranted
+ case cannotSetupInput, cannotSetupOutput, cannotSetupMetalDevice
+}
diff --git a/Sources/Public/Models/Public+Model+MCameraMedia.swift b/Sources/Public/Models/Public+Model+MCameraMedia.swift
new file mode 100644
index 0000000..16ceecb
--- /dev/null
+++ b/Sources/Public/Models/Public+Model+MCameraMedia.swift
@@ -0,0 +1,25 @@
+//
+// Public+Model+MCameraMedia.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Getters
+public extension MCameraMedia {
+ /**
+ Gets the image from the media object.
+ */
+ func getImage() -> UIImage? { image }
+
+ /**
+ Gets the video URL from the media object.
+ */
+ func getVideo() -> URL? { video }
+}
diff --git a/Sources/Public/UI/Public+UI+DefaultCameraScreen.swift b/Sources/Public/UI/Public+UI+DefaultCameraScreen.swift
new file mode 100644
index 0000000..a9b8820
--- /dev/null
+++ b/Sources/Public/UI/Public+UI+DefaultCameraScreen.swift
@@ -0,0 +1,31 @@
+//
+// Public+UI+DefaultCameraScreen.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+// MARK: Initializer
+public extension DefaultCameraScreen {
+ init(cameraManager: CameraManager, namespace: Namespace.ID, closeMCameraAction: @escaping () -> Void) {
+ self.init(cameraManager: cameraManager, namespace: namespace, closeMCameraAction: closeMCameraAction, config: .init())
+ }
+}
+
+// MARK: Methods
+public extension DefaultCameraScreen {
+ func captureButtonAllowed(_ value: Bool) -> Self { config.captureButtonAllowed = value; return self }
+ func cameraOutputSwitchAllowed(_ value: Bool) -> Self { config.cameraOutputSwitchAllowed = value; return self }
+ func cameraPositionButtonAllowed(_ value: Bool) -> Self { config.cameraPositionButtonAllowed = value; return self }
+ func flashButtonAllowed(_ value: Bool) -> Self { config.flashButtonAllowed = value; return self }
+ func lightButtonAllowed(_ value: Bool) -> Self { config.lightButtonAllowed = value; return self }
+ func flipButtonAllowed(_ value: Bool) -> Self { config.flipButtonAllowed = value; return self }
+ func gridButtonAllowed(_ value: Bool) -> Self { config.gridButtonAllowed = value; return self }
+ func closeButtonAllowed(_ value: Bool) -> Self { config.closeButtonAllowed = value; return self }
+}
diff --git a/Sources/Public/UI/Public+UI+MCameraErrorScreen.swift b/Sources/Public/UI/Public+UI+MCameraErrorScreen.swift
new file mode 100644
index 0000000..35c2db3
--- /dev/null
+++ b/Sources/Public/UI/Public+UI+MCameraErrorScreen.swift
@@ -0,0 +1,54 @@
+//
+// Public+UI+MCameraErrorScreen.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+/**
+ Screen that displays an error message if one or more camera permissions are denied by the user.
+
+ - important: A view conforming to **MCameraErrorScreen** has to be passed directly to ``MCamera``. See ``MCamera/setErrorScreen(_:)`` for more details.
+
+
+ ## Usage
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setErrorScreen(CustomCameraErrorScreen.init)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+
+ // MARK: Custom Camera Error Screen
+ struct CustomCameraErrorScreen: MCameraErrorScreen {
+ let error: MCameraError
+ let closeMCameraAction: () -> ()
+
+
+ var body: some View {
+ Button(action: openAppSettings) { Text("Open Settings") }
+ }
+ }
+ ```
+ */
+public protocol MCameraErrorScreen: View {
+ var error: MCameraError { get }
+ var closeMCameraAction: () -> () { get }
+}
+
+// MARK: Methods
+public extension MCameraErrorScreen {
+ func openAppSettings() { if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }}
+}
diff --git a/Sources/Public/UI/Public+UI+MCameraScreen.swift b/Sources/Public/UI/Public+UI+MCameraScreen.swift
new file mode 100644
index 0000000..5bb3313
--- /dev/null
+++ b/Sources/Public/UI/Public+UI+MCameraScreen.swift
@@ -0,0 +1,243 @@
+//
+// Public+UI+MCameraScreen.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+import AVFoundation
+import MijickTimer
+
+/**
+ Screen that displays the camera view and manages camera actions.
+
+ - important: A view conforming to **MCameraScreen** has to be passed directly to ``MCamera``. See ``MCamera/setCameraScreen(_:)`` for more details.
+
+ ## Usage
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setCameraScreen(CustomCameraErrorScreen.init)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+
+ // MARK: Custom Camera Screen
+ struct CustomCameraScreen: MCameraScreen {
+ @ObservedObject var cameraManager: CameraManager
+ let namespace: Namespace.ID
+ let closeMCameraAction: () -> ()
+
+
+ var body: some View {
+ VStack(spacing: 0) {
+ createNavigationBar()
+ createCameraOutputView()
+ createCaptureButton()
+ }
+ }
+ }
+ private extension CustomCameraScreen {
+ func createNavigationBar() -> some View {
+ Text("This is a Custom Camera View")
+ .padding(.top, 12)
+ .padding(.bottom, 12)
+ }
+ func createCaptureButton() -> some View {
+ Button(action: captureOutput) { Text("Click to capture") }
+ .padding(.top, 12)
+ .padding(.bottom, 12)
+ }
+ }
+ ```
+ */
+public protocol MCameraScreen: View {
+ var cameraManager: CameraManager { get }
+ var namespace: Namespace.ID { get }
+ var closeMCameraAction: () -> () { get }
+}
+
+// MARK: Methods
+public extension MCameraScreen {
+ /**
+ View that displays the camera output.
+
+ ## Usage
+ ```swift
+ struct CustomCameraScreen: MCameraScreen {
+ @ObservedObject var cameraManager: CameraManager
+ let namespace: Namespace.ID
+ let closeMCameraAction: () -> ()
+
+
+ var body: some View {
+ (...)
+ createCameraOutputView()
+ (...)
+ }
+ }
+ ```
+ */
+ func createCameraOutputView() -> some View { CameraBridgeView(cameraManager: cameraManager).equatable() }
+}
+public extension MCameraScreen {
+ /**
+ Capture the current camera output.
+
+ The output type depends on what ``cameraOutputType`` is set to.
+ */
+ func captureOutput() { cameraManager.captureOutput() }
+
+ /**
+ Set the output type of the camera.
+
+ For available options, please refer to the ``CameraOutputType`` documentation.
+ */
+ func setOutputType(_ outputType: CameraOutputType) { cameraManager.setOutputType(outputType) }
+
+ /**
+ Set the camera position.
+
+ For available options, please refer to the ``CameraPosition`` documentation.
+
+ - note: If the selected camera position is not available, the camera will not be changed.
+ */
+ func setCameraPosition(_ cameraPosition: CameraPosition) async throws { try await cameraManager.setCameraPosition(cameraPosition) }
+
+ /**
+ Set the zoom factor of the camera.
+
+ - note: If the zoom factor is out of bounds, it will be set to the closest available value.
+ */
+ func setZoomFactor(_ zoomFactor: CGFloat) throws { try cameraManager.setCameraZoomFactor(zoomFactor) }
+
+ /**
+ Set the flash mode of the camera.
+
+ For available options, please refer to the ``CameraFlashMode`` documentation.
+
+ - note: If the selected flash mode is not available, the flash mode will not be changed.
+ */
+ func setFlashMode(_ flashMode: CameraFlashMode) { cameraManager.setFlashMode(flashMode) }
+
+ /**
+ Set the screen flash color for front camera captures.
+
+ When taking photos with the front camera with flash enabled, the screen will illuminate
+ with this color to light up the subject's face. If not set, defaults to white.
+
+ - parameter color: The UIColor to use for screen flash illumination. Pass nil for white.
+ */
+ func setScreenFlashColor(_ color: UIColor?) { cameraManager.setScreenFlashColor(color) }
+
+ /**
+ Set the light mode of the camera.
+
+ For available options, please refer to the ``CameraLightMode`` documentation.
+
+ - note: If the selected light mode is not available, the light mode will not be changed.
+ */
+ func setLightMode(_ lightMode: CameraLightMode) throws { try cameraManager.setLightMode(lightMode) }
+
+ /**
+ Set the camera resolution.
+
+ - important: Changing the resolution may affect the maximum frame rate that can be set.
+ */
+ func setResolution(_ resolution: AVCaptureSession.Preset) { cameraManager.setResolution(resolution) }
+
+ /**
+ Set the camera frame rate.
+
+ - important: Changing the resolution may affect the maximum frame rate that can be set.
+ - note: If the frame rate is out of bounds, it will be set to the closest available value.
+ */
+ func setFrameRate(_ frameRate: Int32) throws { try cameraManager.setFrameRate(frameRate) }
+
+ /**
+ Set the camera exposure duration.
+
+ - note: If the exposure duration is out of bounds, it will be set to the closest available value.
+ */
+ func setExposureDuration(_ exposureDuration: CMTime) throws { try cameraManager.setExposureDuration(exposureDuration) }
+
+ /**
+ Set the camera exposure target bias.
+
+ - note: If the target bias is out of bounds, it will be set to the closest available value.
+ */
+ func setExposureTargetBias(_ exposureTargetBias: Float) throws { try cameraManager.setExposureTargetBias(exposureTargetBias) }
+
+ /**
+ Set the camera ISO.
+
+ - note: If the ISO is out of bounds, it will be set to the closest available value.
+ */
+ func setISO(_ iso: Float) throws { try cameraManager.setISO(iso) }
+
+ /**
+ Set the camera exposure mode.
+
+ - note: If the exposure mode is not supported, the exposure mode will not be changed.
+ */
+ func setExposureMode(_ exposureMode: AVCaptureDevice.ExposureMode) throws { try cameraManager.setExposureMode(exposureMode) }
+
+ /**
+ Set the camera HDR mode.
+
+ For available options, please refer to the ``CameraHDRMode`` documentation.
+ */
+ func setHDRMode(_ hdrMode: CameraHDRMode) throws { try cameraManager.setHDRMode(hdrMode) }
+
+ /**
+ Set the camera filters to be applied to the camera output.
+
+ - important: Setting multiple filters simultaneously can affect the performance of the camera.
+ */
+ func setCameraFilters(_ filters: [CIFilter]) { cameraManager.setCameraFilters(filters) }
+
+ /**
+ Set whether the camera output should be mirrored.
+ */
+ func setMirrorOutput(_ shouldMirror: Bool) { cameraManager.setMirrorOutput(shouldMirror) }
+
+ /**
+ Set whether the camera grid should be visible.
+ */
+ func setGridVisibility(_ shouldShowGrid: Bool) { cameraManager.setGridVisibility(shouldShowGrid) }
+}
+
+// MARK: Attributes
+public extension MCameraScreen {
+ var cameraOutputType: CameraOutputType { cameraManager.attributes.outputType }
+ var cameraPosition: CameraPosition { cameraManager.attributes.cameraPosition }
+ var zoomFactor: CGFloat { cameraManager.attributes.zoomFactor }
+ var flashMode: CameraFlashMode { cameraManager.attributes.flashMode }
+ var lightMode: CameraLightMode { cameraManager.attributes.lightMode }
+ var resolution: AVCaptureSession.Preset { cameraManager.attributes.resolution }
+ var frameRate: Int32 { cameraManager.attributes.frameRate }
+ var exposureDuration: CMTime { cameraManager.attributes.cameraExposure.duration }
+ var exposureTargetBias: Float { cameraManager.attributes.cameraExposure.targetBias }
+ var iso: Float { cameraManager.attributes.cameraExposure.iso }
+ var exposureMode: AVCaptureDevice.ExposureMode { cameraManager.attributes.cameraExposure.mode }
+ var hdrMode: CameraHDRMode { cameraManager.attributes.hdrMode }
+ var cameraFilters: [CIFilter] { cameraManager.attributes.cameraFilters }
+ var isOutputMirrored: Bool { cameraManager.attributes.mirrorOutput }
+ var isGridVisible: Bool { cameraManager.attributes.isGridVisible }
+}
+public extension MCameraScreen {
+ var hasFlash: Bool { cameraManager.hasFlash }
+ var hasLight: Bool { cameraManager.hasLight }
+ var recordingTime: MTime { cameraManager.videoOutput.recordingTime }
+ var isRecording: Bool { cameraManager.videoOutput.timer.timerStatus == .running }
+ var isOrientationLocked: Bool { cameraManager.attributes.orientationLocked || cameraManager.attributes.userBlockedScreenRotation }
+ var deviceOrientation: AVCaptureVideoOrientation { cameraManager.attributes.deviceOrientation }
+}
diff --git a/Sources/Public/UI/Public+UI+MCapturedMediaScreen.swift b/Sources/Public/UI/Public+UI+MCapturedMediaScreen.swift
new file mode 100644
index 0000000..1999fdd
--- /dev/null
+++ b/Sources/Public/UI/Public+UI+MCapturedMediaScreen.swift
@@ -0,0 +1,82 @@
+//
+// Public+UI+MCapturedMediaScreen.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import SwiftUI
+
+/**
+ Screen that displays the captured media.
+
+ - important: A view conforming to **MCapturedMediaScreen** has to be passed directly to ``MCamera``. See ``MCamera/setCapturedMediaScreen(_:)`` for more details.
+
+
+ ## Usage
+ ```swift
+ struct ContentView: View {
+ var body: some View {
+ MCamera()
+ .setCapturedMediaScreen(CustomCapturedMediaScreen.init)
+
+ // MUST BE CALLED!
+ .startSession()
+ }
+ }
+
+ // MARK: Custom Captured Media Screen
+ struct CustomCapturedMediaScreen: MCapturedMediaScreen {
+ let capturedMedia: MCameraMedia
+ let namespace: Namespace.ID
+ let retakeAction: () -> ()
+ let acceptMediaAction: () -> ()
+
+
+ var body: some View {
+ VStack(spacing: 0) {
+ Spacer()
+ createContentView()
+ Spacer()
+ createButtons()
+ }
+ }
+ }
+ private extension CustomCapturedMediaScreen {
+ func createContentView() -> some View { ZStack {
+ if let image = capturedMedia.getImage() { createImageView(image) }
+ else { EmptyView() }
+ }}
+ func createButtons() -> some View {
+ HStack(spacing: 24) {
+ createRetakeButton()
+ createSaveButton()
+ }
+ }
+ }
+ private extension CustomCapturedMediaScreen {
+ func createImageView(_ image: UIImage) -> some View {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .ignoresSafeArea()
+ }
+ func createRetakeButton() -> some View {
+ Button(action: retakeAction) { Text("Retake") }
+ }
+ func createSaveButton() -> some View {
+ Button(action: acceptMediaAction) { Text("Save") }
+ }
+ }
+ ```
+ */
+public protocol MCapturedMediaScreen: View {
+ var capturedMedia: MCameraMedia { get }
+ var namespace: Namespace.ID { get }
+ var retakeAction: () -> () { get }
+ var acceptMediaAction: () -> () { get }
+}
diff --git a/Tests/Tests+CameraManager.swift b/Tests/Tests+CameraManager.swift
new file mode 100644
index 0000000..075d637
--- /dev/null
+++ b/Tests/Tests+CameraManager.swift
@@ -0,0 +1,391 @@
+//
+// Tests+CameraManager.swift of MijickCamera
+//
+// Created by Tomasz Kurylik. Sending ❤️ from Kraków!
+// - Mail: tomasz.kurylik@mijick.com
+// - GitHub: https://github.com/FulcrumOne
+// - Medium: https://medium.com/@mijick
+//
+// Copyright ©2024 Mijick. All rights reserved.
+
+
+import Testing
+import SwiftUI
+@testable import MijickCamera
+
+@MainActor @Suite("Camera Manager Tests") struct CameraManagerTests {
+ var cameraManager: CameraManager = .init(
+ captureSession: MockCaptureSession(),
+ captureDeviceInputType: MockDeviceInput.self
+ )
+}
+
+// MARK: Setup
+extension CameraManagerTests {
+ @Test("Setup: Default Attributes") func setupWithDefaultAttributes() async throws {
+ try await setupCamera()
+
+ #expect(cameraManager.captureSession.isRunning == true)
+ #expect(cameraManager.captureSession.deviceInputs.count == 2)
+ #expect(cameraManager.photoOutput.parent != nil)
+ #expect(cameraManager.videoOutput.parent != nil)
+ #expect(cameraManager.captureSession.outputs.count == 3)
+ #expect(cameraManager.cameraView != nil)
+ #expect(cameraManager.cameraLayer.isHidden == true)
+ #expect(cameraManager.cameraMetalView.parent != nil)
+ #expect(cameraManager.cameraGridView.parent != nil)
+ #expect(cameraManager.motionManager.manager.accelerometerUpdateInterval > 0)
+ #expect(cameraManager.notificationCenterManager.parent != nil)
+ }
+ @Test("Setup: Custom Attributes") func setupWithCustomAttributes() async throws {
+ cameraManager.attributes.cameraPosition = .front
+ cameraManager.attributes.zoomFactor = 2137
+ cameraManager.attributes.lightMode = .on
+ cameraManager.attributes.resolution = .hd1280x720
+ cameraManager.attributes.frameRate = 666
+ cameraManager.attributes.cameraExposure.duration = .init(value: 1, timescale: 10)
+ cameraManager.attributes.cameraExposure.targetBias = 0.66
+ cameraManager.attributes.cameraExposure.iso = 2000
+ cameraManager.attributes.cameraExposure.mode = .custom
+ cameraManager.attributes.hdrMode = .off
+ cameraManager.attributes.isGridVisible = false
+
+ try await setupCamera()
+
+ #expect(currentDevice.uniqueID == cameraManager.frontCameraInput?.device.uniqueID)
+ #expect(currentDevice.videoZoomFactor == currentDevice.maxAvailableVideoZoomFactor)
+ #expect(currentDevice.lightMode == .on)
+ #expect(cameraManager.captureSession.sessionPreset == .hd1280x720)
+ #expect(currentDevice.activeVideoMinFrameDuration == .init(value: 1, timescale: Int32(currentDevice.maxFrameRate!)))
+ #expect(currentDevice.activeVideoMaxFrameDuration == .init(value: 1, timescale: Int32(currentDevice.maxFrameRate!)))
+ #expect(currentDevice.exposureDuration == .init(value: 1, timescale: 10))
+ #expect(currentDevice.exposureTargetBias == 0.66)
+ #expect(currentDevice.iso == currentDevice.maxISO)
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(currentDevice.hdrMode == .off)
+ #expect(cameraManager.cameraGridView.alpha == 0)
+
+ #expect(cameraManager.attributes.zoomFactor == currentDevice.maxAvailableVideoZoomFactor)
+ #expect(cameraManager.attributes.frameRate == Int32(currentDevice.maxFrameRate!))
+ #expect(cameraManager.attributes.cameraExposure.iso == currentDevice.maxISO)
+ }
+ @Test("Setup: Audio Source Unavailable") func setupWithAudioSourceUnavailable() async throws {
+ cameraManager.attributes.isAudioSourceAvailable = false
+ try await setupCamera()
+
+ #expect(cameraManager.captureSession.deviceInputs.count == 1)
+ }
+}
+
+// MARK: Cancel
+extension CameraManagerTests {
+ @Test("Cancel Camera Session") func cancelCameraSession() async throws {
+ try await setupCamera()
+ cameraManager.cancel()
+
+ #expect(cameraManager.captureSession.isRunning == false)
+ #expect(cameraManager.captureSession.deviceInputs.count == 0)
+ #expect(cameraManager.captureSession.outputs.count == 0)
+ }
+}
+
+// MARK: Set Camera Output
+extension CameraManagerTests {
+ @Test("Set Camera Output") func setCameraOutput() async throws {
+ try await setupCamera()
+
+ cameraManager.setOutputType(.photo)
+ #expect(cameraManager.attributes.outputType == .photo)
+
+ cameraManager.setOutputType(.video)
+ #expect(cameraManager.attributes.outputType == .video)
+ }
+}
+
+// MARK: Set Camera Position
+extension CameraManagerTests {
+ @Test("Set Camera Position") func setCameraPosition() async throws {
+ try await setupCamera()
+
+ try await cameraManager.setCameraPosition(.front)
+ #expect(cameraManager.captureSession.deviceInputs.count == 2)
+ #expect(currentDevice.uniqueID == cameraManager.frontCameraInput?.device.uniqueID)
+ #expect(cameraManager.attributes.cameraPosition == .front)
+
+ await Task.sleep(seconds: 0.5)
+
+ try await cameraManager.setCameraPosition(.back)
+ #expect(cameraManager.captureSession.deviceInputs.count == 2)
+ #expect(currentDevice.uniqueID == cameraManager.backCameraInput?.device.uniqueID)
+ #expect(cameraManager.attributes.cameraPosition == .back)
+
+ await Task.sleep(seconds: 0.5)
+
+ try cameraManager.setCameraZoomFactor(3.2)
+ try await cameraManager.setCameraPosition(.front)
+ #expect(currentDevice.videoZoomFactor == 1)
+ #expect(cameraManager.attributes.zoomFactor == 1)
+ }
+}
+
+// MARK: Set Camera Zoom
+extension CameraManagerTests {
+ @Test("Set Camera Zoom") func setCameraZoom() async throws {
+ try await setupCamera()
+
+ try cameraManager.setCameraZoomFactor(2.137)
+ #expect(currentDevice.videoZoomFactor == 2.137)
+ #expect(cameraManager.attributes.zoomFactor == 2.137)
+
+ try cameraManager.setCameraZoomFactor(0.2137)
+ #expect(currentDevice.videoZoomFactor == currentDevice.minAvailableVideoZoomFactor)
+ #expect(cameraManager.attributes.zoomFactor == currentDevice.minAvailableVideoZoomFactor)
+
+ try cameraManager.setCameraZoomFactor(213.7)
+ #expect(currentDevice.videoZoomFactor == currentDevice.maxAvailableVideoZoomFactor)
+ #expect(cameraManager.attributes.zoomFactor == currentDevice.maxAvailableVideoZoomFactor)
+ }
+}
+
+// MARK: Set Camera Focus
+extension CameraManagerTests {
+ @Test("Set Camera Focus") func setCameraFocus() async throws {
+ try await setupCamera()
+
+ let point = CGPoint(x: 213.7, y: 21.37)
+ let expectedPoint = CGPoint(x: point.y / cameraManager.cameraView.frame.height, y: 1 - point.x / cameraManager.cameraView.frame.width)
+
+ try cameraManager.setCameraFocus(at: point)
+ #expect(currentDevice.focusPointOfInterest == expectedPoint)
+ #expect(currentDevice.exposurePointOfInterest == expectedPoint)
+ #expect(currentDevice.focusMode == .autoFocus)
+ #expect(currentDevice.exposureMode == .autoExpose)
+ #expect(cameraManager.cameraView.subviews.filter { $0.tag == .focusIndicatorTag }.count == 1)
+ }
+}
+
+// MARK: Set Flash Mode
+extension CameraManagerTests {
+ @Test("Set Flash Mode") func setFlashMode() async throws {
+ try await setupCamera()
+
+ cameraManager.setFlashMode(.on)
+ #expect(cameraManager.attributes.flashMode == .on)
+
+ cameraManager.setFlashMode(.auto)
+ #expect(cameraManager.attributes.flashMode == .auto)
+
+ cameraManager.setFlashMode(.off)
+ #expect(cameraManager.attributes.flashMode == .off)
+ }
+}
+
+// MARK: Set Light Mode
+extension CameraManagerTests {
+ @Test("Set Light Mode") func setLightMode() async throws {
+ try await setupCamera()
+
+ try cameraManager.setLightMode(.on)
+ #expect(currentDevice.lightMode == .on)
+ #expect(cameraManager.attributes.lightMode == .on)
+
+ try cameraManager.setLightMode(.off)
+ #expect(currentDevice.lightMode == .off)
+ #expect(cameraManager.attributes.lightMode == .off)
+ }
+}
+
+// MARK: Set Mirror Output
+extension CameraManagerTests {
+ @Test("Set Mirror Output") func setMirrorOutput() async throws {
+ try await setupCamera()
+
+ cameraManager.setMirrorOutput(true)
+ #expect(cameraManager.attributes.mirrorOutput == true)
+
+ cameraManager.setMirrorOutput(false)
+ #expect(cameraManager.attributes.mirrorOutput == false)
+ }
+}
+
+// MARK: Set Grid Visibility
+extension CameraManagerTests {
+ @Test("Set Grid Visibility") func setGridVisibility() async throws {
+ try await setupCamera()
+
+ cameraManager.setGridVisibility(true)
+ #expect(cameraManager.cameraGridView.alpha == 1)
+ #expect(cameraManager.attributes.isGridVisible == true)
+
+ cameraManager.setGridVisibility(false)
+ #expect(cameraManager.cameraGridView.alpha == 0)
+ #expect(cameraManager.attributes.isGridVisible == false)
+ }
+}
+
+// MARK: Set Camera Filters
+extension CameraManagerTests {
+ @Test("Set Camera Filters") func setCameraFilters() async throws {
+ try await setupCamera()
+
+ cameraManager.setCameraFilters([.init(name: "CISepiaTone")!])
+ #expect(cameraManager.attributes.cameraFilters.count == 1)
+ }
+}
+
+// MARK: Set Exposure Mode
+extension CameraManagerTests {
+ @Test("Set Exposure Mode") func setExposureMode() async throws {
+ try await setupCamera()
+
+ try cameraManager.setExposureMode(.continuousAutoExposure)
+ #expect(currentDevice.exposureMode == .continuousAutoExposure)
+ #expect(cameraManager.attributes.cameraExposure.mode == .continuousAutoExposure)
+
+ try cameraManager.setExposureMode(.autoExpose)
+ #expect(currentDevice.exposureMode == .autoExpose)
+ #expect(cameraManager.attributes.cameraExposure.mode == .autoExpose)
+
+ try cameraManager.setExposureMode(.custom)
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(cameraManager.attributes.cameraExposure.mode == .custom)
+ }
+}
+
+// MARK: Set Exposure Duration
+extension CameraManagerTests {
+ @Test("Set Exposure Duration") func setExposureDuration() async throws {
+ try await setupCamera()
+
+ try cameraManager.setExposureDuration(.init(value: 1, timescale: 33))
+ #expect(currentDevice.exposureDuration == .init(value: 1, timescale: 33))
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(cameraManager.attributes.cameraExposure.duration == .init(value: 1, timescale: 33))
+
+ try cameraManager.setExposureDuration(.init(value: 1, timescale: 100000))
+ #expect(currentDevice.exposureDuration == currentDevice.minExposureDuration)
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(cameraManager.attributes.cameraExposure.duration == currentDevice.minExposureDuration)
+
+ try cameraManager.setExposureDuration(.init(value: 1, timescale: 2))
+ #expect(currentDevice.exposureDuration == currentDevice.maxExposureDuration)
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(cameraManager.attributes.cameraExposure.duration == currentDevice.maxExposureDuration)
+ }
+}
+
+// MARK: Set ISO
+extension CameraManagerTests {
+ @Test("Set ISO") func setISO() async throws {
+ try await setupCamera()
+
+ try cameraManager.setISO(1)
+ #expect(currentDevice.iso == 1)
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(cameraManager.attributes.cameraExposure.iso == 1)
+
+ try cameraManager.setISO(-2137)
+ #expect(currentDevice.iso == currentDevice.minISO)
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(cameraManager.attributes.cameraExposure.iso == currentDevice.minISO)
+
+ try cameraManager.setISO(2137)
+ #expect(currentDevice.iso == currentDevice.maxISO)
+ #expect(currentDevice.exposureMode == .custom)
+ #expect(cameraManager.attributes.cameraExposure.iso == currentDevice.maxISO)
+ }
+}
+
+// MARK: Set Exposure Target Bias
+extension CameraManagerTests {
+ @Test("Set Exposure Target Bias") func setExposureTargetBias() async throws {
+ try await setupCamera()
+
+ try cameraManager.setExposureTargetBias(1)
+ #expect(currentDevice.exposureTargetBias == 1)
+ #expect(cameraManager.attributes.cameraExposure.targetBias == 1)
+
+ try cameraManager.setExposureTargetBias(-2137)
+ #expect(currentDevice.exposureTargetBias == currentDevice.minExposureTargetBias)
+ #expect(cameraManager.attributes.cameraExposure.targetBias == currentDevice.minExposureTargetBias)
+
+ try cameraManager.setExposureTargetBias(2137)
+ #expect(currentDevice.exposureTargetBias == currentDevice.maxExposureTargetBias)
+ #expect(cameraManager.attributes.cameraExposure.targetBias == currentDevice.maxExposureTargetBias)
+ }
+}
+
+// MARK: Set HDR Mode
+extension CameraManagerTests {
+ @Test("Set HDR Mode") func setHDRMode() async throws {
+ try await setupCamera()
+
+ try cameraManager.setHDRMode(.on)
+ #expect(currentDevice.hdrMode == .on)
+ #expect(cameraManager.attributes.hdrMode == .on)
+
+ try cameraManager.setHDRMode(.off)
+ #expect(currentDevice.hdrMode == .off)
+ #expect(cameraManager.attributes.hdrMode == .off)
+
+ try cameraManager.setHDRMode(.auto)
+ #expect(currentDevice.hdrMode == .auto)
+ #expect(cameraManager.attributes.hdrMode == .auto)
+ }
+}
+
+// MARK: Set Resolution
+extension CameraManagerTests {
+ @Test("Set Resolution") func setResolution() async throws {
+ try await setupCamera()
+
+ cameraManager.setResolution(.hd1280x720)
+ #expect(cameraManager.captureSession.sessionPreset == .hd1280x720)
+ #expect(cameraManager.attributes.resolution == .hd1280x720)
+
+ cameraManager.setResolution(.hd1920x1080)
+ #expect(cameraManager.captureSession.sessionPreset == .hd1920x1080)
+ #expect(cameraManager.attributes.resolution == .hd1920x1080)
+
+ cameraManager.setResolution(.cif352x288)
+ #expect(cameraManager.captureSession.sessionPreset == .cif352x288)
+ #expect(cameraManager.attributes.resolution == .cif352x288)
+ }
+}
+
+// MARK: Set Frame Rate
+extension CameraManagerTests {
+ @Test("Set Frame Rate") func setFrameRate() async throws {
+ try await setupCamera()
+
+ try cameraManager.setFrameRate(45)
+ #expect(currentDevice.activeVideoMinFrameDuration == .init(value: 1, timescale: 45))
+ #expect(currentDevice.activeVideoMaxFrameDuration == .init(value: 1, timescale: 45))
+ #expect(cameraManager.attributes.frameRate == 45)
+
+ try cameraManager.setFrameRate(10)
+ #expect(currentDevice.activeVideoMinFrameDuration.timescale == Int32(currentDevice.minFrameRate!))
+ #expect(currentDevice.activeVideoMaxFrameDuration.timescale == Int32(currentDevice.minFrameRate!))
+ #expect(cameraManager.attributes.frameRate == Int32(currentDevice.minFrameRate!))
+
+ try cameraManager.setFrameRate(100)
+ #expect(currentDevice.activeVideoMinFrameDuration.timescale == Int32(currentDevice.maxFrameRate!))
+ #expect(currentDevice.activeVideoMaxFrameDuration.timescale == Int32(currentDevice.maxFrameRate!))
+ #expect(cameraManager.attributes.frameRate == Int32(currentDevice.maxFrameRate!))
+ }
+}
+
+
+// MARK: Helpers
+private extension CameraManagerTests {
+ func setupCamera() async throws {
+ let cameraView = UIView(frame: .init(origin: .zero, size: .init(width: 1000, height: 1000)))
+
+ cameraManager.initialize(in: cameraView)
+ try await cameraManager.setup()
+ await Task.sleep(seconds: 10)
+ }
+}
+private extension CameraManagerTests {
+ var currentDevice: any CaptureDevice { cameraManager.getCameraInput()!.device }
+}