capacitor-camera-view 2.4.0 → 3.0.1

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 -49
  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 +945 -207
  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 +475 -30
  16. package/dist/esm/definitions.d.ts +478 -26
  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 +624 -141
  23. package/dist/esm/web.js.map +1 -1
  24. package/dist/plugin.cjs.js +711 -179
  25. package/dist/plugin.cjs.js.map +1 -1
  26. package/dist/plugin.js +711 -179
  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 +200 -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 +98 -65
  43. package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
  44. package/package.json +25 -9
@@ -15,7 +15,7 @@ public struct BarcodeDetectedEvent: Sendable {
15
15
  /// Raw bytes as they were encoded in the barcode when the platform exposes them.
16
16
  public let rawBytes: [UInt8]?
17
17
 
18
- /// The type of barcode detected (e.g., "org.iso.QRCode").
18
+ /// The type of barcode detected, normalized to the shared BarcodeType vocabulary (e.g., "qr").
19
19
  public let type: String
20
20
 
21
21
  /// The bounding rectangle of the barcode in screen coordinates.
@@ -97,37 +97,59 @@ public protocol CameraEventDelegate: AnyObject {
97
97
  /// Called when a barcode is detected in the camera feed.
98
98
  /// - Parameter event: The barcode detection event with all relevant data.
99
99
  func cameraDidDetectBarcode(_ event: BarcodeDetectedEvent)
100
- }
101
100
 
102
- // MARK: - Notification Names
101
+ /// Called when the capture session is interrupted (e.g. phone call, another
102
+ /// app claiming the camera, iPad Split View camera loss).
103
+ /// - Parameter reason: A stable string describing the interruption reason.
104
+ func cameraWasInterrupted(reason: String)
105
+
106
+ /// Called when a capture-session interruption ends and the session resumes.
107
+ func cameraInterruptionEnded()
103
108
 
104
- /// Extension for camera-related notification names.
105
- /// These maintain backwards compatibility with the NotificationCenter-based event system.
106
- public extension Notification.Name {
107
- /// Posted when a barcode is detected.
108
- /// UserInfo contains: "value", "displayValue", "rawBytes", "type", "boundingRect"
109
- static let cameraViewBarcodeDetected = Notification.Name("barcodeDetected")
109
+ /// Called when the capture session hits a runtime error.
110
+ /// - Parameters:
111
+ /// - message: A human-readable description of the error.
112
+ /// - code: The underlying `AVError` code, when available.
113
+ func cameraRuntimeError(message: String, code: Int?)
114
+ }
115
+
116
+ /// Default no-op implementations so conformers only implement the events they
117
+ /// care about (and adding new events here stays source-compatible).
118
+ public extension CameraEventDelegate {
119
+ func cameraWasInterrupted(reason: String) {}
120
+ func cameraInterruptionEnded() {}
121
+ func cameraRuntimeError(message: String, code: Int?) {}
110
122
  }
111
123
 
112
124
  // MARK: - Event Emitter Helper
113
125
 
114
- /// Helper class for emitting camera events through both delegate and NotificationCenter.
115
- /// This maintains backwards compatibility while enabling the new typed delegate pattern.
126
+ /// Helper class for emitting camera events through the typed delegate.
116
127
  internal final class CameraEventEmitter {
117
128
  /// Weak reference to the delegate to avoid retain cycles.
118
129
  weak var delegate: CameraEventDelegate?
119
130
 
120
- /// Emits a barcode detected event through both channels.
131
+ /// Emits a barcode detected event to the delegate.
121
132
  /// - Parameter event: The barcode detection event to emit.
122
133
  func emitBarcodeDetected(_ event: BarcodeDetectedEvent) {
123
- // Call typed delegate first (preferred path)
124
134
  delegate?.cameraDidDetectBarcode(event)
135
+ }
136
+
137
+ /// Emits a camera interruption event to the delegate.
138
+ /// - Parameter reason: A stable string describing the interruption reason.
139
+ func emitCameraInterrupted(reason: String) {
140
+ delegate?.cameraWasInterrupted(reason: reason)
141
+ }
142
+
143
+ /// Emits a camera resumed event to the delegate.
144
+ func emitCameraResumed() {
145
+ delegate?.cameraInterruptionEnded()
146
+ }
125
147
 
126
- // Also post to NotificationCenter for backwards compatibility
127
- NotificationCenter.default.post(
128
- name: .cameraViewBarcodeDetected,
129
- object: nil,
130
- userInfo: event.toDictionary()
131
- )
148
+ /// Emits a camera runtime error event to the delegate.
149
+ /// - Parameters:
150
+ /// - message: A human-readable description of the error.
151
+ /// - code: The underlying `AVError` code, when available.
152
+ func emitCameraRuntimeError(message: String, code: Int?) {
153
+ delegate?.cameraRuntimeError(message: message, code: code)
132
154
  }
133
155
  }
@@ -25,6 +25,26 @@ public struct CameraSessionConfiguration: Sendable {
25
25
 
26
26
  /// Initial zoom factor.
27
27
  let zoomFactor: CGFloat?
28
+
29
+ /// Whether to prioritize photo quality over capture responsiveness.
30
+ /// When `false` (default) the plugin opts into the iOS 17+ responsive-capture
31
+ /// pipeline (zero-shutter-lag, responsive capture, fast capture prioritization)
32
+ /// where supported. When `true` those optimizations are skipped so captures
33
+ /// always prioritize quality.
34
+ let prioritizeQuality: Bool
35
+
36
+ /// Desired sensor aspect ratio ("4:3" or "16:9") for both the preview and
37
+ /// photo capture. `nil` keeps the default `.photo` session preset (4:3).
38
+ let aspectRatio: String?
39
+
40
+ /// Optional upper bound, in pixels, for the longer edge of captured
41
+ /// photos. `nil` keeps the photo output's default dimensions.
42
+ let captureMaxDimension: Int?
43
+
44
+ /// How the preview is scaled into its container. `"fit"` letterboxes the
45
+ /// whole frame (`.resizeAspect`); any other value (including `nil`) keeps
46
+ /// the default cover behavior (`.resizeAspectFill`).
47
+ let previewScaleMode: String?
28
48
  }
29
49
 
30
50
  /// Maps a Capacitor plugin call to a CameraSessionConfiguration struct.
@@ -37,6 +57,10 @@ public func sessionConfigFromPluginCall(_ call: CAPPluginCall) -> CameraSessionC
37
57
  let preferredCameraDeviceTypes = call.getArray("preferredCameraDeviceTypes") as? [String]
38
58
  let useTripleCameraIfAvailable = call.getBool("useTripleCameraIfAvailable", false)
39
59
  let zoomFactor = call.getDouble("zoomFactor").map { CGFloat($0) }
60
+ let prioritizeQuality = call.getBool("prioritizeQuality", false)
61
+ let aspectRatio = call.getString("aspectRatio")
62
+ let captureMaxDimension = call.getInt("captureMaxDimension")
63
+ let previewScaleMode = call.getString("previewScaleMode")
40
64
 
41
65
  // Parse barcode types if provided
42
66
  let barcodeTypes: [AVMetadataObject.ObjectType]?
@@ -54,6 +78,10 @@ public func sessionConfigFromPluginCall(_ call: CAPPluginCall) -> CameraSessionC
54
78
  position: position,
55
79
  preferredCameraDeviceTypes: preferredCameraDeviceTypes,
56
80
  useTripleCameraIfAvailable: useTripleCameraIfAvailable,
57
- zoomFactor: zoomFactor
81
+ zoomFactor: zoomFactor,
82
+ prioritizeQuality: prioritizeQuality,
83
+ aspectRatio: aspectRatio,
84
+ captureMaxDimension: captureMaxDimension,
85
+ previewScaleMode: previewScaleMode
58
86
  )
