capacitor-camera-view 2.3.1 → 3.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.
Files changed (44) hide show
  1. package/CapacitorCameraView.podspec +1 -1
  2. package/Package.swift +1 -1
  3. package/README.md +341 -48
  4. package/android/build.gradle +0 -1
  5. package/android/src/main/AndroidManifest.xml +0 -1
  6. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
  7. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +946 -205
  8. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
  9. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
  10. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
  11. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
  12. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
  13. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
  14. package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
  15. package/dist/docs.json +487 -30
  16. package/dist/esm/definitions.d.ts +494 -27
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/dist/esm/utils.d.ts +59 -15
  19. package/dist/esm/utils.js +79 -38
  20. package/dist/esm/utils.js.map +1 -1
  21. package/dist/esm/web.d.ts +191 -13
  22. package/dist/esm/web.js +626 -142
  23. package/dist/esm/web.js.map +1 -1
  24. package/dist/plugin.cjs.js +713 -180
  25. package/dist/plugin.cjs.js.map +1 -1
  26. package/dist/plugin.js +713 -180
  27. package/dist/plugin.js.map +1 -1
  28. package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
  29. package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
  30. package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
  31. package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
  32. package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
  33. package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
  34. package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
  35. package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
  36. package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
  37. package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +195 -0
  38. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
  39. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
  40. package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
  41. package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
  42. package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +111 -69
  43. package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
  44. package/package.json +25 -9
@@ -60,24 +60,71 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
60
60
  /// Currently selected flash mode.
61
61
  private var flashMode: AVCaptureDevice.FlashMode = .auto
62
62
 
63
- /// Reference to the blur overlay view that is shown when switching to the triple camera in order to have a smooth transition
64
- private var blurOverlayView: UIVisualEffectView?
65
-
66
- /// Reference to the webView that is used by the Capacitor plugin for the preview layer is shown on
67
- private var webView: UIView?
68
-
69
- /// Callback for when photo capture completes (legacy UIImage-based API).
70
- internal var photoCaptureHandler: ((UIImage?, Error?) -> Void)?
71
-
63
+ /// Whether the current session opted into the virtual triple camera, kept so
64
+ /// a camera flip back to the rear position resolves it again rather than
65
+ /// falling back to a physical lens. Confined to `sessionQueue`.
66
+ private var useTripleCameraIfAvailable = false
67
+
68
+ /// Reference to the webView that the Capacitor plugin's preview layer is
69
+ /// shown on. Confined to the main queue: written in `attachPreview`'s
70
+ /// main-queue block and cleared in `stopSession`'s, and read only from
71
+ /// main-queue contexts (`revealPreview`, rotation frame updates). Keeping
72
+ /// every access on one queue avoids racing the
73
+ /// session-queue callers that drive session setup/teardown.
74
+ internal var webView: UIView?
75
+
76
+ /// Whether the session was explicitly stopped via `stopSession`, as opposed
77
+ /// to being paused by backgrounding or an interruption. Confined to
78
+ /// `sessionQueue` (set at the top of `startSession`'s and `stopSession`'s
79
+ /// queue blocks) so lifecycle restart paths (which run on `sessionQueue`)
80
+ /// can check it directly instead of reaching for the main-queue-confined
81
+ /// `webView` reference from the wrong queue.
82
+ internal var isSessionStoppedByUser = true
83
+
84
+ /// Serializes assignment and consumption of the capture completion handlers
85
+ /// below. The handlers are written on the Capacitor call thread and read /
86
+ /// cleared on AVFoundation delegate queues, so all access must go through
87
+ /// this lock to avoid one capture silently clobbering another's handler.
88
+ internal let captureHandlerLock = NSLock()
89
+
72
90
  /// Callback for when photo capture completes with raw Data (optimized API).
73
91
  /// This avoids double JPEG encoding by returning the camera's JPEG data directly.
92
+ /// Access only while holding `captureHandlerLock`.
74
93
  internal var photoDataCaptureHandler: ((Data?, Error?) -> Void)?
75
-
94
+
76
95
  /// Callback for when snapshot capture completes.
96
+ /// Access only while holding `captureHandlerLock`.
77
97
  internal var snapshotCompletionHandler: ((UIImage?, Error?) -> Void)?
98
+
99
+ /// Timeout that fails an in-flight `captureSnapshot` if no frame is delivered,
100
+ /// so its JS promise can never hang when the session stalls (backgrounding,
101
+ /// interruption). Access only while holding `captureHandlerLock`.
102
+ internal var snapshotTimeoutWorkItem: DispatchWorkItem?
103
+
104
+ /// How long to wait for a video frame before failing a snapshot capture.
105
+ private let snapshotTimeout: TimeInterval = 2.0
106
+
107
+ /// Work item that restores continuous auto focus/exposure after a one-shot
108
+ /// tap-to-focus (see `CameraViewManager+Focus`). Cancelled and replaced on
109
+ /// each new focus point so rapid taps don't reset a later focus. Confined to
110
+ /// `sessionQueue`.
111
+ internal var focusResetWorkItem: DispatchWorkItem?
78
112
 
79
- /// Emits typed camera events to the delegate and NotificationCenter.
113
+ /// Emits typed camera events to the delegate.
80
114
  internal let eventEmitter = CameraEventEmitter()
115
+
116
+ /// Dedicated serial queue for barcode metadata delivery so the metadata
117
+ /// delegate does not run on (and stall) the main queue.
118
+ internal let barcodeMetadataQueue = DispatchQueue(
119
+ label: "com.michaelwolz.capacitorcameraview.barcodeMetadata",
120
+ qos: .userInitiated
121
+ )
122
+
123
+ /// Timestamps (seconds) of recently emitted barcodes keyed by their dedupe
124
+ /// key (value + type), tracked per key so multiple codes in frame can't
125
+ /// alternate and defeat the suppression window. Accessed only on the serial
126
+ /// `barcodeMetadataQueue`.
127
+ internal var recentBarcodeEmitTimes: [String: TimeInterval] = [:]
81
128
 
