capacitor-camera-view 2.4.0 → 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.
- package/CapacitorCameraView.podspec +1 -1
- package/Package.swift +1 -1
- package/README.md +341 -49
- package/android/build.gradle +0 -1
- package/android/src/main/AndroidManifest.xml +0 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +945 -207
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
- package/dist/docs.json +475 -30
- package/dist/esm/definitions.d.ts +478 -26
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/utils.d.ts +59 -15
- package/dist/esm/utils.js +79 -38
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/web.d.ts +191 -13
- package/dist/esm/web.js +624 -141
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +711 -179
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +711 -179
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
- package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
- package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
- package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
- package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
- package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +195 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
- package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +98 -65
- package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
- 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., "
|
|
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
|
-
|
|
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
|
-
///
|
|
105
|
-
///
|
|
106
|
-
|
|
107
|
-
///
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
+
}
|