capacitor-camera-view 2.2.0 → 2.3.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.
@@ -13,13 +13,13 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
13
13
  .builtInDualCamera,
14
14
  .builtInDualWideCamera,
15
15
  .builtInTripleCamera,
16
- .builtInTrueDepthCamera,
16
+ .builtInTrueDepthCamera
17
17
  ]
18
18
 
19
19
  /// A camera implementation that handles camera session management and photo capture.
20
20
  @objc public class CameraViewManager: NSObject {
21
21
  // MARK: - Shared Resources
22
-
22
+
23
23
  /// Metal-backed CIContext singleton for efficient image processing.
24
24
  /// Creating CIContext per frame is extremely expensive (80%+ CPU waste).
25
25
  /// This shared instance uses Metal for GPU acceleration when available.
@@ -29,69 +29,81 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
29
29
  }
30
30
  return CIContext(options: [.useSoftwareRenderer: true])
31
31
  }()
32
-
32
+
33
33
  // MARK: - Capture Session Components
34
-
34
+
35
35
  internal let captureSession = AVCaptureSession()
36
36
  internal let avPhotoOutput = AVCapturePhotoOutput()
37
37
  internal let avVideoDataOutput = AVCaptureVideoDataOutput()
38
38
  internal let videoPreviewLayer = AVCaptureVideoPreviewLayer()
39
-
39
+
40
40
  /// Dedicated queue for all capture session operations.
41
41
  /// Using a consistent queue prevents race conditions and ensures thread safety.