82
129
  /// Movie file output for video recording.
83
130
  internal let avMovieOutput = AVCaptureMovieFileOutput()
@@ -90,102 +137,205 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
90
137
 
91
138
  /// Session preset used before starting recording, restored when recording ends.
92
139
  internal var sessionPresetBeforeRecording: AVCaptureSession.Preset?
93
-
140
+
141
+ /// Capture-resolution hint of the current session (longer edge, pixels).
142
+ /// Re-applied to the photo output whenever the active format changes; see
143
+ /// `applyConfiguredMaxPhotoDimensions()`. Confined to `sessionQueue`.
144
+ internal var configuredCaptureMaxDimension: Int?
145
+
146
+ /// Aspect ratio ("4:3"/"16:9") of the current session, kept so the session
147
+ /// preset can be re-resolved against the new device on a camera flip.
148
+ /// Confined to `sessionQueue`.
149
+ internal var configuredAspectRatio: String?
150
+
151
+ /// Backing storage for the iOS 17+ rotation coordinator. Stored as `Any?`
152
+ /// because `AVCaptureDevice.RotationCoordinator` is iOS 17+ while this class
153
+ /// targets iOS 16; cast under `#available` via the `rotationCoordinator`
154
+ /// accessor before use. Access only while holding `rotationCoordinatorLock`.
155
+ internal var rotationCoordinatorStorage: Any?
156
+
157
+ /// Serializes access to `rotationCoordinatorStorage`. The coordinator is
158
+ /// written on the main queue (device changes) but read from the Capacitor
159
+ /// call thread (photo/snapshot capture) and the session queue (recording);
160
+ /// an unsynchronized ARC reassignment racing a read is undefined behavior,
161
+ /// so all access goes through the `rotationCoordinator` accessor which
162
+ /// takes this lock.
163
+ internal let rotationCoordinatorLock = NSLock()
164
+
165
+ /// KVO observation of the rotation coordinator's preview angle, used to keep
166
+ /// the preview level with the horizon as the device rotates. Invalidated and
167
+ /// replaced whenever the active device changes.
168
+ internal var rotationObservation: NSKeyValueObservation?
169
+
170
+ /// Token for the block-based `UIDevice.orientationDidChangeNotification`
171
+ /// observer registered in `setupOrientationObserver` (iOS 16 legacy path
172
+ /// only). `NotificationCenter.removeObserver(self)` does not remove
173
+ /// block-based observers, so this token must be removed explicitly via
174
+ /// `removeOrientationObserver` to avoid leaking it for the process lifetime.
175
+ internal var orientationObserverToken: NSObjectProtocol?
176
+
94
177
  override public init() {
95
178
  super.init()
96
179
  setupOrientationObserver()
97
180
  setupAppLifecycleObservers()
181
+ setupInterruptionObservers()
98
182
  }
99
183
 
100
184
  deinit {
101
- stopSession()
185
+ // Stop synchronously and directly here rather than calling `stopSession()`,
186
+ // which hops onto `sessionQueue` via `[weak self]`: by the time that block
187
+ // runs, this instance has already finished deallocating and `self` reads
188
+ // as nil, so the scheduled cleanup silently never executes.
189
+ if captureSession.isRunning {
190
+ captureSession.stopRunning()
191
+ }
192
+ rotationObservation?.invalidate()
193
+ removeOrientationObserver()
102
194
  NotificationCenter.default.removeObserver(self)
103
195
  }
104
196
 
105
197
  // MARK: - Plugin API
106
198
 
107
- /// Starts capture session for the specified camera position.
108
- /// This will reuse the existing capture session if it is already running.
199
+ /// Starts the capture session and attaches the preview to the given view.
200
+ ///
201
+ /// The WebView is only made transparent once the session is running, so the
202
+ /// app's own UI stays visible while the camera powers up.
203
+ ///
204
+ /// Rejects with `CameraError.sessionAlreadyRunning` if a session is already
205
+ /// running — callers must `stop()` first. If setup fails *after*
206
+ /// `startRunning()` has succeeded, the session and preview are torn down
207
+ /// before the error is forwarded, so a rejected start never leaves a live
208
+ /// session behind.
109
209
  ///
110
210
  /// - Parameters:
111
- /// - position: The position of the camera to start the session for.
211
+ /// - configuration: The session configuration to apply.
212
+ /// - webView: The view the camera preview is inserted behind.
112
213
  /// - completion: A closure called when the session setup completes with an optional error.