59
87
  }
@@ -1,6 +1,17 @@
1
1
  import AVFoundation
2
2
  import CoreImage
3
3
  import Foundation
4
+ import QuartzCore
5
+
6
+ /// Suppression window (seconds) during which a repeat of the same barcode
7
+ /// (identical value + type) is not re-emitted. A genuinely new code still emits
8
+ /// immediately. Kept consistent with the Android and web implementations.
9
+ private let barcodeSuppressionWindow: TimeInterval = 0.5
10
+
11
+ /// Once the per-key dedupe map grows past this size, expired entries are pruned
12
+ /// so a long session scanning many different codes stays bounded. Kept
13
+ /// consistent with the Android and web implementations.
14
+ private let barcodeDedupeMapPruneThreshold = 64
4
15
 
5
16
  extension CameraViewManager: AVCaptureMetadataOutputObjectsDelegate {
6
17
  /// Set up metadata output for the capture session in case it's not configured yet
@@ -23,7 +34,7 @@ extension CameraViewManager: AVCaptureMetadataOutputObjectsDelegate {
23
34
  }
24
35
 
25
36
  captureSession.addOutput(newOutput)
26
- newOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
37
+ newOutput.setMetadataObjectsDelegate(self, queue: barcodeMetadataQueue)
27
38
  metadataOutput = newOutput
28
39
  }
