@capgo/camera-preview 8.11.8 → 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.
- package/README.md +73 -4
- 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 +269 -7
- 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
|
|
|
@@ -658,9 +684,19 @@ extension CameraController {
|
|
|
658
684
|
self.outputsPrepared = true
|
|
659
685
|
}
|
|
660
686
|
|
|
661
|
-
func
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|