113
214
  public func startSession(
114
215
  configuration: CameraSessionConfiguration,
115
216
  webView: UIView,
116
217
  completion: @escaping (Error?) -> Void
117
218
  ) {
118
- if let preferredCameraDeviceTypes = configuration
119
- .preferredCameraDeviceTypes {
120
- self.preferredCameraDeviceTypes = convertToNativeCameraTypes(
121
- preferredCameraDeviceTypes
122
- )
123
- }
124
-
125
219
  sessionQueue.async { [weak self] in
126
220
  guard let self = self else { return }
127
-
221
+
222
+ // Checked on the session queue, not the caller thread, so a start()
223
+ // still queued behind another can't slip past the guard.
224
+ guard !self.captureSession.isRunning else {
225
+ DispatchQueue.main.async {
226
+ completion(CameraError.sessionAlreadyRunning)
227
+ }
228
+ return
229
+ }
230
+
231
+ // Stored after the rejection guard so a rejected start() can't alter
232
+ // which device a later flipCamera() picks.
233
+ if let preferredCameraDeviceTypes = configuration.preferredCameraDeviceTypes {
234
+ self.preferredCameraDeviceTypes = convertToNativeCameraTypes(
235
+ preferredCameraDeviceTypes
236
+ )
237
+ }
238
+
128
239
  do {
129
240
  try self.initiateCaptureSession(configuration: configuration)
241
+
242
+ // Applied once the transaction has committed, since the supported
243
+ // zoom range depends on the now-final active format.
244
+ try self.applyInitialZoom(configuration.zoomFactor)
130
245
  } catch {
131
246
  DispatchQueue.main.async {
132
247
  completion(error)
133
248
  }
134
249
  return
135
250
  }
136
-
137
- // Start the capture session
138
- self.captureSession.startRunning()
139
-
140
- // Display the camera preview on the provided webview
141
- self.displayPreview(
142
- on: webView,
143
- completion: { error in
144
- if error != nil {
145
- completion(error)
146
- return
147
- }
148
-
251
+
252
+ self.applyConfiguredMaxPhotoDimensions()
253
+
254
+ // `fit` letterboxes the whole frame; the default `cover` center-crops it.
255
+ let videoGravity: AVLayerVideoGravity =
256
+ configuration.previewScaleMode == "fit" ? .resizeAspect : .resizeAspectFill
257
+ self.attachPreview(to: webView, videoGravity: videoGravity) {
258
+ // attachPreview completes on the main thread; hop back so all
259
+ // session work stays serialized on the session queue.
260
+ self.sessionQueue.async { [weak self] in
261
+ guard let self = self else { return }
262
+
263
+ self.captureSession.startRunning()
264
+
265
+ // Only now is the session user-started, so lifecycle restart
266
+ // paths know they may bring it back if it stops on its own.
267
+ self.isSessionStoppedByUser = false
268
+
269
+ self.revealPreview()
270
+
149
271
  // Handle barcode detection after session is running
150
272
  if configuration.enableBarcodeDetection {
151
273
  do {
152
274
  try self.enableBarcodeDetection(barcodeTypes: configuration.barcodeTypes)
153
275
  } catch {
154
- completion(error)
276
+ // `startRunning()` already succeeded, so forwarding
277
+ // the error as-is would strand a live session and a
278
+ // transparent WebView, and the guard above would then
279
+ // reject every retry. `stopSession` only enqueues onto
280
+ // `sessionQueue`, so calling it from here queues the
281
+ // teardown behind us instead of deadlocking.
282
+ self.stopSession { completion(error) }
155
283
  return
156
284
  }
157
285
  }
158
-
286
+
159
287
  // Complete already because the camera is ready to be used
160
- completion(nil)
161
-
162
- // We might asynchronously upgrade to a triple camera in the background if available and configured
163
- if configuration.useTripleCameraIfAvailable {
164
- Task {
165
- await self.upgradeToTripleCameraIfAvailable()
166
- }
288
+ DispatchQueue.main.async {
289
+ completion(nil)
167
290
  }
168
291
  }
169
- )
292
+ }
170
293
  }
171
294
  }
172
295
 
173
296
  /// Stops the current capture session
174
297
  public func stopSession(completion: (() -> Void)? = nil) {
175
- guard captureSession.isRunning else {
176
- completion?()
177
- return
178
- }
179
-
180
298
  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
299
+ guard let self = self else {
300
+ DispatchQueue.main.async {
301
+ completion?()
302
+ }
303
+ return
185
304
  }
186
-
187
- self?.captureSession.stopRunning()
188
-
305
+
306
+ // Record the user's intent to stop before checking `isRunning` so
307
+ // any lifecycle restart block that was already queued behind this
308
+ // one (or gets queued after it) won't bring the session back up.
309
+ self.isSessionStoppedByUser = true
310
+
311
+ // Only the capture-session teardown is guarded on the running state.
312
+ // The preview-layer / WebView / rotation-state teardown below must
313
+ // ALWAYS run: while backgrounded the session is already stopped but
314
+ // the WebView is still transparent with the preview layer attached.
315
+ if self.captureSession.isRunning {
316
+ if self.avMovieOutput.isRecording {
317
+ // Capture and clear the handler a concurrent `stopRecording()`
318
+ // is waiting on so its promise rejects instead of hanging —
319
+ // the recording delegate finds no handler once cleared here.
320
+ // `recordingWithAudio` is deliberately left alone; the
321
+ // finalize delegate reads it and owns clearing it.
322
+ let pendingRecordingHandler = self.videoRecordingCompletionHandler
323
+ self.avMovieOutput.stopRecording()
324
+ self.videoRecordingCompletionHandler = nil
325
+
326
+ if let pendingRecordingHandler = pendingRecordingHandler {
327
+ DispatchQueue.main.async {
328
+ pendingRecordingHandler(nil, CameraError.sessionNotRunning)
329
+ }
330
+ }
331
+ }
332
+
333
+ self.captureSession.stopRunning()
334
+
335
+ // Reset barcode dedupe state so a restarted session emits immediately
336
+ self.resetBarcodeDedupeState()
337
+ }
338
+
189
339
  DispatchQueue.main.async { [weak self] in
190
340
  guard let self = self else {
191
341
  completion?()
@@ -195,12 +345,16 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
195
345
  self.webView?.isOpaque = true
196
346
  self.webView?.backgroundColor = nil
197
347
  self.webView = nil
198
-
199
- if let blurOverlayView = self.blurOverlayView {
200
- blurOverlayView.removeFromSuperview()
201
- self.blurOverlayView = nil
348
+
349
+ // Release rotation state so a stopped session doesn't keep the
350
+ // coordinator (and its device reference) alive. Recreated by
351
+ // `configureRotationHandling` on the next session start.
352
+ self.rotationObservation?.invalidate()
353
+ self.rotationObservation = nil
354
+ if #available(iOS 17.0, *) {
355
+ self.rotationCoordinator = nil
202
356
  }
203
-
357
+
204
358
  completion?()
205
359
  }
206
360
  }
@@ -211,38 +365,6 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
211
365
  return captureSession.isRunning
212
366
  }
213
367
 
214
- /// Captures a photo with the current camera settings.
215
- /// - Returns: The picture as UIImage via `AVCapturePhotoCaptureDelegate`
216
- public func capturePhoto(completion: @escaping (UIImage?, Error?) -> Void) {
217
- guard let cameraDevice = currentCameraDevice else {
218
- completion(nil, CameraError.cameraUnavailable)
219
- return
220
- }
221
-
222
- guard captureSession.isRunning else {
223
- completion(nil, CameraError.sessionNotRunning)
224
- return
225
- }
226
-
227
- let photoSettings = AVCapturePhotoSettings()
228
- if cameraDevice.hasFlash {
229
- photoSettings.flashMode = flashMode
230
- } else {
231
- photoSettings.flashMode = .off
232
- }
233
-
234
- // Ensure proper orientation
235
- if let photoConnection = avPhotoOutput.connection(with: .video),
236
- let previewConnection = videoPreviewLayer.connection {
237
- if photoConnection.isVideoOrientationSupported {
238
- photoConnection.videoOrientation = previewConnection.videoOrientation
239
- }
240
- }
241
-
242
- avPhotoOutput.capturePhoto(with: photoSettings, delegate: self)
243
- photoCaptureHandler = completion
244
- }
245
-
246
368
  /// Captures a photo and returns the raw JPEG data directly.
247
369
  /// This optimized method avoids double JPEG encoding by returning the camera's
248
370
  /// native JPEG data instead of converting through UIImage.
@@ -258,26 +380,35 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
258
380
  completion(nil, CameraError.sessionNotRunning)
259
381
  return
260
382
  }
261
-
383
+
384
+ // Reserve the photo capture slot before initiating the capture so a
385
+ // concurrent capture cannot clobber this handler. Reject instead of
386
+ // overwriting when one is already in flight.
387
+ captureHandlerLock.lock()
388
+ guard photoDataCaptureHandler == nil else {
389
+ captureHandlerLock.unlock()
390
+ completion(nil, CameraError.captureInProgress)
391
+ return
392
+ }
393
+ photoDataCaptureHandler = completion
394
+ captureHandlerLock.unlock()
395
+
262
396
  let photoSettings = AVCapturePhotoSettings()
263
397
  if cameraDevice.hasFlash {
264
398
  photoSettings.flashMode = flashMode
265
399
  } else {
266
400
  photoSettings.flashMode = .off
267
401
  }
268
-
402
+
269
403
  // Ensure proper orientation
270
- if let photoConnection = avPhotoOutput.connection(with: .video),
271
- let previewConnection = videoPreviewLayer.connection {
272
- if photoConnection.isVideoOrientationSupported {
273
- photoConnection.videoOrientation = previewConnection.videoOrientation
274
- }
404
+ if let photoConnection = avPhotoOutput.connection(with: .video) {
405
+ applyCaptureOrientation(to: photoConnection)
275
406
  }
276
-
407
+
408
+ // Handler is assigned above, before the capture is initiated.
277
409
  avPhotoOutput.capturePhoto(with: photoSettings, delegate: self)
278
- photoDataCaptureHandler = completion
279
410
  }
280
-
411
+
281
412
  /// Capture a snapshot of the current camera view. This is faster than actually processing a
282
413
  /// photo via capturePhoto
283
414
  /// - Parameter completion: called with the captured UIImage or an error.
@@ -293,32 +424,120 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
293
424
  completion(nil, CameraError.sessionNotRunning)
294
425
  return
295
426
  }