42
- private let sessionQueue = DispatchQueue(
42
+ internal let sessionQueue = DispatchQueue(
43
43
  label: "com.michaelwolz.capacitorcameraview.session",
44
44
  qos: .userInitiated
45
45
  )
46
-
46
+
47
47
  /// Reusable queue for sample buffer processing during snapshot capture.
48
48
  /// Creating a new queue per snapshot causes memory allocation churn.
49
- private let sampleBufferQueue = DispatchQueue(
49
+ internal let sampleBufferQueue = DispatchQueue(
50
50
  label: "com.michaelwolz.capacitorcameraview.sampleBuffer",
51
51
  qos: .userInitiated
52
52
  )
53
-
53
+
54
54
  /// The currently active camera device.
55
- private var currentCameraDevice: AVCaptureDevice?
56
-
55
+ internal var currentCameraDevice: AVCaptureDevice?
56
+
57
57
  /// List of preferred camera devices, this overrides the SUPPORTED_CAMERA_DEVICE_TYPES for the capture session
58
58
  private var preferredCameraDeviceTypes = SUPPORTED_CAMERA_DEVICE_TYPES
59
-
59
+
60
60
  /// Currently selected flash mode.
61
61
  private var flashMode: AVCaptureDevice.FlashMode = .auto
62
-
62
+
63
63
  /// Reference to the blur overlay view that is shown when switching to the triple camera in order to have a smooth transition
64
64
  private var blurOverlayView: UIVisualEffectView?
65
-
65
+
66
66
  /// Reference to the webView that is used by the Capacitor plugin for the preview layer is shown on
67
67
  private var webView: UIView?
68
-
68
+
69
69
  /// Callback for when photo capture completes (legacy UIImage-based API).
70
70
  internal var photoCaptureHandler: ((UIImage?, Error?) -> Void)?
71
-
71
+
72
72
  /// Callback for when photo capture completes with raw Data (optimized API).
73
73
  /// This avoids double JPEG encoding by returning the camera's JPEG data directly.
74
74
  internal var photoDataCaptureHandler: ((Data?, Error?) -> Void)?
75
-
75
+
76
76
  /// Callback for when snapshot capture completes.
77
77
  internal var snapshotCompletionHandler: ((UIImage?, Error?) -> Void)?
78
-
78
+
79
79
  /// Emits typed camera events to the delegate and NotificationCenter.
80
80
  internal let eventEmitter = CameraEventEmitter()
81
-
81
+
82
+ /// Movie file output for video recording.
83
+ internal let avMovieOutput = AVCaptureMovieFileOutput()
84
+
85
+ /// Callback invoked when video recording completes with the output URL or an error.
86
+ internal var videoRecordingCompletionHandler: ((URL?, Error?) -> Void)?
87
+
88
+ /// Whether audio was added to the session for the current recording.
89
+ internal var recordingWithAudio = false
90
+
91
+ /// Session preset used before starting recording, restored when recording ends.
92
+ internal var sessionPresetBeforeRecording: AVCaptureSession.Preset?
93
+
82
94
  override public init() {
83
95
  super.init()
84
96
  setupOrientationObserver()
85
97
  setupAppLifecycleObservers()
86
98
  }
87
-
99
+
88
100
  deinit {
89
101
  stopSession()
90
102
  NotificationCenter.default.removeObserver(self)
91
103
  }
92
-
104
+
93
105
  // MARK: - Plugin API
94
-
106
+
95
107
  /// Starts capture session for the specified camera position.
96
108
  /// This will reuse the existing capture session if it is already running.
97
109
  ///
@@ -104,16 +116,15 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
104
116
  completion: @escaping (Error?) -> Void
105
117
  ) {
106
118
  if let preferredCameraDeviceTypes = configuration
107
- .preferredCameraDeviceTypes
108
- {
119
+ .preferredCameraDeviceTypes {
109
120
  self.preferredCameraDeviceTypes = convertToNativeCameraTypes(
110
121
  preferredCameraDeviceTypes
111
122
  )
112
123
  }
113
-
124
+
114
125
  sessionQueue.async { [weak self] in
115
126
  guard let self = self else { return }
116
-
127
+
117
128
  do {
118
129
  try self.initiateCaptureSession(configuration: configuration)
119
130
  } catch {
@@ -122,10 +133,10 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
122
133
  }
123
134
  return
124
135
  }
125
-
136
+
126
137
  // Start the capture session
127
138
  self.captureSession.startRunning()
128
-
139
+
129
140
  // Display the camera preview on the provided webview
130
141
  self.displayPreview(
131
142
  on: webView,
@@ -134,7 +145,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
134
145
  completion(error)
135
146
  return
136
147
  }
137
-
148
+
138
149
  // Handle barcode detection after session is running
139
150
  if configuration.enableBarcodeDetection {
140
151
  do {
@@ -144,10 +155,10 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
144
155
  return
145
156
  }
146
157
  }
147
-
158
+
148
159
  // Complete already because the camera is ready to be used
149
160
  completion(nil)
150
-
161
+
151
162
  // We might asynchronously upgrade to a triple camera in the background if available and configured
152
163
  if configuration.useTripleCameraIfAvailable {
153
164
  Task {
@@ -158,17 +169,23 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
158
169
  )
159
170
  }
160
171
  }
161
-
162
- /// Stops the current capture session and cleans up temporary files.
172
+
173
+ /// Stops the current capture session
163
174
  public func stopSession(completion: (() -> Void)? = nil) {
164
175
  guard captureSession.isRunning else {
165
176
  completion?()
166
177
  return
167
178
  }
168
-
179
+
169
180
  sessionQueue.async { [weak self] in
181
+ if let self = self, self.avMovieOutput.isRecording {
182
+ self.avMovieOutput.stopRecording()
183
+ self.videoRecordingCompletionHandler = nil
184
+ self.recordingWithAudio = false
185
+ }
186
+
170
187
  self?.captureSession.stopRunning()
171
-
188
+
172
189
  DispatchQueue.main.async { [weak self] in
173
190
  guard let self = self else {
174
191
  completion?()
@@ -178,22 +195,22 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
178
195
  self.webView?.isOpaque = true
179
196
  self.webView?.backgroundColor = nil
180
197
  self.webView = nil
181
-
198
+
182
199
  if let blurOverlayView = self.blurOverlayView {
183
200
  blurOverlayView.removeFromSuperview()
184
201
  self.blurOverlayView = nil
185
202
  }
186
-
203
+
187
204
  completion?()
188
205
  }
189
206
  }
190
207
  }
191
-
208
+
192
209
  /// Checks if the capture session is currently running.
193
210
  public func isRunning() -> Bool {
194
211
  return captureSession.isRunning
195
212
  }
196
-
213
+
197
214
  /// Captures a photo with the current camera settings.
198
215
  /// - Returns: The picture as UIImage via `AVCapturePhotoCaptureDelegate`
199
216
  public func capturePhoto(completion: @escaping (UIImage?, Error?) -> Void) {
@@ -201,32 +218,31 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
201
218
  completion(nil, CameraError.cameraUnavailable)
202
219
  return
203
220
  }
204
-
221
+
205
222
  guard captureSession.isRunning else {
206
223
  completion(nil, CameraError.sessionNotRunning)
207
224
  return
208
225
  }
209
-
226
+
210
227
  let photoSettings = AVCapturePhotoSettings()
211
228
  if cameraDevice.hasFlash {
212
229
  photoSettings.flashMode = flashMode
213
230
  } else {
214
231
  photoSettings.flashMode = .off
215
232
  }
216
-
233
+
217
234
  // Ensure proper orientation
218
235
  if let photoConnection = avPhotoOutput.connection(with: .video),
219
- let previewConnection = videoPreviewLayer.connection
220
- {
236
+ let previewConnection = videoPreviewLayer.connection {
221
237
  if photoConnection.isVideoOrientationSupported {
222
238
  photoConnection.videoOrientation = previewConnection.videoOrientation
223
239
  }
224
240
  }
225
-
241
+
226
242
  avPhotoOutput.capturePhoto(with: photoSettings, delegate: self)
227
243
  photoCaptureHandler = completion
228
244
  }
229
-
245
+
230
246
  /// Captures a photo and returns the raw JPEG data directly.
231
247
  /// This optimized method avoids double JPEG encoding by returning the camera's
232
248
  /// native JPEG data instead of converting through UIImage.
@@ -237,32 +253,31 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
237
253
  completion(nil, CameraError.cameraUnavailable)
238
254
  return
239
255
  }
240
-
256
+
241
257
  guard captureSession.isRunning else {
242
258
  completion(nil, CameraError.sessionNotRunning)
243
259
  return
244
260
  }
245
-
261
+
246
262
  let photoSettings = AVCapturePhotoSettings()
247
263
  if cameraDevice.hasFlash {
248
264
  photoSettings.flashMode = flashMode
249
265
  } else {
250
266
  photoSettings.flashMode = .off
251
267
  }
252
-
268
+
253
269
  // Ensure proper orientation
254
270
  if let photoConnection = avPhotoOutput.connection(with: .video),
255
- let previewConnection = videoPreviewLayer.connection
256
- {
271
+ let previewConnection = videoPreviewLayer.connection {
257
272
  if photoConnection.isVideoOrientationSupported {
258
273
  photoConnection.videoOrientation = previewConnection.videoOrientation
259
274
  }
260
275
  }
261
-
276
+
262
277
  avPhotoOutput.capturePhoto(with: photoSettings, delegate: self)
263
278
  photoDataCaptureHandler = completion
264
279
  }
265
-
280
+
266
281
  /// Capture a snapshot of the current camera view. This is faster than actually processing a
267
282
  /// photo via capturePhoto
268
283
  /// - Parameter completion: called with the captured UIImage or an error.
@@ -273,21 +288,20 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
273
288
  completion(nil, CameraError.cameraUnavailable)
274
289
  return
275
290
  }
276
-
291
+
277
292
  guard captureSession.isRunning else {
278
293
  completion(nil, CameraError.sessionNotRunning)
279
294
  return
280
295
  }
281
-
296
+
282
297
  // Ensure proper orientation
283
298
  if let videoConnection = avVideoDataOutput.connection(with: .video),
284
- let previewConnection = videoPreviewLayer.connection
285
- {
299
+ let previewConnection = videoPreviewLayer.connection {
286
300
  if videoConnection.isVideoOrientationSupported {
287
301
  videoConnection.videoOrientation = previewConnection.videoOrientation
288
302
  }
289
303
  }
290
-
304
+
291
305
  // Set the delegate for a single frame capture using the reusable queue
292
306
  snapshotCompletionHandler = completion
293
307
  avVideoDataOutput.setSampleBufferDelegate(
@@ -295,18 +309,18 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
295
309
  queue: sampleBufferQueue
296
310
  )
297
311
  }
298
-
312
+
299
313
  /// Flips the camera to the opposite position (front to back or back to front).
300
314
  public func flipCamera() throws {
301
315
  let currentPosition: AVCaptureDevice.Position =
302
316
  currentCameraDevice?.position ?? .back
303
317
  let newPosition: AVCaptureDevice.Position =
304
318
  currentPosition == .back ? .front : .back
305
-
319
+
306
320
  let newCamera = try getCameraDevice(for: newPosition)
307
321
  try setInput(with: newCamera)
308
322
  }
309
-
323
+
310
324
  /// Sets the flash mode for the currently active camera device.
311
325
  ///
312
326
  /// - Parameter mode: The desired flash mode (.on, .of, or .auto).
@@ -320,15 +334,15 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
320
334
  else {
321
335
  throw CameraError.unsupportedFlashMode
322
336
  }
323
-
337
+
324
338
  flashMode = mode
325
339
  }
