@capgo/camera-preview 8.11.9 → 8.11.10

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
 
@@ -658,9 +684,19 @@ extension CameraController {
658
684
  self.outputsPrepared = true
659
685
  }
660
686
 
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) {
687
+ func invalidateActivePrepare() {
688
+ self.activePrepareToken = 0
689
+ }
690
+
691
+ private func isPrepareTokenValid(_ token: UInt) -> Bool {
692
+ return token != 0 && token == self.activePrepareToken
693
+ }
694
+
695
+ 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
696
  print("[CameraPreview] 🎬 Starting prepare - position: \(cameraPosition), deviceId: \(deviceId ?? "nil"), disableAudio: \(disableAudio), cameraMode: \(cameraMode), aspectRatio: \(aspectRatio ?? "nil"), aspectMode: \(aspectMode), zoom: \(initialZoomLevel ?? 1)")
663
697
 
698
+ self.activePrepareToken = startToken
699
+
664
700
  DispatchQueue.global(qos: .userInitiated).async { [weak self] in
665
701
  guard let self = self else {
666
702
  DispatchQueue.main.async {
@@ -669,6 +705,10 @@ extension CameraController {
669
705
  return
670
706
  }
671
707
 
708
+ guard self.isPrepareTokenValid(startToken) else {
709
+ return
710
+ }
711
+
672
712
  // Start accelerometer
673
713
  var startedAccelerometer = false
674
714
  if self.motionManager.isAccelerometerAvailable {
@@ -758,12 +798,19 @@ extension CameraController {
758
798
  NotificationCenter.default.removeObserver(self, name: .AVCaptureDeviceSubjectAreaDidChange, object: nil)
759
799
  NotificationCenter.default.addObserver(self, selector: #selector(self.subjectAreaDidChange), name: .AVCaptureDeviceSubjectAreaDidChange, object: nil)
760
800
 
801
+ self.registerCaptureSessionObservers(for: captureSession)
802
+
803
+ guard self.isPrepareTokenValid(startToken) else {
804
+ return
805
+ }
806
+
761
807
  // Start the session - all outputs are already configured
762
808
  captureSession.startRunning()
763
809
 
764
810
  // Bring to full opacity after a tiny moment to smooth any visual artifacts
765
811
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
766
- if let layer = self?.previewLayer {
812
+ guard let self = self, self.isPrepareTokenValid(startToken) else { return }
813
+ if let layer = self.previewLayer {
767
814
  CATransaction.begin()
768
815
  CATransaction.setAnimationDuration(0.1)
769
816
  layer.opacity = 1.0
@@ -773,6 +820,7 @@ extension CameraController {
773
820
 
774
821
  // Success callback
775
822
  DispatchQueue.main.async {
823
+ guard self.isPrepareTokenValid(startToken) else { return }
776
824
  completionHandler(nil)
777
825
  }
778
826
  } catch {
@@ -780,6 +828,7 @@ extension CameraController {
780
828
  self.motionManager.stopAccelerometerUpdates()
781
829
  }
782
830
  DispatchQueue.main.async {
831
+ guard self.isPrepareTokenValid(startToken) else { return }
783
832
  completionHandler(error)
784
833
  }
785
834
  }
@@ -951,6 +1000,8 @@ extension CameraController {
951
1000
  private func configureDeviceInputs(cameraPosition: String, deviceId: String?, disableAudio: Bool) throws {
952
1001
  guard let captureSession = self.captureSession else { throw CameraControllerError.captureSessionIsMissing }
953
1002
 
1003
+ self.audioInputEnabledByUser = !disableAudio
1004
+
954
1005
  // Ensure cameras are discovered before configuring inputs
955
1006
  ensureCamerasDiscovered()
956
1007
 
@@ -991,8 +1042,8 @@ extension CameraController {
991
1042
  throw CameraControllerError.inputsAreInvalid
992
1043
  }
993
1044
 
994
- // Add audio input if needed
995
- if !disableAudio {
1045
+ // Add audio input if needed (skip while a phone call owns the audio session)
1046
+ if self.shouldAttachAudioInput(disableAudio: disableAudio) {
996
1047
  if self.audioDevice == nil {
997
1048
  self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
998
1049
  }
@@ -1344,6 +1395,9 @@ extension CameraController {
1344
1395
  self.lastCaptureOrientation = captureOrientation
1345
1396
  self.setVideoOrientation(captureOrientation, on: connection)
1346
1397
  }
1398
+ // For fill + cover the preview shows a centered crop of the frame. Snapshot its
1399
+ // shape now so the photo can be cropped to the same visible area.
1400
+ let visibleFillRatio = self.visibleFillCaptureAspectRatio()
1347
1401
  let settings = AVCapturePhotoSettings()
1348
1402
  // Configure photo capture settings optimized for speed
1349
1403
  // Only use high res if explicitly requesting large dimensions
@@ -1415,7 +1469,7 @@ extension CameraController {
1415
1469
  // When max dimensions are specified, we used high-res capture
1416
1470
  // First crop to aspect ratio if needed, then resize to max dimensions
1417
1471
  if let aspectRatio = self.requestedAspectRatio {
1418
- finalImage = self.cropImageToAspectRatio(image: image, aspectRatio: aspectRatio) ?? image
1472
+ finalImage = self.cropCapturedImage(image, aspectRatio: aspectRatio, visibleFillRatio: visibleFillRatio)
1419
1473
  print("[CameraPreview] Cropped high-res image to aspect ratio \(aspectRatio)")
1420
1474
  }
1421
1475
  // Then resize to fit within maximum dimensions while maintaining aspect ratio
@@ -1424,7 +1478,7 @@ extension CameraController {
1424
1478
  } else if let aspectRatio = self.requestedAspectRatio {
1425
1479
  // No max dimensions specified, but aspect ratio is specified
1426
1480
  // Always apply aspect ratio cropping to ensure correct orientation
1427
- finalImage = self.cropImageToAspectRatio(image: image, aspectRatio: aspectRatio) ?? image
1481
+ finalImage = self.cropCapturedImage(image, aspectRatio: aspectRatio, visibleFillRatio: visibleFillRatio)
1428
1482
  print("[CameraPreview] Applied aspect ratio cropping for \(aspectRatio): \(finalImage.size.width)x\(finalImage.size.height)")
1429
1483
  }
1430
1484
 
@@ -1741,6 +1795,55 @@ extension CameraController {
1741
1795
  return resizeImage(image: image, to: targetSize)
1742
1796
  }
1743
1797
 
1798
+ /// Ratio of the visible preview area for `fill` + `cover`, in capture orientation.
1799
+ /// Returns `nil` for numeric ratios and for `contain`, where the full frame is visible.
1800
+ func visibleFillCaptureAspectRatio() -> CGFloat? {
1801
+ guard let aspectRatio = self.requestedAspectRatio,
1802
+ AspectRatioLayout.isFillMode(aspectRatio),
1803
+ self.requestedAspectMode == "cover",
1804
+ let previewLayer = self.previewLayer else {
1805
+ return nil
1806
+ }
1807
+
1808
+ let orientation = self.lastCaptureOrientation ?? self.getPhysicalOrientation()
1809
+ let captureIsPortrait = orientation == .portrait || orientation == .portraitUpsideDown
1810
+ let screenBounds = UIScreen.main.bounds
1811
+ return AspectRatioLayout.visibleFillCaptureAspectRatio(
1812
+ previewSize: previewLayer.bounds.size,
1813
+ interfaceIsPortrait: screenBounds.height >= screenBounds.width,
1814
+ captureIsPortrait: captureIsPortrait
1815
+ )
1816
+ }
1817
+
1818
+ /// Crops a captured photo so it matches what the preview shows.
1819
+ func cropCapturedImage(_ image: UIImage, aspectRatio: String, visibleFillRatio: CGFloat?) -> UIImage {
1820
+ if AspectRatioLayout.isFillMode(aspectRatio) {
1821
+ guard let ratio = visibleFillRatio else {
1822
+ // fill + contain shows the whole frame, so keep the full photo.
1823
+ return image
1824
+ }
1825
+ return cropImage(image, toAspectRatio: ratio) ?? image
1826
+ }
1827
+ return cropImageToAspectRatio(image: image, aspectRatio: aspectRatio) ?? image
1828
+ }
1829
+
1830
+ /// Center-crops `image` (after normalizing its orientation) to a width / height ratio.
1831
+ func cropImage(_ image: UIImage, toAspectRatio targetAspectRatio: CGFloat) -> UIImage? {
1832
+ let normalizedImage = image.imageOrientation == .up ? image : (image.fixedOrientation() ?? image)
1833
+ guard let cgImage = normalizedImage.cgImage else {
1834
+ return nil
1835
+ }
1836
+
1837
+ let pixelSize = CGSize(width: cgImage.width, height: cgImage.height)
1838
+ let cropRect = AspectRatioLayout.centerCropRect(imageSize: pixelSize, targetAspectRatio: targetAspectRatio)
1839
+ guard let croppedCGImage = cgImage.cropping(to: cropRect) else {
1840
+ return nil
1841
+ }
1842
+
1843
+ print("[CameraPreview] cropImage - Cropped \(pixelSize.width)x\(pixelSize.height) to visible fill area \(cropRect)")
1844
+ return UIImage(cgImage: croppedCGImage, scale: normalizedImage.scale, orientation: .up)
1845
+ }
1846
+
1744
1847
  func cropImageToAspectRatio(image: UIImage, aspectRatio: String) -> UIImage? {
1745
1848
  let components = aspectRatio.split(separator: ":").compactMap {Float(String($0))}
1746
1849
  guard components.count == 2 else {
@@ -2536,10 +2639,168 @@ extension CameraController {
2536
2639
  self.updateVideoOrientation()
2537
2640
  }
2538
2641
 
2642
+ private func isPhoneCallActive() -> Bool {
2643
+ self.callObserver.calls.contains { !$0.hasEnded }
2644
+ }
2645
+
2646
+ private func shouldAttachAudioInput(disableAudio: Bool) -> Bool {
2647
+ return !disableAudio && !self.isPhoneCallActive()
2648
+ }
2649
+
2650
+ private static let insufficientAudioPriorityOSStatus = 561017449
2651
+
2652
+ private func isAudioPriorityError(_ error: NSError) -> Bool {
2653
+ var current: NSError? = error
2654
+ while let err = current {
2655
+ if err.domain == NSOSStatusErrorDomain && err.code == CameraController.insufficientAudioPriorityOSStatus {
2656
+ return true
2657
+ }
2658
+ current = err.userInfo[NSUnderlyingErrorKey] as? NSError
2659
+ }
2660
+ return false
2661
+ }
2662
+
2663
+ private func registerCaptureSessionObservers(for session: AVCaptureSession) {
2664
+ self.unregisterCaptureSessionObservers()
2665
+ self.observedCaptureSession = session
2666
+ NotificationCenter.default.addObserver(
2667
+ self,
2668
+ selector: #selector(self.handleCaptureSessionRuntimeError(_:)),
2669
+ name: .AVCaptureSessionRuntimeError,
2670
+ object: session
2671
+ )
2672
+ NotificationCenter.default.addObserver(
2673
+ self,
2674
+ selector: #selector(self.handleCaptureSessionWasInterrupted(_:)),
2675
+ name: .AVCaptureSessionWasInterrupted,
2676
+ object: session
2677
+ )
2678
+ NotificationCenter.default.addObserver(
2679
+ self,
2680
+ selector: #selector(self.handleCaptureSessionInterruptionEnded(_:)),
2681
+ name: .AVCaptureSessionInterruptionEnded,
2682
+ object: session
2683
+ )
2684
+ }
2685
+
2686
+ private func unregisterCaptureSessionObservers() {
2687
+ guard let session = self.observedCaptureSession else { return }
2688
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionRuntimeError, object: session)
2689
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionWasInterrupted, object: session)
2690
+ NotificationCenter.default.removeObserver(self, name: .AVCaptureSessionInterruptionEnded, object: session)
2691
+ self.observedCaptureSession = nil
2692
+ }
2693
+
2694
+ private func restoreAudioInputIfNeeded(for session: AVCaptureSession) {
2695
+ guard self.audioInputEnabledByUser, self.audioInput == nil, !self.isPhoneCallActive() else { return }
2696
+ guard self.captureSession === session else { return }
2697
+
2698
+ do {
2699
+ session.beginConfiguration()
2700
+ defer { session.commitConfiguration() }
2701
+ if self.audioDevice == nil {
2702
+ self.audioDevice = AVCaptureDevice.default(for: AVMediaType.audio)
2703
+ }
2704
+ guard let audioDevice = self.audioDevice else {
2705
+ return
2706
+ }
2707
+ let input = try AVCaptureDeviceInput(device: audioDevice)
2708
+ if session.canAddInput(input) {
2709
+ session.addInput(input)
2710
+ self.audioInput = input
2711
+ }
2712
+ } catch {
2713
+ print("[CameraPreview] Failed to restore audio input after interruption: \(error)")
2714
+ }
2715
+ }
2716
+
2717
+ private func dropAudioInputAndRestartSession(for session: AVCaptureSession) {
2718
+ guard self.captureSession === session else { return }
2719
+
2720
+ session.beginConfiguration()
2721
+ for input in session.inputs {
2722
+ guard let deviceInput = input as? AVCaptureDeviceInput else { continue }
2723
+ if deviceInput.device.hasMediaType(.audio) {
2724
+ session.removeInput(deviceInput)
2725
+ }
2726
+ }
2727
+ self.audioInput = nil
2728
+ session.commitConfiguration()
2729
+
2730
+ if session.isRunning {
2731
+ session.stopRunning()
2732
+ }
2733
+ session.startRunning()
2734
+ }
2735
+
2736
+ @objc private func handleCaptureSessionRuntimeError(_ notification: Notification) {
2737
+ guard let error = notification.userInfo?[AVCaptureSessionErrorKey] as? NSError else { return }
2738
+ print("[CameraPreview] AVCaptureSession runtime error: \(error)")
2739
+
2740
+ let audioConflict = self.isAudioPriorityError(error)
2741
+ guard let session = notification.object as? AVCaptureSession else { return }
2742
+ if audioConflict {
2743
+ self.sessionRecoveryQueue.async { [weak self] in
2744
+ self?.dropAudioInputAndRestartSession(for: session)
2745
+ }
2746
+ }
2747
+
2748
+ DispatchQueue.main.async { [weak self] in
2749
+ guard let self = self else { return }
2750
+ self.onCameraInterrupted?("runtimeError", audioConflict)
2751
+ if !audioConflict && !self.hasReceivedFirstFrame {
2752
+ self.sessionRecoveryQueue.async { [weak self] in
2753
+ guard let self = self, self.captureSession === session else { return }
2754
+ session.stopRunning()
2755
+ DispatchQueue.main.async {
2756
+ self.onStartFailure?(error)
2757
+ }
2758
+ }
2759
+ }
2760
+ }
2761
+ }
2762
+
2763
+ @objc private func handleCaptureSessionWasInterrupted(_ notification: Notification) {
2764
+ guard let session = notification.object as? AVCaptureSession else { return }
2765
+ var audioRelated = false
2766
+ if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
2767
+ let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue),
2768
+ reason == .audioDeviceInUseByAnotherClient {
2769
+ audioRelated = true
2770
+ self.sessionRecoveryQueue.async { [weak self] in
2771
+ self?.dropAudioInputAndRestartSession(for: session)
2772
+ }
2773
+ }
2774
+
2775
+ DispatchQueue.main.async { [weak self] in
2776
+ self?.onCameraInterrupted?("interrupted", audioRelated)
2777
+ }
2778
+ }
2779
+
2780
+ @objc private func handleCaptureSessionInterruptionEnded(_ notification: Notification) {
2781
+ guard let session = notification.object as? AVCaptureSession else { return }
2782
+ self.sessionRecoveryQueue.async { [weak self] in
2783
+ guard let self = self, self.captureSession === session else { return }
2784
+ self.restoreAudioInputIfNeeded(for: session)
2785
+ if !session.isRunning {
2786
+ session.startRunning()
2787
+ }
2788
+ DispatchQueue.main.async {
2789
+ guard self.captureSession === session else { return }
2790
+ self.onCameraInterruptionEnded?()
2791
+ }
2792
+ }
2793
+ }
2794
+
2539
2795
  func cleanup() {
2796
+ self.invalidateActivePrepare()
2540
2797
  self.cancelPendingFocusExposureRestore()
2541
2798
  configuredVideoFrameRate = nil
2542
2799
  stopBarcodeScanner()
2800
+ self.unregisterCaptureSessionObservers()
2801
+ self.onCameraInterrupted = nil
2802
+ self.onCameraInterruptionEnded = nil
2803
+ self.onStartFailure = nil
2543
2804
  if let captureSession = self.captureSession {
2544
2805
  captureSession.stopRunning()
2545
2806
  captureSession.inputs.forEach { captureSession.removeInput($0) }
@@ -2572,6 +2833,7 @@ extension CameraController {
2572
2833
  self.captureSession = nil
2573
2834
  self.currentCameraPosition = nil
2574
2835
  self.preferredExposureMode = "CONTINUOUS"
2836
+ self.audioInputEnabledByUser = false
2575
2837
 
2576
2838
  // Reset output preparation status
2577
2839
  self.outputsPrepared = false