296
-
297
- // Ensure proper orientation
298
- if let videoConnection = avVideoDataOutput.connection(with: .video),
299
- let previewConnection = videoPreviewLayer.connection {
300
- if videoConnection.isVideoOrientationSupported {
301
- videoConnection.videoOrientation = previewConnection.videoOrientation
302
- }
427
+
428
+ // Reserve the snapshot slot and arm the timeout before starting frame
429
+ // delivery, so a concurrent snapshot cannot clobber this handler and the
430
+ // promise can never hang if no frame arrives.
431
+ let timeoutWorkItem = DispatchWorkItem { [weak self] in
432
+ guard let self = self,
433
+ let handler = self.consumeSnapshotHandler() else { return }
434
+ self.avVideoDataOutput.setSampleBufferDelegate(nil, queue: nil)
435
+ handler(nil, CameraError.captureTimeout)
436
+ }
437
+
438
+ captureHandlerLock.lock()
439
+ guard snapshotCompletionHandler == nil else {
440
+ captureHandlerLock.unlock()
441
+ completion(nil, CameraError.captureInProgress)
442
+ return
303
443
  }
304
-
305
- // Set the delegate for a single frame capture using the reusable queue
306
444
  snapshotCompletionHandler = completion
445
+ snapshotTimeoutWorkItem = timeoutWorkItem
446
+ captureHandlerLock.unlock()
447
+
448
+ // Ensure proper orientation
449
+ if let videoConnection = avVideoDataOutput.connection(with: .video) {
450
+ applyCaptureOrientation(to: videoConnection)
451
+ }
452
+
453
+ sampleBufferQueue.asyncAfter(
454
+ deadline: .now() + snapshotTimeout,
455
+ execute: timeoutWorkItem
456
+ )
457
+
458
+ // Set the delegate last, once the handler and timeout are in place, to
459
+ // begin single-frame capture on the reusable queue.
307
460
  avVideoDataOutput.setSampleBufferDelegate(
308
461
  self,
309
462
  queue: sampleBufferQueue
310
463
  )
311
464
  }
