@capgo/camera-preview 8.11.9 → 8.11.11

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.
@@ -0,0 +1,107 @@
1
+ import CoreGraphics
2
+ import Foundation
3
+
4
+ /// Viewport sizing for numeric aspect ratios (`4:3`, `16:9`) vs full-frame `fill`.
5
+ enum AspectRatioLayout {
6
+ static func isFillMode(_ aspectRatio: String) -> Bool {
7
+ aspectRatio.lowercased() == "fill"
8
+ }
9
+
10
+ /// Width-to-height ratio for the preview view in the current interface orientation.
11
+ /// Returns `nil` for `fill` and other non-numeric values.
12
+ static func parseViewportAspectRatio(_ ratio: String, isPortrait: Bool) -> CGFloat? {
13
+ if isFillMode(ratio) {
14
+ return nil
15
+ }
16
+
17
+ let parts = ratio.split(separator: ":")
18
+ guard parts.count == 2,
19
+ let width = Double(parts[0]), let height = Double(parts[1]),
20
+ width.isFinite, height.isFinite,
21
+ width > 0, height > 0 else {
22
+ return nil
23
+ }
24
+
25
+ let viewportRatio = CGFloat(isPortrait ? height / width : width / height)
26
+ guard viewportRatio.isFinite, viewportRatio > 0 else {
27
+ return nil
28
+ }
29
+
30
+ return viewportRatio
31
+ }
32
+
33
+ static func dimensionsForAspectRatio(
34
+ _ aspectRatio: String,
35
+ availableWidth: CGFloat,
36
+ availableHeight: CGFloat,
37
+ isPortrait: Bool
38
+ ) -> (width: CGFloat, height: CGFloat) {
39
+ guard let ratio = parseViewportAspectRatio(aspectRatio, isPortrait: isPortrait) else {
40
+ return (width: availableWidth, height: availableHeight)
41
+ }
42
+
43
+ let maxWidthByHeight = availableHeight * ratio
44
+ let maxHeightByWidth = availableWidth / ratio
45
+
46
+ if maxWidthByHeight <= availableWidth {
47
+ return (width: maxWidthByHeight, height: availableHeight)
48
+ }
49
+
50
+ return (width: availableWidth, height: maxHeightByWidth)
51
+ }
52
+
53
+ /// Length left in `available` after an explicit `origin`, clamped at zero.
54
+ /// Used to size an explicitly positioned `fill` axis from the web view.
55
+ static func remainingLength(from origin: CGFloat, in available: CGFloat) -> CGFloat {
56
+ max(0, available - origin)
57
+ }
58
+
59
+ /// Size of a `fill` preview with explicit `x` and `y` and no width or height:
60
+ /// the web-view space remaining to the right of `x` and below `y`.
61
+ static func positionedFillSize(
62
+ x: CGFloat,
63
+ y: CGFloat,
64
+ viewportWidth: CGFloat,
65
+ viewportHeight: CGFloat
66
+ ) -> (width: CGFloat, height: CGFloat) {
67
+ (width: remainingLength(from: x, in: viewportWidth), height: remainingLength(from: y, in: viewportHeight))
68
+ }
69
+
70
+ /// Width-to-height ratio of the area visible in a `fill` preview using `cover`
71
+ /// (`resizeAspectFill`), expressed in the orientation of the captured photo.
72
+ /// Returns `nil` when the preview size is unusable.
73
+ static func visibleFillCaptureAspectRatio(
74
+ previewSize: CGSize,
75
+ interfaceIsPortrait: Bool,
76
+ captureIsPortrait: Bool
77
+ ) -> CGFloat? {
78
+ guard previewSize.width.isFinite, previewSize.height.isFinite,
79
+ previewSize.width > 0, previewSize.height > 0 else {
80
+ return nil
81
+ }
82
+
83
+ let ratio = previewSize.width / previewSize.height
84
+ return interfaceIsPortrait == captureIsPortrait ? ratio : 1 / ratio
85
+ }
86
+
87
+ /// Centered crop rect with `targetAspectRatio` (width / height) inside `imageSize`,
88
+ /// matching how `resizeAspectFill` centers the visible area.
89
+ static func centerCropRect(imageSize: CGSize, targetAspectRatio: CGFloat) -> CGRect {
90
+ let bounds = CGRect(origin: .zero, size: imageSize)
91
+ guard targetAspectRatio.isFinite, targetAspectRatio > 0,
92
+ imageSize.width > 0, imageSize.height > 0 else {
93
+ return bounds
94
+ }
95
+
96
+ let imageAspectRatio = imageSize.width / imageSize.height
97
+ let rect: CGRect
98
+ if imageAspectRatio > targetAspectRatio {
99
+ let width = imageSize.height * targetAspectRatio
100
+ rect = CGRect(x: (imageSize.width - width) / 2, y: 0, width: width, height: imageSize.height)
101
+ } else {
102
+ let height = imageSize.width / targetAspectRatio
103
+ rect = CGRect(x: 0, y: (imageSize.height - height) / 2, width: imageSize.width, height: height)
104
+ }
105
+ return rect.intersection(bounds).integral.intersection(bounds)
106
+ }
107
+ }
@@ -4,8 +4,24 @@ import UIKit
4
4
  import CoreLocation
