@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.
- package/README.md +54 -1
- package/android/build.gradle +0 -1
- package/android/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/android/src/main/java/app/capgo/capacitor/camera/preview/CameraPreview.java +111 -126
- package/android/src/main/java/app/capgo/capacitor/camera/preview/CameraXView.java +464 -163
- package/android/src/main/java/app/capgo/capacitor/camera/preview/GridOverlayView.java +2 -2
- package/dist/docs.json +97 -0
- package/dist/esm/definitions.d.ts +31 -0
- package/dist/esm/definitions.js.map +1 -1
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapgoCameraPreviewPlugin/AspectRatioLayout.swift +107 -0
- package/ios/Sources/CapgoCameraPreviewPlugin/CameraController.swift +271 -12
- package/ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift +175 -55
- package/ios/Tests/CameraPreviewPluginTests/CameraPreviewPluginTests.swift +150 -9
- package/package.json +3 -2
|
@@ -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
|
-
|
|
468
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|