312
-
465
+
313
466
  /// Flips the camera to the opposite position (front to back or back to front).
314
- public func flipCamera() throws {
315
- let currentPosition: AVCaptureDevice.Position =
316
- currentCameraDevice?.position ?? .back
317
- let newPosition: AVCaptureDevice.Position =
318
- currentPosition == .back ? .front : .back
319
-
320
- let newCamera = try getCameraDevice(for: newPosition)
321
- try setInput(with: newCamera)
467
+ ///
468
+ /// The input swap runs on the session queue inside a single configuration
469
+ /// transaction, so the session is never left without an input.
470
+ ///
471
+ /// Rejects while a recording is active: `setInput` tears down all inputs
472
+ /// (including the microphone) and only re-adds the video input, which would
473
+ /// silently drop audio from an in-progress recording.
474
+ ///
475
+ /// - Parameter completion: Called on the main thread with an optional error.
476
+ public func flipCamera(completion: @escaping (Error?) -> Void) {
477
+ sessionQueue.async { [weak self] in
478
+ guard let self = self else { return }
479
+
480
+ guard !self.avMovieOutput.isRecording else {
481
+ DispatchQueue.main.async { completion(CameraError.recordingAlreadyInProgress) }
482
+ return
483
+ }
484
+
485
+ let currentPosition: AVCaptureDevice.Position =
486
+ self.currentCameraDevice?.position ?? .back
487
+ let newPosition: AVCaptureDevice.Position =
488
+ currentPosition == .back ? .front : .back
489
+
490
+ self.captureSession.beginConfiguration()
491
+ defer { self.captureSession.commitConfiguration() }
492
+
493
+ do {
494
+ let newCamera = try self.getCameraDevice(for: newPosition)
495
+
496
+ // Drop to the universally supported .photo baseline before the
497
+ // input swap: the session may run a 16:9 preset the new device
498
+ // does not support, which would make `canAddInput` fail. The
499
+ // configured aspect ratio is re-resolved right after, validated
500
+ // against the new device.
501
+ if self.captureSession.canSetSessionPreset(.photo) {
502
+ self.captureSession.sessionPreset = .photo
503
+ }
504
+
505
+ try self.setInput(with: newCamera)
506
+ self.applySessionPreset(forAspectRatio: self.configuredAspectRatio)
507
+ } catch {
508
+ DispatchQueue.main.async {
509
+ completion(error)
510
+ }
511
+ return
512
+ }
513
+
514
+ // Both steps below depend on the new device's active format, which is
515
+ // only final once the input swap has committed — hence the next
516
+ // session-queue block. The call is resolved from there so the flip
517
+ // isn't reported as done while the preview is still settling.
518
+ self.sessionQueue.async { [weak self] in
519
+ guard let self = self else { return }
520
+
521
+ // The new device's format resets the photo output's
522
+ // maxPhotoDimensions.
523
+ self.applyConfiguredMaxPhotoDimensions()
524
+
525
+ // Normalize the new device's zoom domain: flipping back to a
526
+ // virtual rear camera would otherwise sit at its raw 1.0, which
527
+ // is the ultra-wide field of view.
528
+ do {
529
+ try self.applyInitialZoom(nil)
530
+ } catch {
531
+ cameraViewLogger.error(
532
+ "Failed to normalize zoom after camera flip: \(error.localizedDescription, privacy: .public)"
533
+ )
534
+ }
535
+
536
+ DispatchQueue.main.async {
537
+ completion(nil)
538
+ }
539
+ }
540
+ }
322
541
  }
323
542
 
324
543
  /// Sets the flash mode for the currently active camera device.
@@ -400,69 +619,6 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
400
619
  }
401
620
  }
402
621
 
403
- /// Gets the minimum, maximum, and current zoom factors supported by the current camera device.
404
- /// The maximum zoom factor is limited to a reasonable value of 10x to prevent excessive zooming
405
- /// because some devices report very high zoom factors that aren't useful.
406
- ///
407
- /// - Returns: A tuple containing the minimum, maximum, and current zoom factors.
408
- public func getSupportedZoomFactors() -> (
409
- min: CGFloat, max: CGFloat, current: CGFloat
410
- ) {
411
- guard let currentDevice = currentCameraDevice else {
412
- return (
413
- min: 1.0,
414
- max: 1.0,
415
- current: 1.0
416
- )
417
- }
418
-
419
- let minZoomFactor = currentDevice.minAvailableVideoZoomFactor
420
- let maxZoomFactor = min(
421
- currentDevice.activeFormat.videoMaxZoomFactor,
422
- 10.0
423
- )
424
- let currentZoomFactor = currentDevice.videoZoomFactor
425
-
426
- return (
427
- min: minZoomFactor,
428
- max: maxZoomFactor,
429
- current: currentZoomFactor
430
- )
431
- }
432
-
433
- /// Sets the zoom factor for the current camera device.
434
- ///
435
- /// - Parameters:
436
- /// - factor: The zoom factor to set.
437
- /// - ramp: If enabled the zoom will be applied via ramp
438
- /// - Throws: An error if the zoom factor cannot be set.
439
- public func setZoomFactor(_ factor: CGFloat, ramp: Bool = true) throws {
440
- guard let device = currentCameraDevice else {
441
- throw CameraError.cameraUnavailable
442
- }
443
-
444
- let supportedZoomFactors = getSupportedZoomFactors()
445
- guard
446
- factor >= supportedZoomFactors.min
447
- && factor <= supportedZoomFactors.max
448
- else {
449
- throw CameraError.zoomFactorOutOfRange
450
- }
451
-
452
- do {
453
- try device.lockForConfiguration()
454
- defer { device.unlockForConfiguration() }
455
-
456
- if ramp {
457
- device.ramp(toVideoZoomFactor: factor, withRate: 6.0)
458
- } else {
459
- device.videoZoomFactor = factor
460
- }
461
- } catch {
462
- throw CameraError.configurationFailed(error)
463
- }
464
- }
465
-
466
622
  /// Initiates the capture session with the specified camera device.