5
5
  import UniformTypeIdentifiers
6
6
  import CoreMotion
7
+ import CallKit
8
+
9
+ class CameraController: NSObject, CXCallObserverDelegate {
10
+ override init() {
11
+ super.init()
12
+ self.callObserver.setDelegate(self, queue: nil)
13
+ }
14
+
15
+ func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
16
+ guard call.hasEnded,
17
+ callObserver.calls.allSatisfy({ $0.hasEnded }),
18
+ let session = self.captureSession else { return }
19
+ self.sessionRecoveryQueue.async { [weak self] in
20
+ guard let self = self, self.captureSession === session else { return }
21
+ self.restoreAudioInputIfNeeded(for: session)
22
+ }
23
+ }
7
24
 
8
- class CameraController: NSObject {
9
25
  private func getVideoOrientation() -> AVCaptureVideoOrientation {
10
26
  var orientation: AVCaptureVideoOrientation = .portrait
11
27
  if Thread.isMainThread {
@@ -151,6 +167,16 @@ class CameraController: NSObject {
151
167
  var firstFrameReadyCallback: (() -> Void)?
152
168
  var hasReceivedFirstFrame = false
153
169
 
170
+ var onCameraInterrupted: ((String, Bool) -> Void)?
171
+ var onCameraInterruptionEnded: (() -> Void)?
172
+ var onStartFailure: ((Error) -> Void)?
173
+
174
+ private let callObserver = CXCallObserver()
175
+ private weak var observedCaptureSession: AVCaptureSession?
176
+ private let sessionRecoveryQueue = DispatchQueue(label: "com.capgo.camera.sessionRecovery")
177
+ private var activePrepareToken: UInt = 0
178
+ private var audioInputEnabledByUser = false
179
+
154
180
  var audioDevice: AVCaptureDevice?
155
181
  var audioInput: AVCaptureDeviceInput?
156
182
 
@@ -461,13 +487,10 @@ class CameraController: NSObject {
461
487
  // older systems or if the property is unavailable.
462
488
  func getDisplayZoomMultiplier() -> Float {
463
489
  var multiplier: Float = 1.0
464
- // Use KVC to avoid compile-time dependency on the iOS 18 SDK symbol
465
490
  let device = (currentCameraPosition == .rear) ? rearCamera : frontCamera
466
491
  if #available(iOS 18.0, *), let device = device {
467
- if let value = device.value(forKey: "displayVideoZoomFactorMultiplier") as? NSNumber {
468
- let multiplierValue = value.floatValue
469
- if multiplierValue > 0 { multiplier = multiplierValue }
470
- }
492
+ let multiplierValue = device.displayVideoZoomFactorMultiplier
493
+ if multiplierValue > 0 { multiplier = Float(multiplierValue) }
471
494
  }
472
495
  return multiplier
473
496
  }
@@ -658,9 +681,19 @@ extension CameraController {
658
681
  self.outputsPrepared = true
659
682
  }
660
683
 
661
- func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, aspectMode: String = "contain", initialZoomLevel: Float?, disableFocusIndicator: Bool = false, videoQuality: String = "high", completionHandler: @escaping (Error?) -> Void) {
684
+ func invalidateActivePrepare() {
685
+ self.activePrepareToken = 0
686
+ }
687
+
688
+ private func isPrepareTokenValid(_ token: UInt) -> Bool {
689
+ return token != 0 && token == self.activePrepareToken
690
+ }
691
+
692
+ func prepare(cameraPosition: String, deviceId: String? = nil, disableAudio: Bool, cameraMode: Bool, aspectRatio: String? = nil, aspectMode: String = "contain", initialZoomLevel: Float?, disableFocusIndicator: Bool = false, videoQuality: String = "high", startToken: UInt, completionHandler: @escaping (Error?) -> Void) {
662
693
  print("[CameraPreview] 🎬 Starting prepare - position: \(cameraPosition), deviceId: \(deviceId ?? "nil"), disableAudio: \(disableAudio), cameraMode: \(cameraMode), aspectRatio: \(aspectRatio ?? "nil"), aspectMode: \(aspectMode), zoom: \(initialZoomLevel ?? 1)")
663
694
 
695
+ self.activePrepareToken = startToken
696
+
664
697
  DispatchQueue.global(qos: .userInitiated).async { [weak self] in
665
698
  guard let self = self else {
666
699
  DispatchQueue.main.async {
@@ -669,6 +702,10 @@ extension CameraController {
669
702
  return
670
703
  }
671
704
 
705
+ guard self.isPrepareTokenValid(startToken) else {
706
+ return
707
+ }
708
+
672
709
  // Start accelerometer
673
710
  var startedAccelerometer = false
674
711
  if self.motionManager.isAccelerometerAvailable {
@@ -758,12 +795,19 @@ extension CameraController {
758
795
  NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: nil)
759
796
  NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: nil)
760
797
 
798
+ self.registerCaptureSessionObservers(for: captureSession)
799
+
800
+ guard self.isPrepareTokenValid(startToken) else {
801
+ return
802
+ }
803
+
761
804
  // Start the session - all outputs are already configured
762
805
  captureSession.startRunning()
763
806
 
764
807
  // Bring to full opacity after a tiny moment to smooth any visual artifacts
765
808
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
766
- if let layer = self?.previewLayer {
809
+ guard let self = self, self.isPrepareTokenValid(startToken) else { return }
810
+ if let layer = self.previewLayer {
767
811
  CATransaction.begin()
768
812
  CATransaction.setAnimationDuration(0.1)
769
813
  layer.opacity = 1.0
@@ -773,6 +817,7 @@ extension CameraController {
773
817
 
774
818
  // Success callback
775
819
  DispatchQueue.main.async {
820
+ guard self.isPrepareTokenValid(startToken) else { return }
776
821
  completionHandler(nil)
777
822
  }
778
823
  } catch {
@@ -780,6 +825,7 @@ extension CameraController {
780
825
  self.motionManager.stopAccelerometerUpdates()
781
826
  }
782
827
  DispatchQueue.main.async {
828
+ guard self.isPrepareTokenValid(startToken) else { return }
783
829
  completionHandler(error)
784
830
  }
785
831
  }
@@ -951,6 +997,8 @@ extension CameraController {
951
997
  private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
952
998
  guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
953
999
 
1000
+ self.audioInputEnabledByUser = !disableAudio
1001
+
954
1002
  // Ensure cameras are discovered before configuring inputs
955
1003
  ensureCamerasDiscovered()
956
1004
 
@@ -991,8 +1039,8 @@ extension CameraController {
991
1039
  throw CameraControllerError.inputsAreInvalid
992
1040
  }
993
1041
 
994
- // Add audio input if needed
995
- if !disableAudio {
1042
+ // Add audio input if needed (skip while a phone call owns the audio session)
1043
+ if self.shouldAttachAudioInput(disableAudio: disableAudio) {
996
1044
  if self.audioDevice == nil {
997
1045
  self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
998
1046
  }
@@ -1344,6 +1392,9 @@ extension CameraController {
1344
1392
  self.lastCaptureOrientation = captureOrientation
1345
1393
  self.setVideoOrientation(captureOrientation, on: connection)
1346
1394
  }
1395
+ // For fill + cover the preview shows a centered crop of the frame. Snapshot its
1396
+ // shape now so the photo can be cropped to the same visible area.
1397
+ let visibleFillRatio = self.visibleFillCaptureAspectRatio()
1347
1398
  let settings = AVCapturePhotoSettings()
1348
1399
  // Configure photo capture settings optimized for speed
1349
1400
  // Only use high res if explicitly requesting large dimensions
@@ -1415,7 +1466,7 @@ extension CameraController {
1415
1466
  // When max dimensions are specified, we used high-res capture
1416
1467
  // First crop to aspect ratio if needed, then resize to max dimensions
1417
1468
  if let aspectRatio = self.requestedAspectRatio {
1418
- finalImage = self.cropImageToAspectRatio(image: image, aspectRatio: aspectRatio) ?? image
1469
+ finalImage = self.cropCapturedImage(image, aspectRatio: aspectRatio, visibleFillRatio: visibleFillRatio)
1419
1470
  print("[CameraPreview] Cropped high-res image to aspect ratio \(aspectRatio)")
1420
1471
  }
1421
1472
  // Then resize to fit within maximum dimensions while maintaining aspect ratio
@@ -1424,7 +1475,7 @@ extension CameraController {
1424
1475
  } else if let aspectRatio = self.requestedAspectRatio {
1425
1476
  // No max dimensions specified, but aspect ratio is specified
1426
1477
  // Always apply aspect ratio cropping to ensure correct orientation
1427
- finalImage = self.cropImageToAspectRatio(image: image, aspectRatio: aspectRatio) ?? image
1478
+ finalImage = self.cropCapturedImage(image, aspectRatio: aspectRatio, visibleFillRatio: visibleFillRatio)
1428
1479
  print("[CameraPreview] Applied aspect ratio cropping for \(aspectRatio): \(finalImage.size.width)x\(finalImage.size.height)")
1429
1480
  }
1430
1481
 
@@ -1741,6 +1792,55 @@ extension CameraController {
1741
1792
  return resizeImage(image: image, to: targetSize)
1742
1793
  }
1743
1794
 
1795
+ /// Ratio of the visible preview area for `fill` + `cover`, in capture orientation.
1796
+ /// Returns `nil` for numeric ratios and for `contain`, where the full frame is visible.
1797
+ func visibleFillCaptureAspectRatio() -> CGFloat? {
1798
+ guard let aspectRatio = self.requestedAspectRatio,
1799
+ AspectRatioLayout.isFillMode(aspectRatio),
1800
+ self.requestedAspectMode == "cover",
1801
+ let previewLayer = self.previewLayer else {
1802
+ return nil
1803
+ }
1804
+
1805
+ let orientation = self.lastCaptureOrientation ?? self.getPhysicalOrientation()
1806
+ let captureIsPortrait = orientation == .portrait || orientation == .portraitUpsideDown
1807
+ let screenBounds = UIScreen.main.bounds
1808
+ return AspectRatioLayout.visibleFillCaptureAspectRatio(
1809
+ previewSize: previewLayer.bounds.size,
1810
+ interfaceIsPortrait: screenBounds.height >= screenBounds.width,
1811
+ captureIsPortrait: captureIsPortrait
1812
+ )
1813
+ }
1814
+
1815
+ /// Crops a captured photo so it matches what the preview shows.
1816
+ func cropCapturedImage(_ image: UIImage, aspectRatio: String, visibleFillRatio: CGFloat?) -> UIImage {
1817
+ if AspectRatioLayout.isFillMode(aspectRatio) {
1818
+ guard let ratio = visibleFillRatio else {
1819
+ // fill + contain shows the whole frame, so keep the full photo.
1820
+ return image
1821
+ }
1822
+ return cropImage(image, toAspectRatio: ratio) ?? image
1823
+ }
1824
+ return cropImageToAspectRatio(image: image, aspectRatio: aspectRatio) ?? image
1825
+ }
1826
+
1827
+ /// Center-crops `image` (after normalizing its orientation) to a width / height ratio.
1828
+ func cropImage(_ image: UIImage, toAspectRatio targetAspectRatio: CGFloat) -> UIImage? {
1829
+ let normalizedImage = image.imageOrientation == .up ? image : (image.fixedOrientation() ?? image)
1830
+ guard let cgImage = normalizedImage.cgImage else {
1831
+ return nil
1832
+ }
1833
+
1834
+ let pixelSize = CGSize(width: cgImage.width, height: cgImage.height)
1835
+ let cropRect = AspectRatioLayout.centerCropRect(imageSize: pixelSize, targetAspectRatio: targetAspectRatio)
1836
+ guard let croppedCGImage = cgImage.cropping(to: cropRect) else {
1837
+ return nil
1838
+ }
1839
+
1840
+ print("[CameraPreview] cropImage - Cropped \(pixelSize.width)x\(pixelSize.height) to visible fill area \(cropRect)")
1841
+ return UIImage(cgImage: croppedCGImage, scale: normalizedImage.scale, orientation: .up)
1842
+ }
1843
+
1744
1844
  func cropImageToAspectRatio(image: UIImage, aspectRatio: String) -> UIImage? {
1745
1845
  let components = aspectRatio.split(separator: ":").compactMap {Float(String($0))}
1746
1846
  guard components.count == 2 else {
@@ -2536,10 +2636,168 @@ extension CameraController {
2536
2636
  self.updateVideoOrientation()
2537
2637
  }
2538
2638
 
2639
+ private func isPhoneCallActive() -> Bool {
2640
+ self.callObserver.calls.contains { !$0.hasEnded }
2641
+ }
2642
+
2643
+ private func shouldAttachAudioInput(disableAudio: Bool) -> Bool {
2644
+ return !disableAudio && !self.isPhoneCallActive()
2645
+ }
2646
+
2647
+ private static let insufficientAudioPriorityOSStatus = 561017449
2648
+
2649
+ private func isAudioPriorityError(_ error: NSError) -> Bool {
2650
+ var current: NSError? = error
2651
+ while let err = current {
2652
+ if err.domain == NSOSStatusErrorDomain && err.code == CameraController.insufficientAudioPriorityOSStatus {
2653
+ return true
2654
+ }
2655
+ current = err.userInfo[NSUnderlyingErrorKey] as? NSError
2656
+ }
2657
+ return false
2658
+ }
2659
+
2660
+ private func registerCaptureSessionObservers(for session: AVCaptureSession) {
2661
+ self.unregisterCaptureSessionObservers()
2662
+ self.observedCaptureSession = session
2663
+ NotificationCenter.default.addObserver(
2664
+ self,
2665
+ selector: #selector(self.handleCaptureSessionRuntimeError(_:)),
2666
+ name: .AVCaptureSessionRuntimeError,
2667
+ object: session
2668
+ )
2669
+ NotificationCenter.default.addObserver(
2670
+ self,
2671
+ selector: #selector(self.handleCaptureSessionWasInterrupted(_:)),
2672
+ name: .AVCaptureSessionWasInterrupted,
2673
+ object: session
2674
+ )
2675
+ NotificationCenter.default.addObserver(
2676
+ self,
2677
+ selector: #selector(self.handleCaptureSessionInterruptionEnded(_:)),
2678
+ name: .AVCaptureSessionInterruptionEnded,
2679
+ object: session
2680
+ )
2681
+ }
2682
+
2683
+ private func unregisterCaptureSessionObservers() {
2684
+ guard let session = self.observedCaptureSession else { return }
2685
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionRuntimeError, object: session)
2686
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionWasInterrupted, object: session)
2687
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionInterruptionEnded, object: session)
2688
+ self.observedCaptureSession = nil
2689
+ }
2690
+
2691
+ private func restoreAudioInputIfNeeded(for session: AVCaptureSession) {
2692
+ guard self.audioInputEnabledByUser, self.audioInput == nil, !self.isPhoneCallActive() else { return }
2693
+ guard self.captureSession === session else { return }
2694
+
2695
+ do {
2696
+ session.beginConfiguration()
2697
+ defer { session.commitConfiguration() }
2698
+ if self.audioDevice == nil {
2699
+ self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
2700
+ }
2701
+ guard let audioDevice = self.audioDevice else {
2702
+ return
2703
+ }
2704
+ let input = try AVCaptureDeviceInput(device: audioDevice)
2705
+ if session.canAddInput(input) {
2706
+ session.addInput(input)
2707
+ self.audioInput = input
2708
+ }
2709
+ } catch {
2710
+ print("[CameraPreview] Failed to restore audio input after interruption: \(error)")
2711
+ }
2712
+ }
2713
+
2714
+ private func dropAudioInputAndRestartSession(for session: AVCaptureSession) {
2715
+ guard self.captureSession === session else { return }
2716
+
2717
+ session.beginConfiguration()
2718
+ for input in session.inputs {
2719
+ guard let deviceInput = input as? AVCaptureDeviceInput else { continue }
2720
+ if deviceInput.device.hasMediaType(.audio) {
2721
+ session.removeInput(deviceInput)
2722
+ }
2723
+ }
2724
+ self.audioInput = nil
2725
+ session.commitConfiguration()
2726
+
2727
+ if session.isRunning {
2728
+ session.stopRunning()
2729
+ }
2730
+ session.startRunning()
2731
+ }
2732
+
2733
+ @objc private func handleCaptureSessionRuntimeError(_ notification: Notification) {
2734
+ guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? NSError else { return }
2735
+ print("[CameraPreview] AVCaptureSession runtime error: \(error)")
2736
+
2737
+ let audioConflict = self.isAudioPriorityError(error)
2738
+ guard let session = notification.object as? AVCaptureSession else { return }
2739
+ if audioConflict {
2740
+ self.sessionRecoveryQueue.async { [weak self] in
2741
+ self?.dropAudioInputAndRestartSession(for: session)
2742
+ }
2743
+ }
2744
+
2745
+ DispatchQueue.main.async { [weak self] in
2746
+ guard let self = self else { return }
2747
+ self.onCameraInterrupted?("runtimeError", audioConflict)
2748
+ if !audioConflict && !self.hasReceivedFirstFrame {
2749
+ self.sessionRecoveryQueue.async { [weak self] in
2750
+ guard let self = self, self.captureSession === session else { return }
2751
+ session.stopRunning()
2752
+ DispatchQueue.main.async {
2753
+ self.onStartFailure?(error)
2754
+ }
2755
+ }
2756
+ }
2757
+ }
2758
+ }
2759
+
2760
+ @objc private func handleCaptureSessionWasInterrupted(_ notification: Notification) {
2761
+ guard let session = notification.object as? AVCaptureSession else { return }
2762
+ var audioRelated = false
2763
+ if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
2764
+ let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue),
2765
+ reason == .audioDeviceInUseByAnotherClient {
2766
+ audioRelated = true
2767
+ self.sessionRecoveryQueue.async { [weak self] in
2768
+ self?.dropAudioInputAndRestartSession(for: session)
2769
+ }
2770
+ }
2771
+
2772
+ DispatchQueue.main.async { [weak self] in
2773
+ self?.onCameraInterrupted?("interrupted", audioRelated)
2774
+ }
2775
+ }
2776
+
2777
+ @objc private func handleCaptureSessionInterruptionEnded(_ notification: Notification) {
2778
+ guard let session = notification.object as? AVCaptureSession else { return }
2779
+ self.sessionRecoveryQueue.async { [weak self] in
2780
+ guard let self = self, self.captureSession === session else { return }
2781
+ self.restoreAudioInputIfNeeded(for: session)
2782
+ if !session.isRunning {
2783
+ session.startRunning()
2784
+ }
2785
+ DispatchQueue.main.async {
2786
+ guard self.captureSession === session else { return }
2787
+ self.onCameraInterruptionEnded?()
2788
+ }
2789
+ }
2790
+ }
2791
+
2539
2792
  func cleanup() {
2793
+ self.invalidateActivePrepare()
2540
2794
  self.cancelPendingFocusExposureRestore()
2541
2795
  configuredVideoFrameRate = nil
2542
2796
  stopBarcodeScanner()
2797
+ self.unregisterCaptureSessionObservers()
2798
+ self.onCameraInterrupted = nil
2799
+ self.onCameraInterruptionEnded = nil
2800
+ self.onStartFailure = nil
2543
2801
  if let captureSession = self.captureSession {
2544
2802
  captureSession.stopRunning()
2545
2803
  captureSession.inputs.forEach { captureSession.removeInput($0) }
@@ -2572,6 +2830,7 @@ extension CameraController {
2572
2830
  self.captureSession = nil
2573
2831
  self.currentCameraPosition = nil
2574
2832
  self.preferredExposureMode = "CONTINUOUS"
2833
+ self.audioInputEnabledByUser = false
2575
2834
 
2576
2835
  // Reset output preparation status
2577
2836
  self.outputsPrepared = false