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
|
@@ -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
|
-
///
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
///
|
|
70
|
-
|
|
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
|
|
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
|
|
108
|
-
///
|
|
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
|
-
/// -
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
299
|
+
guard let self = self else {
|
|
300
|
+
DispatchQueue.main.async {
|
|
301
|
+
completion?()
|
|
302
|
+
}
|
|
303
|
+
return
|
|
185
304
|
}
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
"
|
|
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
|
-
///
|
|
647
|
-
///
|
|
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
|
-
/// -
|
|
652
|
-
///
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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.
|
|
677
|
-
|
|
678
|
-
completion(
|
|
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
|
-
///
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
|
814
|
-
|
|
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
|
}
|