29
40
 
@@ -43,6 +54,16 @@ extension CameraViewManager: AVCaptureMetadataOutputObjectsDelegate {
43
54
  if let metadataOutput = captureSession.outputs.first(where: { $0 is AVCaptureMetadataOutput }) {
44
55
  captureSession.removeOutput(metadataOutput)
45
56
  }
57
+ resetBarcodeDedupeState()
58
+ }
59
+
60
+ /// Clears the per-key dedupe map so a freshly (re)started detection session
61
+ /// emits immediately. Hops onto `barcodeMetadataQueue` because the map is
62
+ /// confined to that queue.
63
+ internal func resetBarcodeDedupeState() {
64
+ barcodeMetadataQueue.async { [weak self] in
65
+ self?.recentBarcodeEmitTimes.removeAll()
66
+ }
46
67
  }
47
68
 
48
69
  /// Delegate method called when metadata objects are detected in the camera feed
@@ -71,32 +92,66 @@ extension CameraViewManager: AVCaptureMetadataOutputObjectsDelegate {
71
92
  return
72
93
  }
73
94
 
74
- let barcodeType = metadataObject.type.rawValue
75
-
76
- // Transform the metadata object to the coordinate space of the video preview layer
77
- // This is necessary to get the correct bounding box for the detected barcode
78
- // Which in our case should always equal to the device's screen
79
- // This way we can simply use pixel coordinates to get the bounding box of the detected barcode and easily show it in the webview
80
- guard let transformedMetadataObject = videoPreviewLayer.transformedMetadataObject(for: metadataObject)
81
- else {
95
+ // Normalize onto the shared BarcodeType vocabulary so the emitted `type`
96
+ // matches web and Android.
97
+ let barcodeType = convertToStringBarcodeType(metadataObject.type) ?? metadataObject.type.rawValue
98
+
99
+ // Suppress re-emission of the same code within the suppression window so
100
+ // a static barcode in view produces a bounded event rate instead of one
101
+ // event per frame. Timestamps are tracked per key so multiple codes in
102
+ // frame can't defeat the window by alternating. The raw-bytes hash is
103
+ // part of the key so two distinct binary-only codes (empty `stringValue`)
104
+ // don't suppress each other. Runs on the serial `barcodeMetadataQueue`,
105
+ // so no locking is needed.
106
+ let rawBytesKeyComponent = rawBytes.map { String($0.hashValue) } ?? ""
107
+ let barcodeKey = "\(barcodeType)\u{0}\(barcodeValue)\u{0}\(rawBytesKeyComponent)"
108
+ // Monotonic clock: a backward wall-clock jump must not extend the window.
109
+ let now = CACurrentMediaTime()
110
+ if let lastEmit = recentBarcodeEmitTimes[barcodeKey], now - lastEmit < barcodeSuppressionWindow {
82
111
  return
83
112
  }
84
113
 
85
- let boundingRect = BarcodeDetectedEvent.BoundingRect(
86
- x: Double(transformedMetadataObject.bounds.origin.x),
87
- y: Double(transformedMetadataObject.bounds.origin.y),
88
- width: Double(transformedMetadataObject.bounds.width),
89
- height: Double(transformedMetadataObject.bounds.height)
90
- )
91
-
92
- eventEmitter.emitBarcodeDetected(
93
- BarcodeDetectedEvent(
94
- value: barcodeValue,
95
- rawBytes: rawBytes,
96
- type: barcodeType,
97
- boundingRect: boundingRect
114
+ // Prune expired entries once the map grows, keeping it bounded during
115
+ // long sessions that scan many different codes.
116
+ if recentBarcodeEmitTimes.count > barcodeDedupeMapPruneThreshold {
117
+ recentBarcodeEmitTimes = recentBarcodeEmitTimes.filter {
118
+ now - $0.value < barcodeSuppressionWindow
119
+ }
120
+ }
121
+ // Recorded before the main-queue hop below so the map is only ever
122
+ // touched from `barcodeMetadataQueue`.
123
+ recentBarcodeEmitTimes[barcodeKey] = now
124
+
125
+ // `videoPreviewLayer` is a `CALayer` mutated concurrently by the main
126
+ // thread, so every layer access has to be main-confined. Only the cheap
127
+ // parse / dedupe work above stays on the private queue.
128
+ DispatchQueue.main.async { [weak self] in
129
+ guard let self = self else { return }
130
+
131
+ // Transform the metadata object into the preview layer's coordinate
132
+ // space, which equals the webview's, so the bounding box can be used
133
+ // as pixel coordinates directly.
134
+ guard let transformedMetadataObject = self.videoPreviewLayer.transformedMetadataObject(for: metadataObject)
135
+ else {
136
+ return
137
+ }
138
+
139
+ let boundingRect = BarcodeDetectedEvent.BoundingRect(
140
+ x: Double(transformedMetadataObject.bounds.origin.x),
141
+ y: Double(transformedMetadataObject.bounds.origin.y),
142
+ width: Double(transformedMetadataObject.bounds.width),
143
+ height: Double(transformedMetadataObject.bounds.height)
98
144
  )
99
- )
145
+
146
+ self.eventEmitter.emitBarcodeDetected(
147
+ BarcodeDetectedEvent(
148
+ value: barcodeValue,
149
+ rawBytes: rawBytes,
150
+ type: barcodeType,
151
+ boundingRect: boundingRect
152
+ )
153
+ )
154
+ }
100
155
  }
101
156
 
102
157
  private func getRawBytes(from metadataObject: AVMetadataMachineReadableCodeObject) -> [UInt8]? {
@@ -0,0 +1,37 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ /// Deferred Start (iOS 26+) lets `startRunning()` return as soon as the preview
5
+ /// pipeline is up and initializes the remaining outputs shortly afterwards,
6
+ /// roughly halving the time until the first frame is visible.
7
+ ///
8
+ /// The photo and video data outputs are deferred; the preview layer is not,
9
+ /// since it is the one consumer that must be live immediately and its first
10
+ /// frame is what triggers the deferred initialization. The metadata output is
11
+ /// added in its own transaction after the session is already running, so it
12
+ /// never contributes to `startRunning()` latency.
13
+ extension CameraViewManager {
14
+
15
+ /// Marks an output as deferrable so the session does not prepare its
16
+ /// resources until after `startRunning()` has returned.
17
+ ///
18
+ /// No-op below iOS 26 and on outputs that don't support deferral (setting the
19
+ /// flag on those raises `NSInvalidArgumentException`). Must be called inside
20
+ /// the configuration transaction that adds the output.
21
+ internal func deferStart(of output: AVCaptureOutput, _ deferred: Bool = true) {
22
+ if #available(iOS 26.0, *), output.isDeferredStartSupported {
23
+ output.isDeferredStartEnabled = deferred
24
+ }
25
+ }
26
+
27
+ /// Lets the session decide when to run the deferred initialization.
28
+ ///
29
+ /// Set explicitly rather than relying on the default, which is only `true`
30
+ /// for host apps linked against the iOS 26 SDK or later. Must be called
31
+ /// inside a configuration transaction on the session queue.
32
+ internal func configureAutomaticDeferredStart() {
33
+ if #available(iOS 26.0, *) {
34
+ captureSession.automaticallyRunsDeferredStart = true
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,131 @@
1
+ import AVFoundation
2
+ import Foundation
3
+
4
+ /// How long a one-shot tap-to-focus is held before continuous auto focus and
5
+ /// exposure are restored, so focus is never left permanently locked. A
6
+ /// subsequent `setFocusPoint` call cancels and replaces the pending restore.
7
+ private let cameraFocusResetDelay: TimeInterval = 3.0
8
+
9
+ extension CameraViewManager {
10
+ /// Focuses and meters the camera at a point expressed in the preview
11
+ /// layer's coordinate space (CSS/viewport pixels), i.e. tap-to-focus.
12
+ ///
13
+ /// The layer point is converted to a normalized device point on the main
14
+ /// thread (preview-layer geometry is main-thread only), then the device is
15
+ /// configured on `sessionQueue`. A one-shot `.autoFocus`/`.autoExpose` is
16
+ /// applied and continuous auto modes are restored after a short delay.
17
+ ///
18
+ /// - Parameters:
19
+ /// - x: Horizontal coordinate in CSS/viewport pixels from the left edge.
20
+ /// - y: Vertical coordinate in CSS/viewport pixels from the top edge.
21
+ /// - completion: Called on the main queue with `nil` on success or an error.
22
+ // swiftlint:disable:next identifier_name
23
+ func setFocusPoint(x: CGFloat, y: CGFloat, completion: @escaping (Error?) -> Void) {
24
+ guard captureSession.isRunning else {
25
+ completion(CameraError.sessionNotRunning)
26
+ return
27
+ }
28
+
29
+ let layerPoint = CGPoint(x: x, y: y)
30
+
31
+ // `captureDevicePointConverted(fromLayerPoint:)` accounts for the
32
+ // layer's `videoGravity` and its connection's rotation, so the mapping
33
+ // holds in both scale modes and in both orientations.
34
+ DispatchQueue.main.async { [weak self] in
35
+ guard let self = self else { return }
36
+ let devicePoint = self.videoPreviewLayer.captureDevicePointConverted(fromLayerPoint: layerPoint)
37
+
38
+ self.sessionQueue.async {
39
+ do {
40
+ try self.applyFocusPoint(devicePoint)
41
+ DispatchQueue.main.async { completion(nil) }
42
+ } catch {
43
+ DispatchQueue.main.async { completion(error) }
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ /// Applies the one-shot focus/exposure point of interest to the current
50
+ /// device and schedules the continuous-mode restore. Must run on
51
+ /// `sessionQueue`.
52
+ ///
53
+ /// Fixed-focus cameras (some front cameras) degrade to exposure-only
54
+ /// metering. If neither focus nor exposure at a point is supported, throws
55
+ /// `CameraError.focusNotSupported`.
56
+ private func applyFocusPoint(_ devicePoint: CGPoint) throws {
57
+ guard let device = currentCameraDevice else {
58
+ throw CameraError.cameraUnavailable
59
+ }
60
+
61
+ let canFocus = device.isFocusPointOfInterestSupported && device.isFocusModeSupported(.autoFocus)
62
+ let canExpose = device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose)
63
+
64
+ guard canFocus || canExpose else {
65
+ throw CameraError.focusNotSupported
66
+ }
67
+
68
+ do {
69
+ try device.lockForConfiguration()
70
+ defer { device.unlockForConfiguration() }
71
+
72
+ if canFocus {
73
+ device.focusPointOfInterest = devicePoint
74
+ device.focusMode = .autoFocus
75
+ }
76
+
77
+ if canExpose {
78
+ device.exposurePointOfInterest = devicePoint
79
+ device.exposureMode = .autoExpose
80
+ }
81
+ } catch {
82
+ throw CameraError.configurationFailed(error)
83
+ }
84
+
85
+ scheduleFocusReset()
86
+ }
87
+
88
+ /// Schedules restoration of continuous auto focus/exposure after
89
+ /// `cameraFocusResetDelay`, cancelling any previously scheduled restore so
90
+ /// rapid taps don't reset a later focus. Must run on `sessionQueue`.
91
+ private func scheduleFocusReset() {
92
+ focusResetWorkItem?.cancel()
93
+
94
+ let workItem = DispatchWorkItem { [weak self] in
95
+ self?.restoreContinuousFocusAndExposure()
96
+ }
97
+ focusResetWorkItem = workItem
98
+
99
+ sessionQueue.asyncAfter(deadline: .now() + cameraFocusResetDelay, execute: workItem)
100
+ }
101
+
102
+ /// Restores continuous auto focus/exposure centered on the frame, so a
103
+ /// one-shot tap-to-focus never leaves the camera permanently locked.
104
+ /// Best-effort: configuration failures are ignored. Runs on `sessionQueue`.
105
+ private func restoreContinuousFocusAndExposure() {
106
+ guard let device = currentCameraDevice else { return }
107
+
108
+ do {
109
+ try device.lockForConfiguration()
110
+ defer { device.unlockForConfiguration() }
111
+
112
+ let center = CGPoint(x: 0.5, y: 0.5)
113
+
114
+ if device.isFocusPointOfInterestSupported {
115
+ device.focusPointOfInterest = center
116
+ }
117
+ if device.isFocusModeSupported(.continuousAutoFocus) {
118
+ device.focusMode = .continuousAutoFocus
119
+ }
120
+
121
+ if device.isExposurePointOfInterestSupported {
122
+ device.exposurePointOfInterest = center
123
+ }
124
+ if device.isExposureModeSupported(.continuousAutoExposure) {
125
+ device.exposureMode = .continuousAutoExposure
126
+ }
127
+ } catch {
128
+ // Best-effort restore; nothing actionable if the device is busy.
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,156 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import UIKit
4
+
5
+ /// Handles app lifecycle transitions and `AVCaptureSession` interruption /
6
+ /// runtime-error notifications.
7
+ ///
8
+ /// Backgrounding pauses the session (and finalizes any in-flight recording so
9
+ /// its JS promise settles); returning to the foreground resumes it. Session
10
+ /// interruptions (phone calls, Control Center audio capture, iPad camera loss)
11
+ /// and runtime errors are surfaced to JS as `cameraInterrupted`,
12
+ /// `cameraResumed`, and `cameraRuntimeError` events, and a media-services reset
13
+ /// restarts the session automatically.
14
+ extension CameraViewManager {
15
+
16
+ // MARK: - App Lifecycle Observers
17
+
18
+ /// Sets up observers for app background/foreground transitions.
19
+ ///
20
+ /// Uses `didEnterBackground`/`willEnterForeground` rather than
21
+ /// `willResignActive`/`didBecomeActive` on purpose: resign/active also fire
22
+ /// for Control Center pull-downs, notification banners, and permission
23
+ /// dialogs, none of which should tear down the session.
24
+ internal func setupAppLifecycleObservers() {
25
+ NotificationCenter.default.addObserver(
26
+ self,
27
+ selector: #selector(handleAppDidEnterBackground),
28
+ name: UIApplication.didEnterBackgroundNotification,
29
+ object: nil
30
+ )
31
+
32
+ NotificationCenter.default.addObserver(
33
+ self,
34
+ selector: #selector(handleAppWillEnterForeground),
35
+ name: UIApplication.willEnterForegroundNotification,
36
+ object: nil
37
+ )
38
+ }
39
+
40
+ /// Pauses the camera session when the app enters the background.
41
+ ///
42
+ /// Stopping the running session finalizes any in-flight recording, which
43
+ /// invokes the recording delegate and settles a pending `stopRecording`
44
+ /// promise (with the partial file or an error) rather than leaving it to
45
+ /// hang.
46
+ @objc internal func handleAppDidEnterBackground() {
47
+ guard captureSession.isRunning else { return }
48
+ sessionQueue.async { [weak self] in
49
+ self?.captureSession.stopRunning()
50
+ }
51
+ }
52
+
53
+ /// Resumes the camera session when the app returns to the foreground.
54
+ @objc internal func handleAppWillEnterForeground() {
55
+ guard !captureSession.isRunning else { return }
56
+ sessionQueue.async { [weak self] in
57
+ // Re-checked on the session queue: `isRunning` can have changed
58
+ // since the cheap check above, and `isSessionStoppedByUser` is
59
+ // confined to this queue.
60
+ guard let self = self, !self.isSessionStoppedByUser, !self.captureSession.isRunning else { return }
61
+ self.captureSession.startRunning()
62
+ }
63
+ }
64
+
65
+ // MARK: - Interruption & Runtime-Error Observers
66
+
67
+ /// Sets up observers for capture-session interruption and runtime-error
68
+ /// notifications, scoped to this manager's `captureSession`.
69
+ internal func setupInterruptionObservers() {
70
+ NotificationCenter.default.addObserver(
71
+ self,
72
+ selector: #selector(handleSessionWasInterrupted(_:)),
73
+ name: AVCaptureSession.wasInterruptedNotification,
74
+ object: captureSession
75
+ )
76
+
77
+ NotificationCenter.default.addObserver(
78
+ self,
79
+ selector: #selector(handleSessionInterruptionEnded(_:)),
80
+ name: AVCaptureSession.interruptionEndedNotification,
81
+ object: captureSession
82
+ )
83
+
84
+ NotificationCenter.default.addObserver(
85
+ self,
86
+ selector: #selector(handleSessionRuntimeError(_:)),
87
+ name: AVCaptureSession.runtimeErrorNotification,
88
+ object: captureSession
89
+ )
90
+ }
91
+
92
+ /// Handles a capture-session interruption (e.g. phone call, another app
93
+ /// using the camera, iPad Split View camera loss) by emitting
94
+ /// `cameraInterrupted` with a reason.
95
+ @objc internal func handleSessionWasInterrupted(_ notification: Notification) {
96
+ eventEmitter.emitCameraInterrupted(
97
+ reason: interruptionReasonString(from: notification)
98
+ )
99
+ }
100
+
101
+ /// Handles the end of a capture-session interruption. AVFoundation resumes
102
+ /// the session automatically; we just surface `cameraResumed` to JS.
103
+ @objc internal func handleSessionInterruptionEnded(_ notification: Notification) {
104
+ eventEmitter.emitCameraResumed()
105
+ }
106
+
107
+ /// Handles a capture-session runtime error by emitting `cameraRuntimeError`
108
+ /// and restarting the session when media services were reset.
109
+ @objc internal func handleSessionRuntimeError(_ notification: Notification) {
110
+ let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError
111
+ let message = error?.localizedDescription
112
+ ?? "Unknown capture session runtime error"
113
+ eventEmitter.emitCameraRuntimeError(
114
+ message: message,
115
+ code: error?.code.rawValue
116
+ )
117
+
118
+ // A media-services reset invalidates the session; restart it so the
119
+ // preview recovers instead of staying frozen.
120
+ guard error?.code == .mediaServicesWereReset else { return }
121
+ sessionQueue.async { [weak self] in
122
+ guard let self = self, !self.isSessionStoppedByUser, !self.captureSession.isRunning else { return }
123
+ self.captureSession.startRunning()
124
+ }
125
+ }
126
+
127
+ // MARK: - Helpers
128
+
129
+ /// Maps an interruption notification's reason to a stable JS string that
130
+ /// mirrors the `CameraInterruptionReason` TypeScript union.
131
+ private func interruptionReasonString(from notification: Notification) -> String {
132
+ guard
133
+ let reasonValue = notification.userInfo?[
134
+ AVCaptureSessionInterruptionReasonKey
135
+ ] as? Int,
136
+ let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue)
137
+ else {
138
+ return "unknown"
139
+ }
140
+
141
+ switch reason {
142
+ case .videoDeviceNotAvailableInBackground:
143
+ return "videoDeviceNotAvailableInBackground"
144
+ case .audioDeviceInUseByAnotherClient:
145
+ return "audioDeviceInUseByAnotherClient"
146
+ case .videoDeviceInUseByAnotherClient:
147
+ return "videoDeviceInUseByAnotherClient"
148
+ case .videoDeviceNotAvailableWithMultipleForegroundApps:
149
+ return "videoDeviceNotAvailableWithMultipleForegroundApps"
150
+ case .videoDeviceNotAvailableDueToSystemPressure:
151
+ return "videoDeviceNotAvailableDueToSystemPressure"
152
+ @unknown default:
153
+ return "unknown"
154
+ }
155
+ }
156
+ }