326
-
340
+
327
341
  /// Gets the current flash mode for the current camera device.
328
342
  public func getFlashMode() -> AVCaptureDevice.FlashMode {
329
343
  return flashMode
330
344
  }
331
-
345
+
332
346
  /// Gets the supported flash modes for the current camera device.
333
347
  ///
334
348
  /// - Returns: An array of supported flash modes, fallback is .off
@@ -336,10 +350,10 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
336
350
  if let camera = currentCameraDevice, camera.hasFlash {
337
351
  return avPhotoOutput.supportedFlashModes
338
352
  }
339
-
353
+
340
354
  return [.off]
341
355
  }
342
-
356
+
343
357
  /// Checks if torch is available on the current camera device.
344
358
  ///
345
359
  /// - Returns: True if torch is available, false otherwise
@@ -347,19 +361,19 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
347
361
  guard let camera = currentCameraDevice else { return false }
348
362
  return camera.hasTorch
349
363
  }
350
-
364
+
351
365
  /// Gets the current torch mode and level.
352
366
  ///
353
367
  /// - Returns: A tuple containing the torch enabled state and level
354
368
  public func getTorchMode() -> (enabled: Bool, level: Float) {
355
369
  guard let camera = currentCameraDevice else { return (false, 0.0) }
356
-
370
+
357
371
  let isEnabled = camera.torchMode == .on
358
372
  let level = camera.torchLevel
359
-
373
+
360
374
  return (isEnabled, level)
361
375
  }