467
623
  ///
468
624
  /// - Parameters:
@@ -472,7 +628,13 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
472
628
  ) throws {
473
629
  captureSession.beginConfiguration()
474
630
  defer { captureSession.commitConfiguration() }
475
-
631
+
632
+ configureAutomaticDeferredStart()
633
+
634
+ // Remember the triple-camera preference before resolving the device:
635
+ // `getCameraDevice` reads it, and so does a later camera flip.
636
+ useTripleCameraIfAvailable = configuration.useTripleCameraIfAvailable
637
+
476
638
  // Configure the camera device
477
639
  let device: AVCaptureDevice
478
640
  if let deviceId = configuration.deviceId {
@@ -480,17 +642,29 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
480
642
  } else {
481
643
  device = try getCameraDevice(for: configuration.position)
482
644
  }
483
-
484
- // Set the session preset to photo if supported (which should be the case for all devices)
645
+
646
+ // Reset to the universally supported .photo baseline first so a
647
+ // lingering 16:9 preset from a previous session cannot make the new
648
+ // input incompatible before it is added.
485
649
  if captureSession.canSetSessionPreset(.photo) {
486
650
  captureSession.sessionPreset = .photo
487
651
  }
488
-
652
+
653
+ // Remember the resolution selection for later (re-)application on
654
+ // camera flips and format changes.
655
+ configuredAspectRatio = configuration.aspectRatio
656
+ configuredCaptureMaxDimension = configuration.captureMaxDimension
657
+
489
658
  // Set the camera input
490
659
  try setInput(with: device)
491
-
660
+
661
+ // Choose the session preset for the configured aspect ratio now that
662
+ // the input is attached, so preset support is validated against the
663
+ // actual device (e.g. front cameras without 4K).
664
+ applySessionPreset(forAspectRatio: configuration.aspectRatio)
665
+
492
666
  // Set up the photo output
493
- try setupPhotoOutput()
667
+ try setupPhotoOutput(prioritizeQuality: configuration.prioritizeQuality)
494
668
 
495
669
  // Set up the video data output for snapshots
496
670
  try setupVideoDataOutput()
@@ -501,35 +675,51 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
501
675
  removeMetadataOutput()
502
676
  }
503
677
 
504
- // Set the initial zoom factor if specified
505
- if let zoomFactor = configuration.zoomFactor {
506
- try setZoomFactor(zoomFactor, ramp: false)
507
- }
678
+ // The initial zoom factor is applied by the caller once this transaction
679
+ // has committed, see `applyInitialZoom`.
508
680
  }
509
681
 
510
682
  /// Sets the input for the capture session.
511
683
  /// Make sure to call `captureSession.beginConfiguration` before calling this
512
684
  ///
685
+ /// If the new input cannot be added, the previously removed inputs are
686
+ /// restored and `currentCameraDevice` is left untouched so the session
687
+ /// never ends up without an input.
688
+ ///
513
689
  /// - Parameter device: The camera device to use as input.
514
690
  /// - Throws: An error if the input cannot be set.
