capacitor-camera-view 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CapacitorCameraView.podspec +17 -0
  2. package/LICENSE +201 -0
  3. package/Package.swift +28 -0
  4. package/README.md +654 -0
  5. package/android/build.gradle +79 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +555 -0
  8. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +227 -0
  9. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +11 -0
  10. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +14 -0
  11. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +10 -0
  12. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +16 -0
  13. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/ZoomFactors.kt +14 -0
  14. package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +86 -0
  15. package/android/src/main/res/.gitkeep +0 -0
  16. package/dist/docs.json +968 -0
  17. package/dist/esm/definitions.d.ts +378 -0
  18. package/dist/esm/definitions.js +2 -0
  19. package/dist/esm/definitions.js.map +1 -0
  20. package/dist/esm/index.d.ts +7 -0
  21. package/dist/esm/index.js +10 -0
  22. package/dist/esm/index.js.map +1 -0
  23. package/dist/esm/utils.d.ts +45 -0
  24. package/dist/esm/utils.js +108 -0
  25. package/dist/esm/utils.js.map +1 -0
  26. package/dist/esm/web.d.ts +108 -0
  27. package/dist/esm/web.js +406 -0
  28. package/dist/esm/web.js.map +1 -0
  29. package/dist/plugin.cjs.js +530 -0
  30. package/dist/plugin.cjs.js.map +1 -0
  31. package/dist/plugin.js +533 -0
  32. package/dist/plugin.js.map +1 -0
  33. package/ios/Sources/CameraViewPlugin/CameraError.swift +39 -0
  34. package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +32 -0
  35. package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +91 -0
  36. package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +52 -0
  37. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +78 -0
  38. package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +633 -0
  39. package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +295 -0
  40. package/ios/Sources/CameraViewPlugin/Utils.swift +56 -0
  41. package/ios/Tests/CameraViewPluginTests/CameraViewPluginTests.swift +15 -0
  42. package/package.json +94 -0