362
-
376
+
363
377
  /// Sets the torch mode and level for the currently active camera device.
364
378
  ///
365
379
  /// - Parameters:
@@ -371,11 +385,11 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
371
385
  throw CameraError.cameraUnavailable
372
386
  }
373
387
  guard camera.hasTorch else { throw CameraError.torchUnavailable }
374
-
388
+
375
389
  do {
376
390
  try camera.lockForConfiguration()
377
391
  defer { camera.unlockForConfiguration() }
378
-
392
+
379
393
  if enabled && level > 0.0 {
380
394
  try camera.setTorchModeOn(level: level)
381
395
  } else {
@@ -385,7 +399,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
385
399
  throw CameraError.configurationFailed(error)
386
400
  }
387
401
  }
388
-
402
+
389
403
  /// Gets the minimum, maximum, and current zoom factors supported by the current camera device.
390
404
  /// The maximum zoom factor is limited to a reasonable value of 10x to prevent excessive zooming
391
405
  /// because some devices report very high zoom factors that aren't useful.
@@ -401,21 +415,21 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
401
415
  current: 1.0
402
416
  )
403
417
  }
404
-
418
+
405
419
  let minZoomFactor = currentDevice.minAvailableVideoZoomFactor
406
420
  let maxZoomFactor = min(
407
421
  currentDevice.activeFormat.videoMaxZoomFactor,
408
422
  10.0
409
423
  )