515
- private func setInput(with device: AVCaptureDevice) throws {
691
+ internal func setInput(with device: AVCaptureDevice) throws {
516
692
  guard currentCameraDevice?.uniqueID != device.uniqueID else {
517
693
  // Nothing todo, input is already configured for the desired device
518
694
  return
519
695
  }
520
-
521
- // Remove any existing inputs
522
- captureSession.inputs.forEach { captureSession.removeInput($0) }
523
-
696
+
697
+ // Remove any existing inputs, keeping a reference so they can be
698
+ // restored if adding the new input fails
699
+ let removedInputs = captureSession.inputs
700
+ removedInputs.forEach { captureSession.removeInput($0) }
701
+
524
702
  do {
525
703
  let input = try AVCaptureDeviceInput(device: device)
526
704
  if !captureSession.canAddInput(input) {
527
705
  throw CameraError.inputAdditionFailed
528
706
  }
529
-
707
+
530
708
  captureSession.addInput(input)
531
709
  currentCameraDevice = device
710
+
711
+ // Recreate the rotation coordinator for the new device so capture
712
+ // and preview rotation track the physical camera in use (flip,
713
+ // triple-camera upgrade). No-op until the preview is available.
714
+ configureRotationHandling()
532
715
  } catch {
716
+ // Restore the previous inputs so the session is not left without input
717
+ removedInputs.forEach {
718
+ if captureSession.canAddInput($0) {
719
+ captureSession.addInput($0)
720
+ }
721
+ }
722
+
533
723
  if let avError = error as? AVError {
534
724
  throw CameraError.configurationFailed(avError)
535
725
  } else {
@@ -590,8 +780,16 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
590
780
  /// - Throws: An error if no camera device is found.
591
781
  private func getCameraDevice(for position: AVCaptureDevice.Position?) throws
592
782
  -> AVCaptureDevice {
783
+ // The opt-in virtual triple camera takes precedence for the rear
784
+ // position, so the session starts on it directly instead of swapping the
785
+ // input of a running session afterwards. It only exists on Pro models.
786
+ if useTripleCameraIfAvailable, position == .back,
787
+ let tripleCamera = tripleCameraDevice() {
788
+ return tripleCamera
789
+ }
790
+
593
791
  let preferredDevices = getPreferredCameraDevices()
594
-
792
+
595
793
  // First try to get the best match based on the users preferred camera device types
596
794
  if let match = preferredDevices.first(where: { $0.position == position }
597
795
  ) {
@@ -615,14 +813,23 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
615
813
 
616
814
  // Log when we're falling back to a device with different position than requested
617
815
  if let requestedPosition = position, device.position != requestedPosition {
618
- print(
619
- "Warning: Falling back to camera at position \(device.position) when \(requestedPosition) was requested"
816
+ cameraViewLogger.warning(
817
+ "Falling back to camera at position \(device.position.rawValue, privacy: .public) when \(requestedPosition.rawValue, privacy: .public) was requested"
620
818
  )
621
819
  }
622
820
 
623
821
  return device
624
822
  }
625
823
 
824
+ /// The virtual triple camera (Pro models), if this device has one.
825
+ ///
826
+ /// `AVCaptureDevice.default(_:for:position:)` resolves it directly instead of
827
+ /// allocating an `AVCaptureDevice.DiscoverySession` just to read its first
828
+ /// element.
829
+ private func tripleCameraDevice() -> AVCaptureDevice? {
830
+ return AVCaptureDevice.default(.builtInTripleCamera, for: .video, position: .back)
831
+ }
832
+
626
833
  /// Gets the best camera device for the specified position.
627
834
  ///
628
835
  /// - Parameters:
@@ -643,214 +850,54 @@ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
643
850
 
644
851
  // MARK: - UI Preview Layer
645
852
 
646
- /// Sets up the preview layer for the capture session which will
647
- /// display the camera feed in the view.
853
+ /// Attaches the preview layer to the capture session and inserts it behind
854
+ /// the given view, leaving the view opaque for now (see `revealPreview`).
855
+ ///
856
+ /// Called before `startRunning()` so the preview is the session's one
857
+ /// non-deferred consumer.
648
858
  ///
649
859
  /// - Parameters:
650
860
  /// - view: The view that will display the camera preview.
651
- /// - completion: The completion handler after successfully adding the previewLayer to the provided view
652
- /// - Throws: An error if the preview layer cannot be set up.
653
- private func displayPreview(
654
- on view: UIView,
655
- completion: @escaping (Error?) -> Void
861
+ /// - videoGravity: How the preview layer scales the feed into the view.
862
+ /// `.resizeAspectFill` (cover, default) center-crops; `.resizeAspect`
863
+ /// (fit) letterboxes the whole frame.
864
+ /// - completion: Called on the main thread once the layer is attached.
865
+ private func attachPreview(
866
+ to view: UIView,
867
+ videoGravity: AVLayerVideoGravity,
868
+ completion: @escaping () -> Void
656
869
  ) {
657
- guard captureSession.isRunning else {
658
- completion(CameraError.sessionNotRunning)
659
- return
660
- }
661
-
662
- self.webView = view
663
-
664
- videoPreviewLayer.session = captureSession
665
- videoPreviewLayer.videoGravity = .resizeAspectFill
666
-
667
870
  DispatchQueue.main.async { [weak self] in
668
871
  guard let self = self else { return }
669
- view.isOpaque = false
670
- view.backgroundColor = UIColor.clear
671
- (view as? WKWebView)?.scrollView.backgroundColor = UIColor.clear
672
-
872
+
873
+ // Stored on the main queue so every access to `self.webView` is
874
+ // confined to a single queue and can't race the session-queue caller.
875
+ self.webView = view
876
+
877
+ // AVCaptureVideoPreviewLayer is a CALayer; its properties must be
878
+ // mutated on the main thread rather than the session queue.
879
+ self.videoPreviewLayer.session = self.captureSession
880
+ self.videoPreviewLayer.videoGravity = videoGravity
673
881
  self.videoPreviewLayer.frame = view.bounds
674
882
  view.layer.insertSublayer(self.videoPreviewLayer, at: 0)
675
-
676
- self.updatePreviewOrientation()
677
-
678
- completion(nil)
679
- }
680
- }
681
-
682
- // MARK: - Triple Camera
683
-
684
- /// Upgrades the camera to the triple camera if available.
685
- /// Initializing the triple camera is an expensive operation and takes some time.
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).
687
- private func upgradeToTripleCameraIfAvailable() async {
688
- guard captureSession.isRunning else { return }
689
-
690
- // Check if a triple camera is available (only on newer Pro models)
691
- let devices = AVCaptureDevice.DiscoverySession(
692
- deviceTypes: [.builtInTripleCamera],
693
- mediaType: .video,
694
- position: .back
695
- ).devices
696
-
697
- // If we don't have a triple camera, exit early
698
- guard let tripleCamera = devices.first else { return }
699
-
700
- // Don't do anything if we're already using the triple camera
701
- if currentCameraDevice?.uniqueID == tripleCamera.uniqueID {
702
- return
703
- }
704
-
705
- // Add a blur overlay to the webview to have a smooth transition when switching to the triple camera
706
- await addBlurOverlay()
707
-
708
- await Task.detached(priority: .userInitiated) {
709
- self.captureSession.beginConfiguration()
710
-
711
- do {
712
- try self.setInput(with: tripleCamera)
713
- // TODO: Consider configured zoom factor from the initial camera???
714
- try self.setZoomFactor(2.0, ramp: false)
715
- } catch {
716
- // Fail silently if we can't upgrade to the triple camera
717
- print(
718
- "Failed to upgrade to triple camera: \(error.localizedDescription)"
719
- )
720
- }
721
-
722
- self.captureSession.commitConfiguration()
723
- }.value
724
-
725
- // Small delay to let camera stabilize
726
- try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
727
-
728
- await removeBlurOverlayWithAnimation()
729
- }
730
-
731
- /// Adds a blur overlay to the webview to have a smooth transition when switching to the triple camera
732
- @MainActor
733
- private func addBlurOverlay() async {
734
- guard let view = self.webView else { return }
735
-
736
- let blurEffect = UIBlurEffect(style: .light)
737
- let blurOverlayView = UIVisualEffectView(effect: blurEffect)
738
- self.blurOverlayView = blurOverlayView
739
-
740
- blurOverlayView.frame = view.bounds
741
- blurOverlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
742
-
743
- // Add the blurEffect layer to the view hierarchy just above the preview layer
744
- // but below the web content
745
- view.insertSubview(blurOverlayView, at: 1)
746
- }
747
-
748
- /// Removes the blur overlay with a fade out animation to have a smooth transition
749
- /// - Parameter duration: The duration of the fade out animation
750
- @MainActor
751
- private func removeBlurOverlayWithAnimation(duration: TimeInterval = 0.3)
752
- async {
753
- guard let blurEffectView = blurOverlayView else { return }
754
-
755
- await withCheckedContinuation { continuation in
756
- UIView.animate(
757
- withDuration: duration,
758
- animations: {
759
- blurEffectView.alpha = 0
760
- },
761
- completion: { _ in
762
- blurEffectView.removeFromSuperview()
763
- self.blurOverlayView = nil
764
- continuation.resume()
765
- }
766
- )
767
- }
768
- }
769
-
770
- // MARK: - Orientation Observer
771
-
772
- /// Sets up an observer for device orientation changes to update the preview layer orientation.
773
- private func setupOrientationObserver() {
774
- NotificationCenter.default.addObserver(
775
- forName: UIDevice.orientationDidChangeNotification,
776
- object: nil,
777
- queue: .main
778
- ) { [weak self] _ in
779
- self?.updatePreviewOrientation()
883
+
884
+ self.configureRotationHandling()
885
+
886
+ completion()
780
887
  }
781
888
  }
782
-
783
- /// Updates the preview layer orientation based on the current device orientation.
784
- private func updatePreviewOrientation() {
785
- guard let connection = self.videoPreviewLayer.connection,
786
- connection.isVideoOrientationSupported
787
- else {
788
- return
789
- }
790
-
791
- let interfaceOrientation = UIApplication.shared.connectedScenes
792
- .compactMap({ $0 as? UIWindowScene })
793
- .first?.interfaceOrientation ?? .portrait
794
- let videoOrientation: AVCaptureVideoOrientation
795
-
796
- switch interfaceOrientation {
797
- case .portrait:
798
- videoOrientation = .portrait
799
- case .landscapeLeft:
800
- videoOrientation = .landscapeLeft
801
- case .landscapeRight:
802
- videoOrientation = .landscapeRight
803
- case .portraitUpsideDown:
804
- videoOrientation = .portraitUpsideDown
805
- default:
806
- videoOrientation = .portrait
807
- }
808
-
809
- connection.videoOrientation = videoOrientation
810
-
811
- // Update the frame of the preview layer to match the new bounds
889
+
890
+ /// Makes the WebView transparent so the attached preview layer becomes
891
+ /// visible. Called once the session is running, so the app's own UI stays
892
+ /// visible while the camera powers up.
893
+ private func revealPreview() {
812
894
  DispatchQueue.main.async { [weak self] in
813
- guard let self = self, let view = self.webView else { return }
814
- self.videoPreviewLayer.frame = view.bounds
815
- }
816
- }
817
-
818
- // MARK: - App Lifecycle Observers
819
-
820
- /// Sets up observers for app lifecycle events to pause and resume the camera session.
821
- private func setupAppLifecycleObservers() {
822
- NotificationCenter.default.addObserver(
823
- self,
824
- selector: #selector(handleAppWillResignActive),
825
- name: UIApplication.willResignActiveNotification,
826
- object: nil
827
- )
828
-
829
- NotificationCenter.default.addObserver(
830
- self,
831
- selector: #selector(handleAppDidBecomeActive),
832
- name: UIApplication.didBecomeActiveNotification,
833
- object: nil
834
- )
835
- }
836
-
837
- /// Handles the app going to background by pausing the camera session.
838
- @objc private func handleAppWillResignActive() {
839
- // Pause the session when app goes to background to save resources
840
- if captureSession.isRunning {
841
- sessionQueue.async { [weak self] in
842
- self?.captureSession.stopRunning()
843
- }
844
- }
845
- }
846
-
847
- /// Handles the app coming back to foreground by resuming the camera session.
848
- @objc private func handleAppDidBecomeActive() {
849
- // Resume the session when app comes back to foreground
850
- if !captureSession.isRunning && webView != nil {
851
- sessionQueue.async { [weak self] in
852
- self?.captureSession.startRunning()
853
- }
895
+ guard let view = self?.webView else { return }
896
+
897
+ view.isOpaque = false
898
+ view.backgroundColor = UIColor.clear
899
+ (view as? WKWebView)?.scrollView.backgroundColor = UIColor.clear
854
900
  }
855
901
  }
902
+
856
903
  }