@@ -0,0 +1,633 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import UIKit
4
+
5
+ /// Supported camera device types for the capture session.
6
+ internal let SUPPORTED_CAMERA_DEVICE_TYPES: [AVCaptureDevice.DeviceType] = [
7
+ .builtInWideAngleCamera,
8
+ .builtInUltraWideCamera,
9
+ .builtInTelephotoCamera,
10
+ .builtInDualCamera,
11
+ .builtInDualWideCamera,
12
+ .builtInTripleCamera,
13
+ .builtInTrueDepthCamera,
14
+ ]
15
+
16
+ /// A camera implementation that handles camera session management and photo capture.
17
+ @objc public class CameraViewManager: NSObject {
18
+ internal let captureSession = AVCaptureSession()
19
+ internal let avPhotoOutput = AVCapturePhotoOutput()
20
+ internal let avVideoDataOutput = AVCaptureVideoDataOutput()
21
+ internal let videoPreviewLayer = AVCaptureVideoPreviewLayer()
22
+
23
+ /// The currently active camera device.
24
+ private var currentCameraDevice: AVCaptureDevice?
25
+
26
+ /// List of preferred camera devices, this overrides the SUPPORTED_CAMERA_DEVICE_TYPES for the capture session
27
+ private var preferredCameraDeviceTypes = SUPPORTED_CAMERA_DEVICE_TYPES
28
+
29
+ /// Currently selected flash mode.
30
+ private var flashMode: AVCaptureDevice.FlashMode = .auto
31
+
32
+ /// Reference to the blur overlay view that is shown when switching to the triple camera in order to have a smooth transition
33
+ private var blurOverlayView: UIVisualEffectView?
34
+
35
+ /// Reference to the webView that is used by the Capacitor plugin for the preview layer is shown on
36
+ private var webView: UIView?
37
+
38
+ /// Callback for when photo capture completes.
39
+ internal var photoCaptureHandler: ((UIImage?, Error?) -> Void)?
40
+
41
+ /// Callback for when snapshot capture completes.
42
+ internal var snapshotCompletionHandler: ((UIImage?, Error?) -> Void)?
43
+
44
+ public override init() {
45
+ super.init()
46
+ setupOrientationObserver()
47
+ setupAppLifecycleObservers()
48
+ }
49
+
50
+ deinit {
51
+ stopSession()
52
+ NotificationCenter.default.removeObserver(self)
53
+ }
54
+
55
+ /// MARK: - Plugin API
56
+
57
+ /// Starts capture session for the specified camera position.
58
+ /// This will reuse the existing capture session if it is already running.
59
+ ///
60
+ /// - Parameters:
61
+ /// - position: The position of the camera to start the session for.
62
+ /// - completion: A closure called when the session setup completes with an optional error.
63
+ public func startSession(
64
+ configuration: CameraSessionConfiguration,
65
+ webView: UIView,
66
+ completion: @escaping (Error?) -> Void
67
+ ) {
68
+ if let preferredCameraDeviceTypes = configuration.preferredCameraDeviceTypes {
69
+ self.preferredCameraDeviceTypes = convertToNativeCameraTypes(preferredCameraDeviceTypes)
70
+ }
71
+
72
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
73
+ guard let self = self else { return }
74
+
75
+ do {
76
+ try self.initiateCaptureSession(configuration: configuration)
77
+ } catch {
78
+ DispatchQueue.main.async {
79
+ completion(error)
80
+ }
81
+ return
82
+ }
83
+
84
+ // Start the capture session
85
+ self.captureSession.startRunning()
86
+
87
+ // Display the camera preview on the provided webview
88
+ self.displayPreview(
89
+ on: webView,
90
+ completion: { error in
91
+ if error != nil { completion(error) }
92
+
93
+ // Complete already because the camera is ready to be used
94
+ // We might asynchronously upgrade to a triple camera in the background if available and configured
95
+ completion(nil)
96
+
97
+ if configuration.useTripleCameraIfAvailable {
98
+ Task {
99
+ await self.upgradeToTripleCameraIfAvailable()
100
+ }
101
+ }
102
+ })
103
+ }
104
+ }
105
+
106
+ /// Stops the current capture session.
107
+ public func stopSession() {
108
+ guard captureSession.isRunning else { return }
109
+
110
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
111
+ self?.captureSession.stopRunning()
112
+ }
113
+
114
+ DispatchQueue.main.async { [weak self] in
115
+ guard let self = self else { return }
116
+ self.videoPreviewLayer.removeFromSuperlayer()
117
+ self.webView?.isOpaque = true
118
+ self.webView?.backgroundColor = nil
119
+ self.webView = nil
120
+
121
+ if let blurOverlayView = self.blurOverlayView {
122
+ blurOverlayView.removeFromSuperview()
123
+ self.blurOverlayView = nil
124
+ }
125
+ }
126
+ }
127
+
128
+ /// Checks if the capture session is currently running.
129
+ public func isRunning() -> Bool {
130
+ return captureSession.isRunning
131
+ }
132
+
133
+ /// Captures a photo with the current camera settings.
134
+ /// - Returns: The picture as UIImage via `AVCapturePhotoCaptureDelegate`
135
+ public func capturePhoto(completion: @escaping (UIImage?, Error?) -> Void) {
136
+ guard let cameraDevice = currentCameraDevice else {
137
+ completion(nil, CameraError.cameraUnavailable)
138
+ return
139
+ }
140
+
141
+ guard captureSession.isRunning else {
142
+ completion(nil, CameraError.sessionNotRunning)
143
+ return
144
+ }
145
+
146
+ let photoSettings = AVCapturePhotoSettings()
147
+ if cameraDevice.hasFlash {
148
+ photoSettings.flashMode = flashMode
149
+ } else {
150
+ photoSettings.flashMode = .off
151
+ }
152
+
153
+ // Ensure proper orientation
154
+ if let photoConnection = avPhotoOutput.connection(with: .video),
155
+ let previewConnection = videoPreviewLayer.connection {
156
+ if photoConnection.isVideoOrientationSupported {
157
+ photoConnection.videoOrientation = previewConnection.videoOrientation
158
+ }
159
+ }
160
+
161
+ avPhotoOutput.capturePhoto(with: photoSettings, delegate: self)
162
+ photoCaptureHandler = completion
163
+ }
164
+
165
+ /// Capture a snapshot of the current camera view. This is faster than actually processing a
166
+ /// photo via capturePhoto
167
+ /// - Parameter completion: called with the captured UIImage or an error.
168
+ public func captureSnapshot(completion: @escaping (UIImage?, Error?) -> Void) {
169
+ guard currentCameraDevice != nil else {
170
+ completion(nil, CameraError.cameraUnavailable)
171
+ return
172
+ }
173
+
174
+ guard captureSession.isRunning else {
175
+ completion(nil, CameraError.sessionNotRunning)
176
+ return
177
+ }
178
+
179
+ // Ensure proper orientation
180
+ if let videoConnection = avVideoDataOutput.connection(with: .video),
181
+ let previewConnection = videoPreviewLayer.connection {
182
+ if videoConnection.isVideoOrientationSupported {
183
+ videoConnection.videoOrientation = previewConnection.videoOrientation
184
+ }
185
+ }
186
+
187
+ // Create a serial queue for sample buffer processing
188
+ let sampleBufferQueue = DispatchQueue(label: "com.michaelwolz.capacitorcameraview.snapshotQueue")
189
+
190
+ // Set the delegate for a single frame capture
191
+ snapshotCompletionHandler = completion
192
+ avVideoDataOutput.setSampleBufferDelegate(self, queue: sampleBufferQueue)
193
+ }
194
+
195
+ /// Flips the camera to the opposite position (front to back or back to front).
196
+ public func flipCamera() throws {
197
+ let currentPosition: AVCaptureDevice.Position = currentCameraDevice?.position ?? .back
198
+ let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back
199
+
200
+ let newCamera = try getCameraDevice(for: newPosition)
201
+ try setInput(with: newCamera)
202
+ }
203
+
204
+ /// Sets the flash mode for the currently active camera device.
205
+ ///
206
+ /// - Parameter mode: The desired flash mode (.on, .of, or .auto).
207
+ /// - Throws: An error if the flash mode cannot be set or is not supported.
208
+ public func setFlashMode(_ mode: AVCaptureDevice.FlashMode) throws {
209
+ guard let camera = currentCameraDevice else { throw CameraError.cameraUnavailable }
210
+ guard camera.hasFlash else { throw CameraError.unsupportedFlashMode }
211
+ guard avPhotoOutput.supportedFlashModes.contains(mode)
212
+ else {
213
+ throw CameraError.unsupportedFlashMode
214
+ }
215
+
216
+ flashMode = mode
217
+ }
218
+
219
+ /// Gets the current flash mode for the current camera device.
220
+ public func getFlashMode() -> AVCaptureDevice.FlashMode {
221
+ return flashMode
222
+ }
223
+
224
+ /// Gets the supported flash modes for the current camera device.
225
+ ///
226
+ /// - Returns: An array of supported flash modes, fallback is .off
227
+ public func getSupportedFlashModes() -> [AVCaptureDevice.FlashMode] {
228
+ if let camera = currentCameraDevice, camera.hasFlash {
229
+ return avPhotoOutput.supportedFlashModes
230
+ }
231
+
232
+ return [.off]
233
+ }
234
+
235
+ /// Gets the minimum, maximum, and current zoom factors supported by the current camera device.
236
+ /// The maximum zoom factor is limited to a reasonable value of 10x to prevent excessive zooming
237
+ /// because some devices report very high zoom factors that aren't useful.
238
+ ///
239
+ /// - Returns: A tuple containing the minimum, maximum, and current zoom factors.
240
+ public func getSupportedZoomFactors() -> (min: CGFloat, max: CGFloat, current: CGFloat) {
241
+ guard let currentDevice = currentCameraDevice else {
242
+ return (
243
+ min: 1.0,
244
+ max: 1.0,
245
+ current: 1.0
246
+ )
247
+ }
248
+
249
+ let minZoomFactor = currentDevice.minAvailableVideoZoomFactor
250
+ let maxZoomFactor = min(currentDevice.activeFormat.videoMaxZoomFactor, 10.0)
251
+ let currentZoomFactor = currentDevice.videoZoomFactor
252
+
253
+ return (
254
+ min: minZoomFactor,
255
+ max: maxZoomFactor,
256
+ current: currentZoomFactor
257
+ )
258
+ }
259
+
260
+ /// Sets the zoom factor for the current camera device.
261
+ ///
262
+ /// - Parameters:
263
+ /// - factor: The zoom factor to set.
264
+ /// - ramp: If enabled the zoom will be applied via ramp
265
+ /// - Throws: An error if the zoom factor cannot be set.
266
+ public func setZoomFactor(_ factor: CGFloat, ramp: Bool = true) throws {
267
+ guard let device = currentCameraDevice else { throw CameraError.cameraUnavailable }
268
+
269
+ let supportedZoomFactors = getSupportedZoomFactors()
270
+ guard factor >= supportedZoomFactors.min && factor <= supportedZoomFactors.max else {
271
+ throw CameraError.zoomFactorOutOfRange
272
+ }
273
+
274
+ do {
275
+ try device.lockForConfiguration()
276
+ defer { device.unlockForConfiguration() }
277
+
278
+ if ramp {
279
+ device.ramp(toVideoZoomFactor: factor, withRate: 6.0)
280
+ } else {
281
+ device.videoZoomFactor = factor
282
+ }
283
+ } catch {
284
+ throw CameraError.configurationFailed(error)
285
+ }
286
+ }
287
+
288
+ /// Initiates the capture session with the specified camera device.
289
+ ///
290
+ /// - Parameters:
291
+ /// - configuration: The configuration object for the camera session.
292
+ private func initiateCaptureSession(configuration: CameraSessionConfiguration) throws {
293
+ captureSession.beginConfiguration()
294
+ defer { captureSession.commitConfiguration() }
295
+
296
+ // Configure the camera device
297
+ let device: AVCaptureDevice
298
+ if let deviceId = configuration.deviceId {
299
+ device = try getCameraDeviceById(deviceId)
300
+ } else {
301
+ device = try getCameraDevice(for: configuration.position)
302
+ }
303
+
304
+ // Set the session preset to photo if supported (which should be the case for all devices)
305
+ if captureSession.canSetSessionPreset(.photo) {
306
+ captureSession.sessionPreset = .photo
307
+ }
308
+
309
+ // Set the camera input
310
+ try setInput(with: device)
311
+
312
+ // Set up the photo output
313
+ try setupPhotoOutput()
314
+
315
+ // Set up the video data output for snapshots
316
+ try setupVideoDataOutput()
317
+
318
+ // Setup metadata output for QR code scanning if enabled
319
+ if configuration.enableBarcodeDetection {
320
+ try setupMetadataOutput()
321
+ }
322
+
323
+ // Set the initial zoom factor if specified
324
+ if let zoomFactor = configuration.zoomFactor {
325
+ try setZoomFactor(zoomFactor, ramp: false)
326
+ }
327
+ }
328
+
329
+ /// Sets the input for the capture session.
330
+ /// Make sure to call `captureSession.beginConfiguration` before calling this
331
+ ///
332
+ /// - Parameter device: The camera device to use as input.
333
+ /// - Throws: An error if the input cannot be set.
334
+ private func setInput(with device: AVCaptureDevice) throws {
335
+ guard currentCameraDevice?.uniqueID != device.uniqueID else {
336
+ // Nothing todo, input is already configured for the desired device
337
+ return
338
+ }
339
+
340
+ // Remove any existing inputs
341
+ captureSession.inputs.forEach { captureSession.removeInput($0) }
342
+
343
+ do {
344
+ let input = try AVCaptureDeviceInput(device: device)
345
+ if !captureSession.canAddInput(input) {
346
+ throw CameraError.inputAdditionFailed
347
+ }
348
+
349
+ captureSession.addInput(input)
350
+ currentCameraDevice = device
351
+ } catch {
352
+ if let avError = error as? AVError {
353
+ throw CameraError.configurationFailed(avError)
354
+ } else {
355
+ throw CameraError.inputAdditionFailed
356
+ }
357
+ }
358
+ }
359
+
360
+ /// Retrieve a list of a available camera devices
361
+ ///
362
+ /// - Returns: a list of all available camera devices.
363
+ public func getAvailableDevices() -> [AVCaptureDevice] {
364
+ return AVCaptureDevice.DiscoverySession(
365
+ deviceTypes: SUPPORTED_CAMERA_DEVICE_TYPES,
366
+ mediaType: .video,
367
+ position: .unspecified
368
+ ).devices
369
+ }
370
+
371
+ /// Returns a list of available camera devices based on the preferences by the user
372
+ ///
373
+ /// - Returns: a list of camera devices based on the preferredCameraDeviceTypes
374
+ private func getPreferredCameraDevices() -> [AVCaptureDevice] {
375
+ return AVCaptureDevice.DiscoverySession(
376
+ deviceTypes: self.preferredCameraDeviceTypes,
377
+ mediaType: .video,
378
+ position: .unspecified
379
+ ).devices
380
+ }
381
+
382
+ /// Gets the best match camera device for the specified position.
383
+ /// This method will consider preferredCameraDevices as possibly provided by the user allowing a best
384
+ /// match to the users request.
385
+ ///
386
+ /// - Parameters:
387
+ /// - position: The position of the camera device to get
388
+ /// - Returns: The camera device for the specified position
389
+ /// - Throws: An error if no camera device is found.
390
+ private func getCameraDevice(for position: AVCaptureDevice.Position?) throws -> AVCaptureDevice
391
+ {
392
+ let preferredDevices = getPreferredCameraDevices()
393
+
394
+ // First try to get the best match based on the users preferred camera device types
395
+ if let match = preferredDevices.first(where: { $0.position == position }) {
396
+ return match
397
+ }
398
+
399
+ // If we haven't found one we try to get a best match for the position by iterating all supported device types
400
+ // Only doing this when preferredCameraDeviceTypes size differs from SUPPORTED_CAMERA_DEVICE_TYPES, otherwise
401
+ // we don't have to initialize a new discovery session
402
+ if preferredCameraDeviceTypes.count < SUPPORTED_CAMERA_DEVICE_TYPES.count,
403
+ let match = getAvailableDevices().first(where: { $0.position == position }) {
404
+ return match
405
+ }
406
+
407
+ // Otherwise, fallback to default video device or throw an error
408
+ guard let device = AVCaptureDevice.default(for: .video) else {
409
+ throw CameraError.cameraUnavailable
410
+ }
411
+
412
+ // Log when we're falling back to a device with different position than requested
413
+ if let requestedPosition = position, device.position != requestedPosition {
414
+ print("Warning: Falling back to camera at position \(device.position) when \(requestedPosition) was requested")
415
+ }
416
+
417
+ return device
418
+ }
419
+
420
+ /// Gets the best camera device for the specified position.
421
+ ///
422
+ /// - Parameters:
423
+ /// - deviceId: The unique identifier of the camera device to get
424
+ /// - Returns: The camera device for the specified position
425
+ /// - Throws: An error if no camera device is found.
426
+ private func getCameraDeviceById(_ deviceId: String) throws -> AVCaptureDevice {
427
+ guard let device = getAvailableDevices().first(where: { $0.uniqueID == deviceId }) else {
428
+ throw CameraError.cameraUnavailable
429
+ }
430
+ return device
431
+ }
432
+
433
+ /// MARK: - UI Preview Layer
434
+
435
+ /// Sets up the preview layer for the capture session which will
436
+ /// display the camera feed in the view.
437
+ ///
438
+ /// - Parameters:
439
+ /// - view: The view that will display the camera preview.
440
+ /// - completion: The completion handler after successfully adding the previewLayer to the provided view
441
+ /// - Throws: An error if the preview layer cannot be set up.
442
+ private func displayPreview(on view: UIView, completion: @escaping (Error?) -> Void) {
443
+ guard captureSession.isRunning else {
444
+ completion(CameraError.sessionNotRunning)
445
+ return
446
+ }
447
+
448
+ self.webView = view
449
+
450
+ videoPreviewLayer.session = captureSession
451
+ videoPreviewLayer.videoGravity = .resizeAspectFill
452
+
453
+ DispatchQueue.main.async { [weak self] in
454
+ guard let self = self else { return }
455
+ view.isOpaque = false
456
+ view.backgroundColor = UIColor.clear
457
+ view.scrollView.backgroundColor = UIColor.clear
458
+
459
+ self.videoPreviewLayer.frame = view.bounds
460
+ view.layer.insertSublayer(self.videoPreviewLayer, at: 0)
461
+
462
+ self.updatePreviewOrientation()
463
+
464
+ completion(nil)
465
+ }
466
+ }
467
+
468
+ /// MARK: - Triple Camera
469
+
470
+ /// Upgrades the camera to the triple camera if available.
471
+ /// Initializing the triple camera is an expensive operation and takes some time.
472
+ /// This is why by default the regular physical camera is used and then later upgraded to the triple camera if available (Pro models only).
473
+ private func upgradeToTripleCameraIfAvailable() async {
474
+ guard captureSession.isRunning else { return }
475
+
476
+ // Check if a triple camera is available (only on newer Pro models)
477
+ let devices = AVCaptureDevice.DiscoverySession(
478
+ deviceTypes: [.builtInTripleCamera],
479
+ mediaType: .video,
480
+ position: .back
481
+ ).devices
482
+
483
+ // If we don't have a triple camera, exit early
484
+ guard let tripleCamera = devices.first else { return }
485
+
486
+ // Don't do anything if we're already using the triple camera
487
+ if currentCameraDevice?.uniqueID == tripleCamera.uniqueID {
488
+ return
489
+ }
490
+
491
+ // Add a blur overlay to the webview to have a smooth transition when switching to the triple camera
492
+ await addBlurOverlay()
493
+
494
+ await Task.detached(priority: .userInitiated) {
495
+ self.captureSession.beginConfiguration()
496
+
497
+ do {
498
+ try self.setInput(with: tripleCamera)
499
+ // TODO: Consider configured zoom factor from the initial camera???
500
+ try self.setZoomFactor(2.0, ramp: false)
501
+ } catch {
502
+ // Fail silently if we can't upgrade to the triple camera
503
+ print("Failed to upgrade to triple camera: \(error.localizedDescription)")
504
+ }
505
+
506
+ self.captureSession.commitConfiguration()
507
+ }.value
508
+
509
+ // Small delay to let camera stabilize
510
+ try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
511
+
512
+ await removeBlurOverlayWithAnimation()
513
+ }
514
+
515
+ /// Adds a blur overlay to the webview to have a smooth transition when switching to the triple camera
516
+ @MainActor
517
+ private func addBlurOverlay() async {
518
+ guard let view = self.webView else { return }
519
+
520
+ let blurEffect = UIBlurEffect(style: .light)
521
+ let blurOverlayView = UIVisualEffectView(effect: blurEffect)
522
+ self.blurOverlayView = blurOverlayView
523
+
524
+ blurOverlayView.frame = view.bounds
525
+ blurOverlayView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
526
+
527
+ // Add the blurEffect layer to the view hierarchy just above the preview layer
528
+ // but below the web content
529
+ view.insertSubview(blurOverlayView, at: 1)
530
+ }
531
+
532
+ /// Removes the blur overlay with a fade out animation to have a smooth transition
533
+ /// - Parameter duration: The duration of the fade out animation
534
+ @MainActor
535
+ private func removeBlurOverlayWithAnimation(duration: TimeInterval = 0.3) async {
536
+ guard let blurEffectView = blurOverlayView else { return }
537
+
538
+ await withCheckedContinuation { continuation in
539
+ UIView.animate(
540
+ withDuration: duration,
541
+ animations: {
542
+ blurEffectView.alpha = 0
543
+ },
544
+ completion: { _ in
545
+ blurEffectView.removeFromSuperview()
546
+ self.blurOverlayView = nil
547
+ continuation.resume()
548
+ })
549
+ }
550
+ }
551
+
552
+ /// MARK: - Orientation Observer
553
+
554
+ /// Sets up an observer for device orientation changes to update the preview layer orientation.
555
+ private func setupOrientationObserver() {
556
+ NotificationCenter.default.addObserver(
557
+ forName: UIDevice.orientationDidChangeNotification,
558
+ object: nil,
559
+ queue: .main
560
+ ) { [weak self] _ in
561
+ self?.updatePreviewOrientation()
562
+ }
563
+ }
564
+
565
+ /// Updates the preview layer orientation based on the current device orientation.
566
+ private func updatePreviewOrientation() {
567
+ guard let connection = self.videoPreviewLayer.connection, connection.isVideoOrientationSupported else {
568
+ return
569
+ }
570
+
571
+ let interfaceOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait
572
+ let videoOrientation: AVCaptureVideoOrientation
573
+
574
+ switch interfaceOrientation {
575
+ case .portrait:
576
+ videoOrientation = .portrait
577
+ case .landscapeLeft:
578
+ videoOrientation = .landscapeLeft
579
+ case .landscapeRight:
580
+ videoOrientation = .landscapeRight
581
+ case .portraitUpsideDown:
582
+ videoOrientation = .portraitUpsideDown
583
+ default:
584
+ videoOrientation = .portrait
585
+ }
586
+
587
+ connection.videoOrientation = videoOrientation
588
+
589
+ // Update the frame of the preview layer to match the new bounds
590
+ DispatchQueue.main.async { [weak self] in
591
+ guard let self = self, let view = self.webView else { return }
592
+ self.videoPreviewLayer.frame = view.bounds
593
+ }
594
+ }
595
+
596
+ /// MARK: - App Lifecycle Observers
597
+
598
+ /// Sets up observers for app lifecycle events to pause and resume the camera session.
599
+ private func setupAppLifecycleObservers() {
600
+ NotificationCenter.default.addObserver(
601
+ self,
602
+ selector: #selector(handleAppWillResignActive),
603
+ name: UIApplication.willResignActiveNotification,
604
+ object: nil
605
+ )
606
+
607
+ NotificationCenter.default.addObserver(
608
+ self,
609
+ selector: #selector(handleAppDidBecomeActive),
610
+ name: UIApplication.didBecomeActiveNotification,
611
+ object: nil)
612
+ }
613
+
614
+ /// Handles the app going to background by pausing the camera session.
615
+ @objc private func handleAppWillResignActive() {
616
+ // Pause the session when app goes to background to save resources
617
+ if captureSession.isRunning {
618
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
619
+ self?.captureSession.stopRunning()
620
+ }
621
+ }
622
+ }
623
+
624
+ /// Handles the app coming back to foreground by resuming the camera session.
625
+ @objc private func handleAppDidBecomeActive() {
626
+ // Resume the session when app comes back to foreground
627
+ if !captureSession.isRunning && webView != nil {
628
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
629
+ self?.captureSession.startRunning()
630
+ }
631
+ }
632
+ }
633
+ }