capacitor-camera-view 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorCameraView.podspec +17 -0
- package/LICENSE +201 -0
- package/Package.swift +28 -0
- package/README.md +654 -0
- package/android/build.gradle +79 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +555 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +227 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +11 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +14 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +10 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +16 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/ZoomFactors.kt +14 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +86 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +968 -0
- package/dist/esm/definitions.d.ts +378 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.js +10 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/utils.d.ts +45 -0
- package/dist/esm/utils.js +108 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/esm/web.d.ts +108 -0
- package/dist/esm/web.js +406 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +530 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +533 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/CameraViewPlugin/CameraError.swift +39 -0
- package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +32 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +91 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +52 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +78 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +633 -0
- package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +295 -0
- package/ios/Sources/CameraViewPlugin/Utils.swift +56 -0
- package/ios/Tests/CameraViewPluginTests/CameraViewPluginTests.swift +15 -0
- package/package.json +94 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
/// Supported camera device types for the capture session.
|
|
6
|
+
internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
|
|
7
|
+
.builtInWideAngleCamera,
|
|
8
|
+
.builtInUltraWideCamera,
|
|
9
|
+
.builtInTelephotoCamera,
|
|
10
|
+
.builtInDualCamera,
|
|
11
|
+
.builtInDualWideCamera,
|
|
12
|
+
.builtInTripleCamera,
|
|
13
|
+
.builtInTrueDepthCamera,
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
/// A camera implementation that handles camera session management and photo capture.
|
|
17
|
+
@objc public class CameraViewManager: NSObject {
|
|
18
|
+
internal let captureSession = AVCaptureSession()
|
|
19
|
+
internal let avPhotoOutput = AVCapturePhotoOutput()
|
|
20
|
+
internal let avVideoDataOutput = AVCaptureVideoDataOutput()
|
|
21
|
+
internal let videoPreviewLayer = AVCaptureVideoPreviewLayer()
|
|
22
|
+
|
|
23
|
+
/// The currently active camera device.
|
|
24
|
+
private var currentCameraDevice: AVCaptureDevice?
|
|
25
|
+
|
|
26
|
+
/// List of preferred camera devices, this overrides the SUPPORTED_CAMERA_DEVICE_TYPES for the capture session
|
|
27
|
+
private var preferredCameraDeviceTypes = SUPPORTED_CAMERA_DEVICE_TYPES
|
|
28
|
+
|
|
29
|
+
/// Currently selected flash mode.
|
|
30
|
+
private var flashMode: AVCaptureDevice.FlashMode = .auto
|
|
31
|
+
|
|
32
|
+
/// Reference to the blur overlay view that is shown when switching to the triple camera in order to have a smooth transition
|
|
33
|
+
private var blurOverlayView: UIVisualEffectView?
|
|
34
|
+
|
|
35
|
+
/// Reference to the webView that is used by the Capacitor plugin for the preview layer is shown on
|
|
36
|
+
private var webView: UIView?
|
|
37
|
+
|
|
38
|
+
/// Callback for when photo capture completes.
|
|
39
|
+
internal var photoCaptureHandler: ((UIImage?, Error?) -> Void)?
|
|
40
|
+
|
|
41
|
+
/// Callback for when snapshot capture completes.
|
|
42
|
+
internal var snapshotCompletionHandler: ((UIImage?, Error?) -> Void)?
|
|
43
|
+
|
|
44
|
+
public override init() {
|
|
45
|
+
super.init()
|
|
46
|
+
setupOrientationObserver()
|
|
47
|
+
setupAppLifecycleObservers()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
deinit {
|
|
51
|
+
stopSession()
|
|
52
|
+
NotificationCenter.default.removeObserver(self)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/// MARK: - Plugin API
|
|
56
|
+
|
|
57
|
+
/// Starts capture session for the specified camera position.
|
|
58
|
+
/// This will reuse the existing capture session if it is already running.
|
|
59
|
+
///
|
|
60
|
+
/// - Parameters:
|
|
61
|
+
/// - position: The position of the camera to start the session for.
|
|
62
|
+
/// - completion: A closure called when the session setup completes with an optional error.
|
|
63
|
+
public func startSession(
|
|
64
|
+
configuration: CameraSessionConfiguration,
|
|
65
|
+
webView: UIView,
|
|
66
|
+
completion: @escaping (Error?) -> Void
|
|
67
|
+
) {
|
|
68
|
+
if let preferredCameraDeviceTypes = configuration.preferredCameraDeviceTypes {
|
|
69
|
+
self.preferredCameraDeviceTypes = convertToNativeCameraTypes(preferredCameraDeviceTypes)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
73
|
+
guard let self = self else { return }
|
|
74
|
+
|
|
75
|
+
do {
|
|
76
|
+
try self.initiateCaptureSession(configuration: configuration)
|
|
77
|
+
} catch {
|
|
78
|
+
DispatchQueue.main.async {
|
|
79
|
+
completion(error)
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Start the capture session
|
|
85
|
+
self.captureSession.startRunning()
|
|
86
|
+
|
|
87
|
+
// Display the camera preview on the provided webview
|
|
88
|
+
self.displayPreview(
|
|
89
|
+
on: webView,
|
|
90
|
+
completion: { error in
|
|
91
|
+
if error != nil { completion(error) }
|
|
92
|
+
|
|
93
|
+
// Complete already because the camera is ready to be used
|
|
94
|
+
// We might asynchronously upgrade to a triple camera in the background if available and configured
|
|
95
|
+
completion(nil)
|
|
96
|
+
|
|
97
|
+
if configuration.useTripleCameraIfAvailable {
|
|
98
|
+
Task {
|
|
99
|
+
await self.upgradeToTripleCameraIfAvailable()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Stops the current capture session.
|
|
107
|
+
public func stopSession() {
|
|
108
|
+
guard captureSession.isRunning else { return }
|
|
109
|
+
|
|
110
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
111
|
+
self?.captureSession.stopRunning()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
DispatchQueue.main.async { [weak self] in
|
|
115
|
+
guard let self = self else { return }
|
|
116
|
+
self.videoPreviewLayer.removeFromSuperlayer()
|
|
117
|
+
self.webView?.isOpaque = true
|
|
118
|
+
self.webView?.backgroundColor = nil
|
|
119
|
+
self.webView = nil
|
|
120
|
+
|
|
121
|
+
if let blurOverlayView = self.blurOverlayView {
|
|
122
|
+
blurOverlayView.removeFromSuperview()
|
|
123
|
+
self.blurOverlayView = nil
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// Checks if the capture session is currently running.
|
|
129
|
+
public func isRunning() -> Bool {
|
|
130
|
+
return captureSession.isRunning
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/// Captures a photo with the current camera settings.
|
|
134
|
+
/// - Returns: The picture as UIImage via `AVCapturePhotoCaptureDelegate`
|
|
135
|
+
public func capturePhoto(completion: @escaping (UIImage?, Error?) -> Void) {
|
|
136
|
+
guard let cameraDevice = currentCameraDevice else {
|
|
137
|
+
completion(nil, CameraError.cameraUnavailable)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
guard captureSession.isRunning else {
|
|
142
|
+
completion(nil, CameraError.sessionNotRunning)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let photoSettings = AVCapturePhotoSettings()
|
|
147
|
+
if cameraDevice.hasFlash {
|
|
148
|
+
photoSettings.flashMode = flashMode
|
|
149
|
+
} else {
|
|
150
|
+
photoSettings.flashMode = .off
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Ensure proper orientation
|
|
154
|
+
if let photoConnection = avPhotoOutput.connection(with: .video),
|
|
155
|
+
let previewConnection = videoPreviewLayer.connection {
|
|
156
|
+
if photoConnection.isVideoOrientationSupported {
|
|
157
|
+
photoConnection.videoOrientation = previewConnection.videoOrientation
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
avPhotoOutput.capturePhoto(with: photoSettings, delegate: self)
|
|
162
|
+
photoCaptureHandler = completion
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Capture a snapshot of the current camera view. This is faster than actually processing a
|
|
166
|
+
/// photo via capturePhoto
|
|
167
|
+
/// - Parameter completion: called with the captured UIImage or an error.
|
|
168
|
+
public func captureSnapshot(completion: @escaping (UIImage?, Error?) -> Void) {
|
|
169
|
+
guard currentCameraDevice != nil else {
|
|
170
|
+
completion(nil, CameraError.cameraUnavailable)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
guard captureSession.isRunning else {
|
|
175
|
+
completion(nil, CameraError.sessionNotRunning)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Ensure proper orientation
|
|
180
|
+
if let videoConnection = avVideoDataOutput.connection(with: .video),
|
|
181
|
+
let previewConnection = videoPreviewLayer.connection {
|
|
182
|
+
if videoConnection.isVideoOrientationSupported {
|
|
183
|
+
videoConnection.videoOrientation = previewConnection.videoOrientation
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Create a serial queue for sample buffer processing
|
|
188
|
+
let sampleBufferQueue = DispatchQueue(label: "com.michaelwolz.capacitorcameraview.snapshotQueue")
|
|
189
|
+
|
|
190
|
+
// Set the delegate for a single frame capture
|
|
191
|
+
snapshotCompletionHandler = completion
|
|
192
|
+
avVideoDataOutput.setSampleBufferDelegate(self, queue: sampleBufferQueue)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// Flips the camera to the opposite position (front to back or back to front).
|
|
196
|
+
public func flipCamera() throws {
|
|
197
|
+
let currentPosition: AVCaptureDevice.Position = currentCameraDevice?.position ?? .back
|
|
198
|
+
let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back
|
|
199
|
+
|
|
200
|
+
let newCamera = try getCameraDevice(for: newPosition)
|
|
201
|
+
try setInput(with: newCamera)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Sets the flash mode for the currently active camera device.
|
|
205
|
+
///
|
|
206
|
+
/// - Parameter mode: The desired flash mode (.on, .of, or .auto).
|
|
207
|
+
/// - Throws: An error if the flash mode cannot be set or is not supported.
|
|
208
|
+
public func setFlashMode(_ mode: AVCaptureDevice.FlashMode) throws {
|
|
209
|
+
guard let camera = currentCameraDevice else { throw CameraError.cameraUnavailable }
|
|
210
|
+
guard camera.hasFlash else { throw CameraError.unsupportedFlashMode }
|
|
211
|
+
guard avPhotoOutput.supportedFlashModes.contains(mode)
|
|
212
|
+
else {
|
|
213
|
+
throw CameraError.unsupportedFlashMode
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
flashMode = mode
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/// Gets the current flash mode for the current camera device.
|
|
220
|
+
public func getFlashMode() -> AVCaptureDevice.FlashMode {
|
|
221
|
+
return flashMode
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/// Gets the supported flash modes for the current camera device.
|
|
225
|
+
///
|
|
226
|
+
/// - Returns: An array of supported flash modes, fallback is .off
|
|
227
|
+
public func getSupportedFlashModes() -> [AVCaptureDevice.FlashMode] {
|
|
228
|
+
if let camera = currentCameraDevice, camera.hasFlash {
|
|
229
|
+
return avPhotoOutput.supportedFlashModes
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return [.off]
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/// Gets the minimum, maximum, and current zoom factors supported by the current camera device.
|
|
236
|
+
/// The maximum zoom factor is limited to a reasonable value of 10x to prevent excessive zooming
|
|
237
|
+
/// because some devices report very high zoom factors that aren't useful.
|
|
238
|
+
///
|
|
239
|
+
/// - Returns: A tuple containing the minimum, maximum, and current zoom factors.
|
|
240
|
+
public func getSupportedZoomFactors() -> (min: CGFloat, max: CGFloat, current: CGFloat) {
|
|
241
|
+
guard let currentDevice = currentCameraDevice else {
|
|
242
|
+
return (
|
|
243
|
+
min: 1.0,
|
|
244
|
+
max: 1.0,
|
|
245
|
+
current: 1.0
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let minZoomFactor = currentDevice.minAvailableVideoZoomFactor
|
|
250
|
+
let maxZoomFactor = min(currentDevice.activeFormat.videoMaxZoomFactor, 10.0)
|
|
251
|
+
let currentZoomFactor = currentDevice.videoZoomFactor
|
|
252
|
+
|
|
253
|
+
return (
|
|
254
|
+
min: minZoomFactor,
|
|
255
|
+
max: maxZoomFactor,
|
|
256
|
+
current: currentZoomFactor
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// Sets the zoom factor for the current camera device.
|
|
261
|
+
///
|
|
262
|
+
/// - Parameters:
|
|
263
|
+
/// - factor: The zoom factor to set.
|
|
264
|
+
/// - ramp: If enabled the zoom will be applied via ramp
|
|
265
|
+
/// - Throws: An error if the zoom factor cannot be set.
|
|
266
|
+
public func setZoomFactor(_ factor: CGFloat, ramp: Bool = true) throws {
|
|
267
|
+
guard let device = currentCameraDevice else { throw CameraError.cameraUnavailable }
|
|
268
|
+
|
|
269
|
+
let supportedZoomFactors = getSupportedZoomFactors()
|
|
270
|
+
guard factor >= supportedZoomFactors.min && factor <= supportedZoomFactors.max else {
|
|
271
|
+
throw CameraError.zoomFactorOutOfRange
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
do {
|
|
275
|
+
try device.lockForConfiguration()
|
|
276
|
+
defer { device.unlockForConfiguration() }
|
|
277
|
+
|
|
278
|
+
if ramp {
|
|
279
|
+
device.ramp(toVideoZoomFactor: factor, withRate: 6.0)
|
|
280
|
+
} else {
|
|
281
|
+
device.videoZoomFactor = factor
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
throw CameraError.configurationFailed(error)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Initiates the capture session with the specified camera device.
|
|
289
|
+
///
|
|
290
|
+
/// - Parameters:
|
|
291
|
+
/// - configuration: The configuration object for the camera session.
|
|
292
|
+
private func initiateCaptureSession(configuration: CameraSessionConfiguration) throws {
|
|
293
|
+
captureSession.beginConfiguration()
|
|
294
|
+
defer { captureSession.commitConfiguration() }
|
|
295
|
+
|
|
296
|
+
// Configure the camera device
|
|
297
|
+
let device: AVCaptureDevice
|
|
298
|
+
if let deviceId = configuration.deviceId {
|
|
299
|
+
device = try getCameraDeviceById(deviceId)
|
|
300
|
+
} else {
|
|
301
|
+
device = try getCameraDevice(for: configuration.position)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Set the session preset to photo if supported (which should be the case for all devices)
|
|
305
|
+
if captureSession.canSetSessionPreset(.photo) {
|
|
306
|
+
captureSession.sessionPreset = .photo
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Set the camera input
|
|
310
|
+
try setInput(with: device)
|
|
311
|
+
|
|
312
|
+
// Set up the photo output
|
|
313
|
+
try setupPhotoOutput()
|
|
314
|
+
|
|
315
|
+
// Set up the video data output for snapshots
|
|
316
|
+
try setupVideoDataOutput()
|
|
317
|
+
|
|
318
|
+
// Setup metadata output for QR code scanning if enabled
|
|
319
|
+
if configuration.enableBarcodeDetection {
|
|
320
|
+
try setupMetadataOutput()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Set the initial zoom factor if specified
|
|
324
|
+
if let zoomFactor = configuration.zoomFactor {
|
|
325
|
+
try setZoomFactor(zoomFactor, ramp: false)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// Sets the input for the capture session.
|
|
330
|
+
/// Make sure to call `captureSession.beginConfiguration` before calling this
|
|
331
|
+
///
|
|
332
|
+
/// - Parameter device: The camera device to use as input.
|
|
333
|
+
/// - Throws: An error if the input cannot be set.
|
|
334
|
+
private func setInput(with device: AVCaptureDevice) throws {
|
|
335
|
+
guard currentCameraDevice?.uniqueID != device.uniqueID else {
|
|
336
|
+
// Nothing todo, input is already configured for the desired device
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Remove any existing inputs
|
|
341
|
+
captureSession.inputs.forEach { captureSession.removeInput($0) }
|
|
342
|
+
|
|
343
|
+
do {
|
|
344
|
+
let input = try AVCaptureDeviceInput(device: device)
|
|
345
|
+
if !captureSession.canAddInput(input) {
|
|
346
|
+
throw CameraError.inputAdditionFailed
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
captureSession.addInput(input)
|
|
350
|
+
currentCameraDevice = device
|
|
351
|
+
} catch {
|
|
352
|
+
if let avError = error as? AVError {
|
|
353
|
+
throw CameraError.configurationFailed(avError)
|
|
354
|
+
} else {
|
|
355
|
+
throw CameraError.inputAdditionFailed
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/// Retrieve a list of a available camera devices
|
|
361
|
+
///
|
|
362
|
+
/// - Returns: a list of all available camera devices.
|
|
363
|
+
public func getAvailableDevices() -> [AVCaptureDevice] {
|
|
364
|
+
return AVCaptureDevice.DiscoverySession(
|
|
365
|
+
deviceTypes: SUPPORTED_CAMERA_DEVICE_TYPES,
|
|
366
|
+
mediaType: .video,
|
|
367
|
+
position: .unspecified
|
|
368
|
+
).devices
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// Returns a list of available camera devices based on the preferences by the user
|
|
372
|
+
///
|
|
373
|
+
/// - Returns: a list of camera devices based on the preferredCameraDeviceTypes
|
|
374
|
+
private func getPreferredCameraDevices() -> [AVCaptureDevice] {
|
|
375
|
+
return AVCaptureDevice.DiscoverySession(
|
|
376
|
+
deviceTypes: self.preferredCameraDeviceTypes,
|
|
377
|
+
mediaType: .video,
|
|
378
|
+
position: .unspecified
|
|
379
|
+
).devices
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/// Gets the best match camera device for the specified position.
|
|
383
|
+
/// This method will consider preferredCameraDevices as possibly provided by the user allowing a best
|
|
384
|
+
/// match to the users request.
|
|
385
|
+
///
|
|
386
|
+
/// - Parameters:
|
|
387
|
+
/// - position: The position of the camera device to get
|
|
388
|
+
/// - Returns: The camera device for the specified position
|
|
389
|
+
/// - Throws: An error if no camera device is found.
|
|
390
|
+
private func getCameraDevice(for position: AVCaptureDevice.Position?) throws -> AVCaptureDevice
|
|
391
|
+
{
|
|
392
|
+
let preferredDevices = getPreferredCameraDevices()
|
|
393
|
+
|
|
394
|
+
// First try to get the best match based on the users preferred camera device types
|
|
395
|
+
if let match = preferredDevices.first(where: { $0.position == position }) {
|
|
396
|
+
return match
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// If we haven't found one we try to get a best match for the position by iterating all supported device types
|
|
400
|
+
// Only doing this when preferredCameraDeviceTypes size differs from SUPPORTED_CAMERA_DEVICE_TYPES, otherwise
|
|
401
|
+
// we don't have to initialize a new discovery session
|
|
402
|
+
if preferredCameraDeviceTypes.count < SUPPORTED_CAMERA_DEVICE_TYPES.count,
|
|
403
|
+
let match = getAvailableDevices().first(where: { $0.position == position }) {
|
|
404
|
+
return match
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Otherwise, fallback to default video device or throw an error
|
|
408
|
+
guard let device = AVCaptureDevice.default(for: .video) else {
|
|
409
|
+
throw CameraError.cameraUnavailable
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Log when we're falling back to a device with different position than requested
|
|
413
|
+
if let requestedPosition = position, device.position != requestedPosition {
|
|
414
|
+
print("Warning: Falling back to camera at position \(device.position) when \(requestedPosition) was requested")
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return device
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// Gets the best camera device for the specified position.
|
|
421
|
+
///
|
|
422
|
+
/// - Parameters:
|
|
423
|
+
/// - deviceId: The unique identifier of the camera device to get
|
|
424
|
+
/// - Returns: The camera device for the specified position
|
|
425
|
+
/// - Throws: An error if no camera device is found.
|
|
426
|
+
private func getCameraDeviceById(_ deviceId: String) throws -> AVCaptureDevice {
|
|
427
|
+
guard let device = getAvailableDevices().first(where: { $0.uniqueID == deviceId }) else {
|
|
428
|
+
throw CameraError.cameraUnavailable
|
|
429
|
+
}
|
|
430
|
+
return device
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/// MARK: - UI Preview Layer
|
|
434
|
+
|
|
435
|
+
/// Sets up the preview layer for the capture session which will
|
|
436
|
+
/// display the camera feed in the view.
|
|
437
|
+
///
|
|
438
|
+
/// - Parameters:
|
|
439
|
+
/// - view: The view that will display the camera preview.
|
|
440
|
+
/// - completion: The completion handler after successfully adding the previewLayer to the provided view
|
|
441
|
+
/// - Throws: An error if the preview layer cannot be set up.
|
|
442
|
+
private func displayPreview(on view: UIView, completion: @escaping (Error?) -> Void) {
|
|
443
|
+
guard captureSession.isRunning else {
|
|
444
|
+
completion(CameraError.sessionNotRunning)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
self.webView = view
|
|
449
|
+
|
|
450
|
+
videoPreviewLayer.session = captureSession
|
|
451
|
+
videoPreviewLayer.videoGravity = .resizeAspectFill
|
|
452
|
+
|
|
453
|
+
DispatchQueue.main.async { [weak self] in
|
|
454
|
+
guard let self = self else { return }
|
|
455
|
+
view.isOpaque = false
|
|
456
|
+
view.backgroundColor = UIColor.clear
|
|
457
|
+
view.scrollView.backgroundColor = UIColor.clear
|
|
458
|
+
|
|
459
|
+
self.videoPreviewLayer.frame = view.bounds
|
|
460
|
+
view.layer.insertSublayer(self.videoPreviewLayer, at: 0)
|
|
461
|
+
|
|
462
|
+
self.updatePreviewOrientation()
|
|
463
|
+
|
|
464
|
+
completion(nil)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/// MARK: - Triple Camera
|
|
469
|
+
|
|
470
|
+
/// Upgrades the camera to the triple camera if available.
|
|
471
|
+
/// Initializing the triple camera is an expensive operation and takes some time.
|
|
472
|
+
/// This is why by default the regular physical camera is used and then later upgraded to the triple camera if available (Pro models only).
|
|
473
|
+
private func upgradeToTripleCameraIfAvailable() async {
|
|
474
|
+
guard captureSession.isRunning else { return }
|
|
475
|
+
|
|
476
|
+
// Check if a triple camera is available (only on newer Pro models)
|
|
477
|
+
let devices = AVCaptureDevice.DiscoverySession(
|
|
478
|
+
deviceTypes: [.builtInTripleCamera],
|
|
479
|
+
mediaType: .video,
|
|
480
|
+
position: .back
|
|
481
|
+
).devices
|
|
482
|
+
|
|
483
|
+
// If we don't have a triple camera, exit early
|
|
484
|
+
guard let tripleCamera = devices.first else { return }
|
|
485
|
+
|
|
486
|
+
// Don't do anything if we're already using the triple camera
|
|
487
|
+
if currentCameraDevice?.uniqueID == tripleCamera.uniqueID {
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Add a blur overlay to the webview to have a smooth transition when switching to the triple camera
|
|
492
|
+
await addBlurOverlay()
|
|
493
|
+
|
|
494
|
+
await Task.detached(priority: .userInitiated) {
|
|
495
|
+
self.captureSession.beginConfiguration()
|
|
496
|
+
|
|
497
|
+
do {
|
|
498
|
+
try self.setInput(with: tripleCamera)
|
|
499
|
+
// TODO: Consider configured zoom factor from the initial camera???
|
|
500
|
+
try self.setZoomFactor(2.0, ramp: false)
|
|
501
|
+
} catch {
|
|
502
|
+
// Fail silently if we can't upgrade to the triple camera
|
|
503
|
+
print("Failed to upgrade to triple camera: \(error.localizedDescription)")
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
self.captureSession.commitConfiguration()
|
|
507
|
+
}.value
|
|
508
|
+
|
|
509
|
+
// Small delay to let camera stabilize
|
|
510
|
+
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
|
|
511
|
+
|
|
512
|
+
await removeBlurOverlayWithAnimation()
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/// Adds a blur overlay to the webview to have a smooth transition when switching to the triple camera
|
|
516
|
+
@MainActor
|
|
517
|
+
private func addBlurOverlay() async {
|
|
518
|
+
guard let view = self.webView else { return }
|
|
519
|
+
|
|
520
|
+
let blurEffect = UIBlurEffect(style: .light)
|
|
521
|
+
let blurOverlayView = UIVisualEffectView(effect: blurEffect)
|
|
522
|
+
self.blurOverlayView = blurOverlayView
|
|
523
|
+
|
|
524
|
+
blurOverlayView.frame = view.bounds
|
|
525
|
+
blurOverlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
526
|
+
|
|
527
|
+
// Add the blurEffect layer to the view hierarchy just above the preview layer
|
|
528
|
+
// but below the web content
|
|
529
|
+
view.insertSubview(blurOverlayView, at: 1)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/// Removes the blur overlay with a fade out animation to have a smooth transition
|
|
533
|
+
/// - Parameter duration: The duration of the fade out animation
|
|
534
|
+
@MainActor
|
|
535
|
+
private func removeBlurOverlayWithAnimation(duration: TimeInterval = 0.3) async {
|
|
536
|
+
guard let blurEffectView = blurOverlayView else { return }
|
|
537
|
+
|
|
538
|
+
await withCheckedContinuation { continuation in
|
|
539
|
+
UIView.animate(
|
|
540
|
+
withDuration: duration,
|
|
541
|
+
animations: {
|
|
542
|
+
blurEffectView.alpha = 0
|
|
543
|
+
},
|
|
544
|
+
completion: { _ in
|
|
545
|
+
blurEffectView.removeFromSuperview()
|
|
546
|
+
self.blurOverlayView = nil
|
|
547
|
+
continuation.resume()
|
|
548
|
+
})
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/// MARK: - Orientation Observer
|
|
553
|
+
|
|
554
|
+
/// Sets up an observer for device orientation changes to update the preview layer orientation.
|
|
555
|
+
private func setupOrientationObserver() {
|
|
556
|
+
NotificationCenter.default.addObserver(
|
|
557
|
+
forName: UIDevice.orientationDidChangeNotification,
|
|
558
|
+
object: nil,
|
|
559
|
+
queue: .main
|
|
560
|
+
) { [weak self] _ in
|
|
561
|
+
self?.updatePreviewOrientation()
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/// Updates the preview layer orientation based on the current device orientation.
|
|
566
|
+
private func updatePreviewOrientation() {
|
|
567
|
+
guard let connection = self.videoPreviewLayer.connection, connection.isVideoOrientationSupported else {
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let interfaceOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait
|
|
572
|
+
let videoOrientation: AVCaptureVideoOrientation
|
|
573
|
+
|
|
574
|
+
switch interfaceOrientation {
|
|
575
|
+
case .portrait:
|
|
576
|
+
videoOrientation = .portrait
|
|
577
|
+
case .landscapeLeft:
|
|
578
|
+
videoOrientation = .landscapeLeft
|
|
579
|
+
case .landscapeRight:
|
|
580
|
+
videoOrientation = .landscapeRight
|
|
581
|
+
case .portraitUpsideDown:
|
|
582
|
+
videoOrientation = .portraitUpsideDown
|
|
583
|
+
default:
|
|
584
|
+
videoOrientation = .portrait
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
connection.videoOrientation = videoOrientation
|
|
588
|
+
|
|
589
|
+
// Update the frame of the preview layer to match the new bounds
|
|
590
|
+
DispatchQueue.main.async { [weak self] in
|
|
591
|
+
guard let self = self, let view = self.webView else { return }
|
|
592
|
+
self.videoPreviewLayer.frame = view.bounds
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/// MARK: - App Lifecycle Observers
|
|
597
|
+
|
|
598
|
+
/// Sets up observers for app lifecycle events to pause and resume the camera session.
|
|
599
|
+
private func setupAppLifecycleObservers() {
|
|
600
|
+
NotificationCenter.default.addObserver(
|
|
601
|
+
self,
|
|
602
|
+
selector: #selector(handleAppWillResignActive),
|
|
603
|
+
name: UIApplication.willResignActiveNotification,
|
|
604
|
+
object: nil
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
NotificationCenter.default.addObserver(
|
|
608
|
+
self,
|
|
609
|
+
selector: #selector(handleAppDidBecomeActive),
|
|
610
|
+
name: UIApplication.didBecomeActiveNotification,
|
|
611
|
+
object: nil)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/// Handles the app going to background by pausing the camera session.
|
|
615
|
+
@objc private func handleAppWillResignActive() {
|
|
616
|
+
// Pause the session when app goes to background to save resources
|
|
617
|
+
if captureSession.isRunning {
|
|
618
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
619
|
+
self?.captureSession.stopRunning()
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/// Handles the app coming back to foreground by resuming the camera session.
|
|
625
|
+
@objc private func handleAppDidBecomeActive() {
|
|
626
|
+
// Resume the session when app comes back to foreground
|
|
627
|
+
if !captureSession.isRunning && webView != nil {
|
|
628
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
629
|
+
self?.captureSession.startRunning()
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|