410
424
  let currentZoomFactor = currentDevice.videoZoomFactor
411
-
425
+
412
426
  return (
413
427
  min: minZoomFactor,
414
428
  max: maxZoomFactor,
415
429
  current: currentZoomFactor
416
430
  )
417
431
  }
418
-
432
+
419
433
  /// Sets the zoom factor for the current camera device.
420
434
  ///
421
435
  /// - Parameters:
@@ -426,7 +440,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
426
440
  guard let device = currentCameraDevice else {
427
441
  throw CameraError.cameraUnavailable
428
442
  }
429
-
443
+
430
444
  let supportedZoomFactors = getSupportedZoomFactors()
431
445
  guard
432
446
  factor >= supportedZoomFactors.min
@@ -434,11 +448,11 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
434
448
  else {
435
449
  throw CameraError.zoomFactorOutOfRange
436
450
  }
437
-
451
+
438
452
  do {
439
453
  try device.lockForConfiguration()
440
454
  defer { device.unlockForConfiguration() }
441
-
455
+
442
456
  if ramp {
443
457
  device.ramp(toVideoZoomFactor: factor, withRate: 6.0)
444
458
  } else {
@@ -448,7 +462,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
448
462
  throw CameraError.configurationFailed(error)
449
463
  }
450
464
  }
451
-
465
+
452
466
  /// Initiates the capture session with the specified camera device.
453
467
  ///
454
468
  /// - Parameters:
@@ -458,7 +472,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
458
472
  ) throws {
459
473
  captureSession.beginConfiguration()
460
474
  defer { captureSession.commitConfiguration() }
461
-
475
+
462
476
  // Configure the camera device
463
477
  let device: AVCaptureDevice
464
478
  if let deviceId = configuration.deviceId {
@@ -466,33 +480,33 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
466
480
  } else {
467
481
  device = try getCameraDevice(for: configuration.position)
468
482
  }
469
-
483
+
470
484
  // Set the session preset to photo if supported (which should be the case for all devices)
471
485
  if captureSession.canSetSessionPreset(.photo) {
472
486
  captureSession.sessionPreset = .photo
473
487
  }
474
-
488
+
475
489
  // Set the camera input
476
490
  try setInput(with: device)
477
-
491
+
478
492
  // Set up the photo output
479
493
  try setupPhotoOutput()
480
-
494
+
481
495
  // Set up the video data output for snapshots
482
496
  try setupVideoDataOutput()
483
-
497
+
484
498
  if !configuration.enableBarcodeDetection {
485
499
  // Remove the metadata output in case it already existed for the
486
500
  // capture session
487
501
  removeMetadataOutput()
488
502
  }
489
-
503
+
490
504
  // Set the initial zoom factor if specified
491
505
  if let zoomFactor = configuration.zoomFactor {
492
506
  try setZoomFactor(zoomFactor, ramp: false)
493
507
  }
494
508
  }
495
-
509
+
496
510
  /// Sets the input for the capture session.
497
511
  /// Make sure to call `captureSession.beginConfiguration` before calling this
498
512
  ///
@@ -503,16 +517,16 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
503
517
  // Nothing todo, input is already configured for the desired device
504
518
  return
505
519
  }
506
-
520
+
507
521
  // Remove any existing inputs
508
522
  captureSession.inputs.forEach { captureSession.removeInput($0) }
509
-
523
+
510
524
  do {
511
525
  let input = try AVCaptureDeviceInput(device: device)
512
526
  if !captureSession.canAddInput(input) {
513
527
  throw CameraError.inputAdditionFailed
514
528
  }
515
-
529
+
516
530
  captureSession.addInput(input)
517
531
  currentCameraDevice = device
518
532
  } catch {
@@ -523,7 +537,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
523
537
  }
524
538
  }
525
539
  }
526
-
540
+
527
541
  /// Enables barcode detection by adding metadata output to the running session.
528
542
  /// Somehow adding the metadata output with the session not being started yet
529
543
  /// caused issues on some devices (iPad 7th Gen) where the session would just
@@ -537,13 +551,13 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
537
551
  guard captureSession.isRunning else {
538
552
  throw CameraError.sessionNotRunning
539
553
  }
540
-
554
+
541
555
  captureSession.beginConfiguration()
542
556
  defer { captureSession.commitConfiguration() }
543
-
557
+
544
558
  try setupMetadataOutput(barcodeTypes: barcodeTypes)
545
559
  }
546
-
560
+
547
561
  /// Retrieve a list of a available camera devices
548
562
  ///
549
563
  /// - Returns: a list of all available camera devices.
@@ -554,7 +568,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
554
568
  position: .unspecified
555
569
  ).devices
556
570
  }
557
-
571
+
558
572
  /// Returns a list of available camera devices based on the preferences by the user
559
573
  ///
560
574
  /// - Returns: a list of camera devices based on the preferredCameraDeviceTypes
@@ -565,7 +579,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
565
579
  position: .unspecified
566
580
  ).devices
567
581
  }
568
-
582
+
569
583
  /// Gets the best match camera device for the specified position.
570
584
  /// This method will consider preferredCameraDevices as possibly provided by the user allowing a best
571
585
  /// match to the users request.
@@ -575,43 +589,40 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
575
589
  /// - Returns: The camera device for the specified position
576
590
  /// - Throws: An error if no camera device is found.
577
591
  private func getCameraDevice(for position: AVCaptureDevice.Position?) throws
578
- -> AVCaptureDevice
579
- {
592
+ -> AVCaptureDevice {
580
593
  let preferredDevices = getPreferredCameraDevices()
581
-
594
+
582
595
  // First try to get the best match based on the users preferred camera device types
583
596
  if let match = preferredDevices.first(where: { $0.position == position }
584
597
  ) {
585
598
  return match
586
599
  }
587
-
600
+
588
601
  // If we haven't found one we try to get a best match for the position by iterating all supported device types
589
602
  // Only doing this when preferredCameraDeviceTypes size differs from SUPPORTED_CAMERA_DEVICE_TYPES, otherwise
590
603
  // we don't have to initialize a new discovery session
591
604
  if preferredCameraDeviceTypes.count < SUPPORTED_CAMERA_DEVICE_TYPES.count,
592
605
  let match = getAvailableDevices().first(where: {
593
606
  $0.position == position
594
- })
595
- {
607
+ }) {
596
608
  return match
597
609
  }
598
-
610
+
599
611
  // Otherwise, fallback to default video device or throw an error
600
612
  guard let device = AVCaptureDevice.default(for: .video) else {
601
613
  throw CameraError.cameraUnavailable
602
614
  }
603
-
615
+
604
616
  // Log when we're falling back to a device with different position than requested
605
- if let requestedPosition = position, device.position != requestedPosition
606
- {
617
+ if let requestedPosition = position, device.position != requestedPosition {
607
618
  print(
608
619
  "Warning: Falling back to camera at position \(device.position) when \(requestedPosition) was requested"
609
620
  )
610
621
  }
611
-
622
+
612
623
  return device
613
624
  }
614
-
625
+
615
626
  /// Gets the best camera device for the specified position.
616
627
  ///
617
628
  /// - Parameters:
@@ -619,8 +630,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
619
630
  /// - Returns: The camera device for the specified position
620
631
  /// - Throws: An error if no camera device is found.
621
632
  private func getCameraDeviceById(_ deviceId: String) throws
622
- -> AVCaptureDevice
623
- {
633
+ -> AVCaptureDevice {
624
634
  guard
625
635
  let device = getAvailableDevices().first(where: {
626
636
  $0.uniqueID == deviceId
@@ -630,9 +640,9 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
630
640
  }
631
641
  return device
632
642
  }
633
-
643
+
634
644
  // MARK: - UI Preview Layer
635
-
645
+
636
646
  /// Sets up the preview layer for the capture session which will
637
647
  /// display the camera feed in the view.
638
648
  ///
@@ -648,56 +658,56 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
648
658
  completion(CameraError.sessionNotRunning)
649
659
  return
650
660
  }
651
-
661
+
652
662
  self.webView = view
653
-
663
+
654
664
  videoPreviewLayer.session = captureSession
655
665
  videoPreviewLayer.videoGravity = .resizeAspectFill
656
-
666
+
657
667
  DispatchQueue.main.async { [weak self] in
658
668
  guard let self = self else { return }
659
669
  view.isOpaque = false
660
670
  view.backgroundColor = UIColor.clear
661
671
  (view as? WKWebView)?.scrollView.backgroundColor = UIColor.clear
662
-
672
+
663
673
  self.videoPreviewLayer.frame = view.bounds
664
674
  view.layer.insertSublayer(self.videoPreviewLayer, at: 0)
665
-
675
+
666
676
  self.updatePreviewOrientation()
667
-
677
+
668
678
  completion(nil)
669
679
  }
670
680
  }
671
-
681
+
672
682
  // MARK: - Triple Camera
673
-
683
+
674
684
  /// Upgrades the camera to the triple camera if available.
675
685
  /// Initializing the triple camera is an expensive operation and takes some time.
676
686
  /// This is why by default the regular physical camera is used and then later upgraded to the triple camera if available (Pro models only).
677
687
  private func upgradeToTripleCameraIfAvailable() async {
678
688
  guard captureSession.isRunning else { return }
679
-
689
+
680
690
  // Check if a triple camera is available (only on newer Pro models)
681
691
  let devices = AVCaptureDevice.DiscoverySession(
682
692
  deviceTypes: [.builtInTripleCamera],
683
693
  mediaType: .video,
684
694
  position: .back
685
695
  ).devices
686
-
696
+
687
697
  // If we don't have a triple camera, exit early
688
698
  guard let tripleCamera = devices.first else { return }
689
-
699
+
690
700
  // Don't do anything if we're already using the triple camera
691
701
  if currentCameraDevice?.uniqueID == tripleCamera.uniqueID {
692
702
  return
693
703
  }
694
-
704
+
695
705
  // Add a blur overlay to the webview to have a smooth transition when switching to the triple camera
696
706
  await addBlurOverlay()
697
-
707
+
698
708
  await Task.detached(priority: .userInitiated) {
699
709
  self.captureSession.beginConfiguration()
700
-
710
+
701
711
  do {
702
712
  try self.setInput(with: tripleCamera)
703
713
  // TODO: Consider configured zoom factor from the initial camera???
@@ -708,41 +718,40 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
708
718
  "Failed to upgrade to triple camera: \(error.localizedDescription)"
709
719
  )
710
720
  }
711
-
721
+
712
722
  self.captureSession.commitConfiguration()
713
723
  }.value
714
-
724
+
715
725
  // Small delay to let camera stabilize
716
726
  try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
717
-
727
+
718
728
  await removeBlurOverlayWithAnimation()
719
729
  }
720
-
730
+
721
731
  /// Adds a blur overlay to the webview to have a smooth transition when switching to the triple camera
722
732
  @MainActor
723
733
  private func addBlurOverlay() async {
724
734
  guard let view = self.webView else { return }
725
-
735
+
726
736
  let blurEffect = UIBlurEffect(style: .light)
727
737
  let blurOverlayView = UIVisualEffectView(effect: blurEffect)
728
738
  self.blurOverlayView = blurOverlayView
729
-
739
+
730
740
  blurOverlayView.frame = view.bounds
731
741
  blurOverlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
732
-
742
+
733
743
  // Add the blurEffect layer to the view hierarchy just above the preview layer
734
744
  // but below the web content
735
745
  view.insertSubview(blurOverlayView, at: 1)
736
746
  }
737
-
747
+
738
748
  /// Removes the blur overlay with a fade out animation to have a smooth transition
739
749
  /// - Parameter duration: The duration of the fade out animation
740
750
  @MainActor
741
751
  private func removeBlurOverlayWithAnimation(duration: TimeInterval = 0.3)
742
- async
743
- {
752
+ async {
744
753
  guard let blurEffectView = blurOverlayView else { return }
745
-
754
+
746
755
  await withCheckedContinuation { continuation in
747
756
  UIView.animate(
748
757
  withDuration: duration,
@@ -757,9 +766,9 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
757
766
  )
758
767
  }
759
768
  }
760
-
769
+
761
770
  // MARK: - Orientation Observer
762
-
771
+
763
772
  /// Sets up an observer for device orientation changes to update the preview layer orientation.
764
773
  private func setupOrientationObserver() {
765
774
  NotificationCenter.default.addObserver(
@@ -770,7 +779,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
770
779
  self?.updatePreviewOrientation()
771
780
  }
772
781
  }
773
-
782
+
774
783
  /// Updates the preview layer orientation based on the current device orientation.
775
784
  private func updatePreviewOrientation() {
776
785
  guard let connection = self.videoPreviewLayer.connection,
@@ -778,12 +787,12 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
778
787
  else {
779
788
  return
780
789
  }
781
-
790
+
782
791
  let interfaceOrientation = UIApplication.shared.connectedScenes
783
792
  .compactMap({ $0 as? UIWindowScene })
784
793
  .first?.interfaceOrientation ?? .portrait
785
794
  let videoOrientation: AVCaptureVideoOrientation
786
-
795
+
787
796
  switch interfaceOrientation {
788
797
  case .portrait:
789
798
  videoOrientation = .portrait
@@ -796,18 +805,18 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
796
805
  default:
797
806
  videoOrientation = .portrait
798
807
  }
799
-
808
+
800
809
  connection.videoOrientation = videoOrientation
801
-
810
+
802
811
  // Update the frame of the preview layer to match the new bounds
803
812
  DispatchQueue.main.async { [weak self] in
804
813
  guard let self = self, let view = self.webView else { return }
805
814
  self.videoPreviewLayer.frame = view.bounds
806
815
  }
807
816
  }
808
-
817
+
809
818
  // MARK: - App Lifecycle Observers
810
-
819
+
811
820
  /// Sets up observers for app lifecycle events to pause and resume the camera session.
812
821
  private func setupAppLifecycleObservers() {
813
822
  NotificationCenter.default.addObserver(
@@ -816,7 +825,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
816
825
  name: UIApplication.willResignActiveNotification,
817
826
  object: nil
818
827
  )
819
-
828
+
820
829
  NotificationCenter.default.addObserver(
821
830
  self,
822
831
  selector: #selector(handleAppDidBecomeActive),
@@ -824,7 +833,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
824
833
  object: nil
825
834
  )
826
835
  }
827
-
836
+
828
837
  /// Handles the app going to background by pausing the camera session.
829
838
  @objc private func handleAppWillResignActive() {
830
839
  // Pause the session when app goes to background to save resources
@@ -834,7 +843,7 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
834
843
  }
835
844
  }
836
845
  }
837
-
846
+
838
847
  /// Handles the app coming back to foreground by resuming the camera session.
839
848
  @objc private func handleAppDidBecomeActive() {
840
849
  // Resume the session when app comes back to foreground