@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.
- 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 +269 -7
- package/ios/Sources/CapgoCameraPreviewPlugin/Plugin.swift +175 -55
- package/ios/Tests/CameraPreviewPluginTests/CameraPreviewPluginTests.swift +150 -9
- package/package.json +3 -2
|
@@ -35,7 +35,7 @@ extension UIWindow {
|
|
|
35
35
|
*/
|
|
36
36
|
@objc(CameraPreview)
|
|
37
37
|
public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
|
|
38
|
-
private let pluginVersion: String = "8.11.
|
|
38
|
+
private let pluginVersion: String = "8.11.10"
|
|
39
39
|
public let identifier = "CameraPreviewPlugin"
|
|
40
40
|
public let jsName = "CameraPreview"
|
|
41
41
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
@@ -111,6 +111,9 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
111
111
|
var posY: CGFloat?
|
|
112
112
|
var width: CGFloat?
|
|
113
113
|
var height: CGFloat?
|
|
114
|
+
/// Whether `width` / `height` came from the caller rather than from defaults or aspect ratio sizing.
|
|
115
|
+
var widthIsExplicit = false
|
|
116
|
+
var heightIsExplicit = false
|
|
114
117
|
var paddingBottom: CGFloat?
|
|
115
118
|
var rotateWhenOrientationChanged: Bool?
|
|
116
119
|
var toBack: Bool?
|
|
@@ -129,41 +132,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
129
132
|
private var isPresentingPermissionAlert: Bool = false
|
|
130
133
|
private var pendingStartBarcodeScannerOptions: (formats: [String], detectionInterval: Int)?
|
|
131
134
|
private var hasResolvedStartCall: Bool = false
|
|
135
|
+
private var startGeneration: UInt = 0
|
|
136
|
+
private var firstFrameTimeoutWorkItem: DispatchWorkItem?
|
|
137
|
+
private var pendingStartCall: CAPPluginCall?
|
|
132
138
|
|
|
133
139
|
// Store original webview colors to restore them when stopping
|
|
134
140
|
private var originalWebViewBackgroundColor: UIColor?
|
|
135
141
|
private var originalWebViewSubviewColors: [UIView: UIColor] = [:]
|
|
136
142
|
|
|
137
|
-
// MARK: - Helper Methods for Aspect Ratio
|
|
138
|
-
|
|
139
|
-
/// Parses aspect ratio string and returns the appropriate ratio for the current orientation
|
|
140
|
-
private func parseAspectRatio(_ ratio: String, isPortrait: Bool) -> CGFloat {
|
|
141
|
-
let parts = ratio.split(separator: ":").compactMap { Double($0) }
|
|
142
|
-
guard parts.count == 2 else { return 1.0 }
|
|
143
|
-
|
|
144
|
-
// For camera (portrait), we want portrait orientation: 4:3 becomes 3:4, 16:9 becomes 9:16
|
|
145
|
-
return isPortrait ?
|
|
146
|
-
CGFloat(parts[1] / parts[0]) :
|
|
147
|
-
CGFloat(parts[0] / parts[1])
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/// Calculates dimensions based on aspect ratio and available space
|
|
151
|
-
private func calculateDimensionsForAspectRatio(_ aspectRatio: String, availableWidth: CGFloat, availableHeight: CGFloat, isPortrait: Bool) -> (width: CGFloat, height: CGFloat) {
|
|
152
|
-
let ratio = parseAspectRatio(aspectRatio, isPortrait: isPortrait)
|
|
153
|
-
|
|
154
|
-
// Calculate maximum size that fits the aspect ratio in available space
|
|
155
|
-
let maxWidthByHeight = availableHeight * ratio
|
|
156
|
-
let maxHeightByWidth = availableWidth / ratio
|
|
157
|
-
|
|
158
|
-
if maxWidthByHeight <= availableWidth {
|
|
159
|
-
// Height is the limiting factor
|
|
160
|
-
return (width: maxWidthByHeight, height: availableHeight)
|
|
161
|
-
} else {
|
|
162
|
-
// Width is the limiting factor
|
|
163
|
-
return (width: availableWidth, height: maxHeightByWidth)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
143
|
// MARK: - Transparency Methods
|
|
168
144
|
|
|
169
145
|
private func makeWebViewTransparent() {
|
|
@@ -501,9 +477,16 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
501
477
|
// Parse aspect ratio - convert to portrait orientation for camera use
|
|
502
478
|
// Use the centralized calculation method
|
|
503
479
|
if let aspectRatio = self.aspectRatio {
|
|
504
|
-
let dimensions =
|
|
480
|
+
let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
|
|
481
|
+
aspectRatio,
|
|
482
|
+
availableWidth: availableWidth,
|
|
483
|
+
availableHeight: availableHeight,
|
|
484
|
+
isPortrait: isPortrait
|
|
485
|
+
)
|
|
505
486
|
self.width = dimensions.width
|
|
506
487
|
self.height = dimensions.height
|
|
488
|
+
self.widthIsExplicit = false
|
|
489
|
+
self.heightIsExplicit = false
|
|
507
490
|
}
|
|
508
491
|
|
|
509
492
|
self.updateCameraFrame()
|
|
@@ -685,7 +668,12 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
685
668
|
|
|
686
669
|
// If force is true, kill everything and restart no matter what
|
|
687
670
|
if force {
|
|
671
|
+
if let pendingStartCall = self.pendingStartCall, !self.hasResolvedStartCall {
|
|
672
|
+
self.failPendingStart(pendingStartCall, message: "Camera start cancelled")
|
|
673
|
+
}
|
|
688
674
|
if self.isInitializing || self.isInitialized || self.cameraController.isCapturingPhoto || self.cameraController.stopRequestedAfterCapture {
|
|
675
|
+
self.startGeneration += 1
|
|
676
|
+
self.cancelFirstFrameTimeout()
|
|
689
677
|
// Force stop everything synchronously
|
|
690
678
|
DispatchQueue.main.sync {
|
|
691
679
|
self.cameraController.removeGridOverlay()
|
|
@@ -732,6 +720,9 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
732
720
|
|
|
733
721
|
self.isInitializing = true
|
|
734
722
|
self.hasResolvedStartCall = false
|
|
723
|
+
self.pendingStartCall = call
|
|
724
|
+
self.startGeneration += 1
|
|
725
|
+
let startToken = self.startGeneration
|
|
735
726
|
|
|
736
727
|
self.cameraPosition = call.getString("position") ?? "rear"
|
|
737
728
|
let deviceId = call.getString("deviceId")
|
|
@@ -740,15 +731,19 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
740
731
|
// Set width - use screen width if not provided or if 0
|
|
741
732
|
if let width = call.getInt("width"), width > 0 {
|
|
742
733
|
self.width = CGFloat(width)
|
|
734
|
+
self.widthIsExplicit = true
|
|
743
735
|
} else {
|
|
744
736
|
self.width = UIScreen.main.bounds.size.width
|
|
737
|
+
self.widthIsExplicit = false
|
|
745
738
|
}
|
|
746
739
|
|
|
747
740
|
// Set height - use screen height if not provided or if 0
|
|
748
741
|
if let height = call.getInt("height"), height > 0 {
|
|
749
742
|
self.height = CGFloat(height)
|
|
743
|
+
self.heightIsExplicit = true
|
|
750
744
|
} else {
|
|
751
745
|
self.height = UIScreen.main.bounds.size.height
|
|
746
|
+
self.heightIsExplicit = false
|
|
752
747
|
}
|
|
753
748
|
|
|
754
749
|
// Set x position - use exact CSS pixel value from web view, or mark for centering
|
|
@@ -819,18 +814,36 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
819
814
|
}
|
|
820
815
|
}
|
|
821
816
|
|
|
822
|
-
self.cameraController.
|
|
817
|
+
self.cameraController.onCameraInterrupted = { [weak self] reason, audioDropped in
|
|
818
|
+
self?.notifyListeners("cameraInterrupted", data: [
|
|
819
|
+
"reason": reason,
|
|
820
|
+
"audioDropped": audioDropped
|
|
821
|
+
])
|
|
822
|
+
}
|
|
823
|
+
self.cameraController.onCameraInterruptionEnded = { [weak self] in
|
|
824
|
+
self?.notifyListeners("cameraInterruptionEnded", data: [:])
|
|
825
|
+
}
|
|
826
|
+
self.cameraController.onStartFailure = { [weak self] error in
|
|
827
|
+
guard let self = self, startToken == self.startGeneration, !self.hasResolvedStartCall else { return }
|
|
828
|
+
DispatchQueue.main.async {
|
|
829
|
+
self.failPendingStart(call, message: error.localizedDescription)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
self.cameraController.prepare(cameraPosition: self.cameraPosition, deviceId: deviceId, disableAudio: self.disableAudio, cameraMode: cameraMode, aspectRatio: self.aspectRatio, aspectMode: self.aspectMode, initialZoomLevel: initialZoomLevel, disableFocusIndicator: self.disableFocusIndicator, videoQuality: videoQuality, startToken: startToken) { error in
|
|
823
834
|
if let error = error {
|
|
824
835
|
print(error)
|
|
825
836
|
DispatchQueue.main.async {
|
|
826
|
-
self.
|
|
827
|
-
self.
|
|
828
|
-
call.reject(error.localizedDescription)
|
|
837
|
+
guard startToken == self.startGeneration else { return }
|
|
838
|
+
self.failPendingStart(call, message: error.localizedDescription)
|
|
829
839
|
}
|
|
830
840
|
return
|
|
831
841
|
}
|
|
832
842
|
|
|
833
843
|
DispatchQueue.main.async {
|
|
844
|
+
guard startToken == self.startGeneration, !self.hasResolvedStartCall else {
|
|
845
|
+
return
|
|
846
|
+
}
|
|
834
847
|
if self.rotateWhenOrientationChanged == true {
|
|
835
848
|
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
|
|
836
849
|
self.isGeneratingDeviceOrientationNotifications = true
|
|
@@ -839,16 +852,15 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
839
852
|
name: UIDevice.orientationDidChangeNotification,
|
|
840
853
|
object: nil)
|
|
841
854
|
}
|
|
842
|
-
self.completeStartCamera(call: call)
|
|
855
|
+
self.completeStartCamera(call: call, startToken: startToken)
|
|
843
856
|
}
|
|
844
857
|
}
|
|
845
858
|
}
|
|
846
859
|
|
|
847
860
|
let handleDenied: (AVAuthorizationStatus) -> Void = { _ in
|
|
848
861
|
DispatchQueue.main.async {
|
|
849
|
-
self.
|
|
850
|
-
self.
|
|
851
|
-
call.reject("camera permission denied. enable camera access in Settings.", "cameraPermissionDenied")
|
|
862
|
+
guard startToken == self.startGeneration else { return }
|
|
863
|
+
self.failPendingStart(call, message: "camera permission denied. enable camera access in Settings.", errorCode: "cameraPermissionDenied")
|
|
852
864
|
}
|
|
853
865
|
}
|
|
854
866
|
|
|
@@ -859,6 +871,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
859
871
|
beginStart()
|
|
860
872
|
case .notDetermined:
|
|
861
873
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
874
|
+
guard startToken == self.startGeneration else { return }
|
|
862
875
|
if granted {
|
|
863
876
|
beginStart()
|
|
864
877
|
} else {
|
|
@@ -873,7 +886,71 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
873
886
|
}
|
|
874
887
|
}
|
|
875
888
|
|
|
876
|
-
private func
|
|
889
|
+
private func cancelFirstFrameTimeout() {
|
|
890
|
+
self.firstFrameTimeoutWorkItem?.cancel()
|
|
891
|
+
self.firstFrameTimeoutWorkItem = nil
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private func teardownAfterFailedStart() {
|
|
895
|
+
self.cameraController.removeGridOverlay()
|
|
896
|
+
if let previewView = self.previewView {
|
|
897
|
+
previewView.removeFromSuperview()
|
|
898
|
+
self.previewView = nil
|
|
899
|
+
}
|
|
900
|
+
if let webView = self.webView {
|
|
901
|
+
webView.isOpaque = true
|
|
902
|
+
self.restoreWebViewBackground(webView)
|
|
903
|
+
}
|
|
904
|
+
NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
|
|
905
|
+
if self.isGeneratingDeviceOrientationNotifications {
|
|
906
|
+
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
|
907
|
+
self.isGeneratingDeviceOrientationNotifications = false
|
|
908
|
+
}
|
|
909
|
+
self.cameraController.invalidateActivePrepare()
|
|
910
|
+
self.cameraController.cleanup()
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private func failPendingStart(_ call: CAPPluginCall, message: String, errorCode: String? = nil) {
|
|
914
|
+
let applyFailure = { [weak self] in
|
|
915
|
+
guard let self = self else { return }
|
|
916
|
+
guard !self.hasResolvedStartCall else { return }
|
|
917
|
+
self.hasResolvedStartCall = true
|
|
918
|
+
self.pendingStartCall = nil
|
|
919
|
+
self.cancelFirstFrameTimeout()
|
|
920
|
+
self.cameraController.firstFrameReadyCallback = nil
|
|
921
|
+
self.cameraController.onStartFailure = nil
|
|
922
|
+
self.teardownAfterFailedStart()
|
|
923
|
+
self.isInitializing = false
|
|
924
|
+
self.isInitialized = false
|
|
925
|
+
self.pendingStartBarcodeScannerOptions = nil
|
|
926
|
+
if let errorCode = errorCode {
|
|
927
|
+
call.reject(message, errorCode)
|
|
928
|
+
} else {
|
|
929
|
+
call.reject(message)
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
if Thread.isMainThread {
|
|
934
|
+
applyFailure()
|
|
935
|
+
} else {
|
|
936
|
+
DispatchQueue.main.sync {
|
|
937
|
+
applyFailure()
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
private func scheduleFirstFrameTimeout(for call: CAPPluginCall, startToken: UInt) {
|
|
943
|
+
self.cancelFirstFrameTimeout()
|
|
944
|
+
let workItem = DispatchWorkItem { [weak self] in
|
|
945
|
+
guard let self = self, startToken == self.startGeneration, !self.hasResolvedStartCall else { return }
|
|
946
|
+
self.failPendingStart(call, message: "Camera preview failed to start: timed out waiting for the first frame")
|
|
947
|
+
}
|
|
948
|
+
self.firstFrameTimeoutWorkItem = workItem
|
|
949
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: workItem)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private func completeStartCamera(call: CAPPluginCall, startToken: UInt) {
|
|
953
|
+
guard startToken == self.startGeneration else { return }
|
|
877
954
|
// Create and configure the preview view first
|
|
878
955
|
self.updateCameraFrame()
|
|
879
956
|
|
|
@@ -913,9 +990,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
913
990
|
|
|
914
991
|
// Set up callback to wait for first frame before resolving
|
|
915
992
|
self.cameraController.firstFrameReadyCallback = { [weak self] in
|
|
916
|
-
guard let self = self else { return }
|
|
993
|
+
guard let self = self, startToken == self.startGeneration else { return }
|
|
917
994
|
|
|
918
995
|
DispatchQueue.main.async {
|
|
996
|
+
guard startToken == self.startGeneration else { return }
|
|
919
997
|
var returnedObject = JSObject()
|
|
920
998
|
returnedObject["width"] = self.previewView.frame.width as any JSValue
|
|
921
999
|
returnedObject["height"] = self.previewView.frame.height as any JSValue
|
|
@@ -925,9 +1003,12 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
925
1003
|
}
|
|
926
1004
|
}
|
|
927
1005
|
|
|
1006
|
+
self.scheduleFirstFrameTimeout(for: call, startToken: startToken)
|
|
1007
|
+
|
|
928
1008
|
// If already received first frame (unlikely but possible), resolve immediately on main thread
|
|
929
1009
|
if self.cameraController.hasReceivedFirstFrame {
|
|
930
1010
|
DispatchQueue.main.async {
|
|
1011
|
+
guard startToken == self.startGeneration else { return }
|
|
931
1012
|
var returnedObject = JSObject()
|
|
932
1013
|
returnedObject["width"] = self.previewView.frame.width as any JSValue
|
|
933
1014
|
returnedObject["height"] = self.previewView.frame.height as any JSValue
|
|
@@ -955,7 +1036,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
955
1036
|
private func resolveStartCall(_ call: CAPPluginCall, returnedObject: JSObject) {
|
|
956
1037
|
guard !hasResolvedStartCall else { return }
|
|
957
1038
|
hasResolvedStartCall = true
|
|
1039
|
+
pendingStartCall = nil
|
|
1040
|
+
cancelFirstFrameTimeout()
|
|
958
1041
|
cameraController.firstFrameReadyCallback = nil
|
|
1042
|
+
cameraController.onStartFailure = nil
|
|
959
1043
|
|
|
960
1044
|
if let options = pendingStartBarcodeScannerOptions {
|
|
961
1045
|
do {
|
|
@@ -1044,6 +1128,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
1044
1128
|
|
|
1045
1129
|
// UI operations must be on main thread
|
|
1046
1130
|
DispatchQueue.main.async {
|
|
1131
|
+
if !self.hasResolvedStartCall, let pendingStartCall = self.pendingStartCall {
|
|
1132
|
+
self.failPendingStart(pendingStartCall, message: "Camera start cancelled")
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1047
1135
|
// If a photo capture is in-flight, defer cleanup until it finishes,
|
|
1048
1136
|
// but hide the preview immediately so UI can close.
|
|
1049
1137
|
self.cameraController.removeGridOverlay()
|
|
@@ -1059,6 +1147,8 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
1059
1147
|
self.restoreWebViewBackground(webView)
|
|
1060
1148
|
}
|
|
1061
1149
|
|
|
1150
|
+
self.startGeneration += 1
|
|
1151
|
+
self.cancelFirstFrameTimeout()
|
|
1062
1152
|
self.isInitialized = false
|
|
1063
1153
|
self.isInitializing = false
|
|
1064
1154
|
|
|
@@ -1233,7 +1323,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
1233
1323
|
print("[CameraPreview] Location permission not determined, requesting...")
|
|
1234
1324
|
// Save the call for the delegate callback
|
|
1235
1325
|
print("[CameraPreview] Saving call for location authorization flow")
|
|
1236
|
-
call
|
|
1326
|
+
self.bridge?.saveCall(call)
|
|
1237
1327
|
self.permissionCallID = call.callbackId
|
|
1238
1328
|
self.waitingForLocation = true
|
|
1239
1329
|
|
|
@@ -1286,6 +1376,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
1286
1376
|
}()
|
|
1287
1377
|
print("[CameraPreview] Preview dimensions: \(previewWidth)x\(previewHeight)")
|
|
1288
1378
|
|
|
1379
|
+
let capturedFromFrontCamera = self.cameraController.currentCameraPosition == .front
|
|
1289
1380
|
let gpsForThisCapture = (withExifLocation ?? false) ? self.currentLocation : nil
|
|
1290
1381
|
self.cameraController.captureImage(width: width, height: height, quality: quality, gpsLocation: gpsForThisCapture, embedTimestamp: embedTimestamp, embedLocation: effectiveEmbedLocation, photoQualityPrioritization: photoQualityPrioritization) { (image, originalPhotoData, _, error) in
|
|
1291
1382
|
print("[CameraPreview] captureImage callback received")
|
|
@@ -1311,7 +1402,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
1311
1402
|
}
|
|
1312
1403
|
|
|
1313
1404
|
let imageToEncode: UIImage
|
|
1314
|
-
if mirrorFrontCamera,
|
|
1405
|
+
if mirrorFrontCamera, capturedFromFrontCamera {
|
|
1315
1406
|
imageToEncode = self.cameraController.mirrorImageHorizontally(capturedImage)
|
|
1316
1407
|
} else {
|
|
1317
1408
|
imageToEncode = capturedImage
|
|
@@ -1534,6 +1625,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
1534
1625
|
@objc func captureSample(_ call: CAPPluginCall) {
|
|
1535
1626
|
let quality: Int = call.getInt("quality") ?? 85
|
|
1536
1627
|
let mirrorFrontCamera = call.getBool("mirrorFrontCamera", false) ?? false
|
|
1628
|
+
let capturedFromFrontCamera = self.cameraController.currentCameraPosition == .front
|
|
1537
1629
|
|
|
1538
1630
|
self.cameraController.captureSample { image, error in
|
|
1539
1631
|
guard let image = image else {
|
|
@@ -1543,7 +1635,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
1543
1635
|
}
|
|
1544
1636
|
|
|
1545
1637
|
var outputImage = image
|
|
1546
|
-
if mirrorFrontCamera,
|
|
1638
|
+
if mirrorFrontCamera, capturedFromFrontCamera {
|
|
1547
1639
|
outputImage = self.cameraController.mirrorImageHorizontally(image)
|
|
1548
1640
|
}
|
|
1549
1641
|
let imageData = outputImage.jpegData(compressionQuality: CGFloat(quality)/100)
|
|
@@ -2029,14 +2121,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
2029
2121
|
print("[CameraPreview] Location authorized, getting location for capture")
|
|
2030
2122
|
self.getCurrentLocation { _ in
|
|
2031
2123
|
self.performCapture(call: call)
|
|
2032
|
-
call
|
|
2124
|
+
self.bridge?.releaseCall(call)
|
|
2033
2125
|
self.permissionCallID = nil
|
|
2034
2126
|
self.waitingForLocation = false
|
|
2035
2127
|
}
|
|
2036
2128
|
case .denied, .restricted:
|
|
2037
2129
|
print("[CameraPreview] Location denied, rejecting capture")
|
|
2038
2130
|
call.reject("Location permission denied")
|
|
2039
|
-
call
|
|
2131
|
+
self.bridge?.releaseCall(call)
|
|
2040
2132
|
self.permissionCallID = nil
|
|
2041
2133
|
self.waitingForLocation = false
|
|
2042
2134
|
case .notDetermined:
|
|
@@ -2045,7 +2137,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
2045
2137
|
@unknown default:
|
|
2046
2138
|
print("[CameraPreview] Unknown status, rejecting capture")
|
|
2047
2139
|
call.reject("Unknown location permission status")
|
|
2048
|
-
call
|
|
2140
|
+
self.bridge?.releaseCall(call)
|
|
2049
2141
|
self.permissionCallID = nil
|
|
2050
2142
|
self.waitingForLocation = false
|
|
2051
2143
|
}
|
|
@@ -2209,13 +2301,23 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
2209
2301
|
print("[CameraPreview] width: \(UIScreen.main.bounds.size.width) height: \(UIScreen.main.bounds.size.height)")
|
|
2210
2302
|
|
|
2211
2303
|
// Calculate dimensions using centralized method
|
|
2212
|
-
let dimensions =
|
|
2304
|
+
let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
|
|
2305
|
+
ratio,
|
|
2306
|
+
availableWidth: finalWidth,
|
|
2307
|
+
availableHeight: webViewHeight - paddingBottom,
|
|
2308
|
+
isPortrait: isPortrait
|
|
2309
|
+
)
|
|
2213
2310
|
if isPortrait {
|
|
2214
2311
|
finalHeight = dimensions.height
|
|
2215
2312
|
finalWidth = dimensions.width
|
|
2216
2313
|
} else {
|
|
2217
2314
|
// In landscape, recalculate based on available space
|
|
2218
|
-
let landscapeDimensions =
|
|
2315
|
+
let landscapeDimensions = AspectRatioLayout.dimensionsForAspectRatio(
|
|
2316
|
+
ratio,
|
|
2317
|
+
availableWidth: webViewWidth,
|
|
2318
|
+
availableHeight: webViewHeight - paddingBottom,
|
|
2319
|
+
isPortrait: isPortrait
|
|
2320
|
+
)
|
|
2219
2321
|
finalWidth = landscapeDimensions.width
|
|
2220
2322
|
finalHeight = landscapeDimensions.height
|
|
2221
2323
|
}
|
|
@@ -2256,6 +2358,17 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
2256
2358
|
}
|
|
2257
2359
|
}
|
|
2258
2360
|
|
|
2361
|
+
// A fill preview sizes each explicitly positioned axis from the web-view space
|
|
2362
|
+
// remaining after that coordinate, unless the caller supplied that dimension.
|
|
2363
|
+
if let ratio = currentAspectRatio, AspectRatioLayout.isFillMode(ratio) {
|
|
2364
|
+
if currentX != -1 && width == nil && !self.widthIsExplicit {
|
|
2365
|
+
finalWidth = AspectRatioLayout.remainingLength(from: currentX, in: webViewWidth)
|
|
2366
|
+
}
|
|
2367
|
+
if currentY != -1 && height == nil && !self.heightIsExplicit {
|
|
2368
|
+
finalHeight = AspectRatioLayout.remainingLength(from: currentY, in: webViewHeight - paddingBottom)
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2259
2372
|
return CGRect(x: finalX, y: finalY, width: finalWidth, height: finalHeight)
|
|
2260
2373
|
}
|
|
2261
2374
|
|
|
@@ -2276,9 +2389,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
2276
2389
|
var frame = calculateCameraFrame()
|
|
2277
2390
|
|
|
2278
2391
|
// Apply aspect ratio adjustments only if not auto-centering
|
|
2279
|
-
if posX != -1 && posY != -1,
|
|
2280
|
-
|
|
2281
|
-
|
|
2392
|
+
if posX != -1 && posY != -1,
|
|
2393
|
+
let aspectRatio = self.aspectRatio,
|
|
2394
|
+
!AspectRatioLayout.isFillMode(aspectRatio),
|
|
2395
|
+
let ratio = AspectRatioLayout.parseViewportAspectRatio(aspectRatio, isPortrait: self.isPortrait()) {
|
|
2282
2396
|
let currentRatio = frame.width / frame.height
|
|
2283
2397
|
|
|
2284
2398
|
if currentRatio > ratio {
|
|
@@ -2352,8 +2466,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
|
|
|
2352
2466
|
self.posY = -1 // Auto-center if Y not provided
|
|
2353
2467
|
}
|
|
2354
2468
|
|
|
2355
|
-
if let width = call.getInt("width") {
|
|
2356
|
-
|
|
2469
|
+
if let width = call.getInt("width") {
|
|
2470
|
+
self.width = CGFloat(width)
|
|
2471
|
+
self.widthIsExplicit = true
|
|
2472
|
+
}
|
|
2473
|
+
if let height = call.getInt("height") {
|
|
2474
|
+
self.height = CGFloat(height)
|
|
2475
|
+
self.heightIsExplicit = true
|
|
2476
|
+
}
|
|
2357
2477
|
|
|
2358
2478
|
DispatchQueue.main.async {
|
|
2359
2479
|
// Direct update without animation for better performance
|
|
@@ -1,15 +1,156 @@
|
|
|
1
1
|
import XCTest
|
|
2
|
-
@testable import
|
|
2
|
+
@testable import CapgoCameraPreview
|
|
3
3
|
|
|
4
|
-
class
|
|
5
|
-
func
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
final class AspectRatioLayoutTests: XCTestCase {
|
|
5
|
+
func testFillModeIsRecognizedCaseInsensitively() {
|
|
6
|
+
XCTAssertTrue(AspectRatioLayout.isFillMode("fill"))
|
|
7
|
+
XCTAssertTrue(AspectRatioLayout.isFillMode("FILL"))
|
|
8
|
+
XCTAssertFalse(AspectRatioLayout.isFillMode("4:3"))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
func testFillDoesNotParseAsNumericRatio() {
|
|
12
|
+
XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("fill", isPortrait: true))
|
|
13
|
+
XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("fill", isPortrait: false))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func testFillUsesFullAvailableSpace() {
|
|
17
|
+
let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
|
|
18
|
+
"fill",
|
|
19
|
+
availableWidth: 428,
|
|
20
|
+
availableHeight: 926,
|
|
21
|
+
isPortrait: true
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
XCTAssertEqual(dimensions.width, 428)
|
|
25
|
+
XCTAssertEqual(dimensions.height, 926)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testFourThreePortraitParsesAsThreeFour() {
|
|
29
|
+
let ratio = AspectRatioLayout.parseViewportAspectRatio("4:3", isPortrait: true)
|
|
30
|
+
XCTAssertEqual(ratio ?? 0, 0.75, accuracy: 0.0001)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func testFourThreePortraitLetterboxesInTallViewport() {
|
|
34
|
+
let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
|
|
35
|
+
"4:3",
|
|
36
|
+
availableWidth: 428,
|
|
37
|
+
availableHeight: 926,
|
|
38
|
+
isPortrait: true
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
XCTAssertEqual(dimensions.width, 428, accuracy: 0.5)
|
|
42
|
+
XCTAssertLessThan(dimensions.height, 926)
|
|
43
|
+
XCTAssertGreaterThan(dimensions.height, 500)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func testMalformedRatioFallsBackToFullViewport() {
|
|
47
|
+
XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("4:bad:3", isPortrait: true))
|
|
48
|
+
|
|
49
|
+
let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
|
|
50
|
+
"4:bad:3",
|
|
51
|
+
availableWidth: 428,
|
|
52
|
+
availableHeight: 926,
|
|
53
|
+
isPortrait: true
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
XCTAssertEqual(dimensions.width, 428)
|
|
57
|
+
XCTAssertEqual(dimensions.height, 926)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func testExtremeRatioQuotientFallsBackToFullViewport() {
|
|
61
|
+
XCTAssertNil(AspectRatioLayout.parseViewportAspectRatio("1e-300:1e300", isPortrait: true))
|
|
62
|
+
|
|
63
|
+
let dimensions = AspectRatioLayout.dimensionsForAspectRatio(
|
|
64
|
+
"1e-300:1e300",
|
|
65
|
+
availableWidth: 428,
|
|
66
|
+
availableHeight: 926,
|
|
67
|
+
isPortrait: true
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
XCTAssertEqual(dimensions.width, 428)
|
|
71
|
+
XCTAssertEqual(dimensions.height, 926)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func testPositionedFillUsesRemainingWebViewSpace() {
|
|
75
|
+
let size = AspectRatioLayout.positionedFillSize(x: 20, y: 100, viewportWidth: 390, viewportHeight: 800)
|
|
8
76
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
77
|
+
XCTAssertEqual(size.width, 370)
|
|
78
|
+
XCTAssertEqual(size.height, 700)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func testPositionedFillNeverReturnsNegativeSize() {
|
|
82
|
+
let size = AspectRatioLayout.positionedFillSize(x: 500, y: 900, viewportWidth: 390, viewportHeight: 800)
|
|
83
|
+
|
|
84
|
+
XCTAssertEqual(size.width, 0)
|
|
85
|
+
XCTAssertEqual(size.height, 0)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func testRemainingLengthIsComputedPerAxis() {
|
|
89
|
+
XCTAssertEqual(AspectRatioLayout.remainingLength(from: 40, in: 390), 350)
|
|
90
|
+
XCTAssertEqual(AspectRatioLayout.remainingLength(from: 0, in: 800), 800)
|
|
91
|
+
XCTAssertEqual(AspectRatioLayout.remainingLength(from: 900, in: 800), 0)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
func testVisibleFillRatioMatchesPreviewWhenOrientationsAgree() {
|
|
95
|
+
let ratio = AspectRatioLayout.visibleFillCaptureAspectRatio(
|
|
96
|
+
previewSize: CGSize(width: 390, height: 844),
|
|
97
|
+
interfaceIsPortrait: true,
|
|
98
|
+
captureIsPortrait: true
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
XCTAssertEqual(ratio ?? 0, 390.0 / 844.0, accuracy: 0.0001)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func testVisibleFillRatioFlipsWhenCaptureOrientationDiffers() {
|
|
105
|
+
let ratio = AspectRatioLayout.visibleFillCaptureAspectRatio(
|
|
106
|
+
previewSize: CGSize(width: 390, height: 844),
|
|
107
|
+
interfaceIsPortrait: true,
|
|
108
|
+
captureIsPortrait: false
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
XCTAssertEqual(ratio ?? 0, 844.0 / 390.0, accuracy: 0.0001)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func testVisibleFillRatioRejectsEmptyPreview() {
|
|
115
|
+
XCTAssertNil(
|
|
116
|
+
AspectRatioLayout.visibleFillCaptureAspectRatio(
|
|
117
|
+
previewSize: .zero,
|
|
118
|
+
interfaceIsPortrait: true,
|
|
119
|
+
captureIsPortrait: true
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func testCenterCropOfPortraitPhotoToTallPreviewTrimsSides() {
|
|
125
|
+
// 3:4 portrait photo shown in a 390x844 cover preview: the sides are cropped.
|
|
126
|
+
let rect = AspectRatioLayout.centerCropRect(
|
|
127
|
+
imageSize: CGSize(width: 3024, height: 4032),
|
|
128
|
+
targetAspectRatio: 390.0 / 844.0
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
XCTAssertEqual(rect.height, 4032)
|
|
132
|
+
XCTAssertEqual(rect.width, (4032 * 390.0 / 844.0).rounded(), accuracy: 2)
|
|
133
|
+
XCTAssertEqual(rect.midX, 1512, accuracy: 1)
|
|
134
|
+
XCTAssertEqual(rect.minY, 0)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func testCenterCropToWiderRatioTrimsTopAndBottom() {
|
|
138
|
+
let rect = AspectRatioLayout.centerCropRect(
|
|
139
|
+
imageSize: CGSize(width: 3024, height: 4032),
|
|
140
|
+
targetAspectRatio: 1
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
XCTAssertEqual(rect.width, 3024)
|
|
144
|
+
XCTAssertEqual(rect.height, 3024)
|
|
145
|
+
XCTAssertEqual(rect.minY, 504)
|
|
146
|
+
}
|
|
12
147
|
|
|
13
|
-
|
|
148
|
+
func testCenterCropWithInvalidRatioKeepsFullImage() {
|
|
149
|
+
let size = CGSize(width: 3024, height: 4032)
|
|
150
|
+
XCTAssertEqual(AspectRatioLayout.centerCropRect(imageSize: size, targetAspectRatio: 0), CGRect(origin: .zero, size: size))
|
|
151
|
+
XCTAssertEqual(
|
|
152
|
+
AspectRatioLayout.centerCropRect(imageSize: size, targetAspectRatio: .infinity),
|
|
153
|
+
CGRect(origin: .zero, size: size)
|
|
154
|
+
)
|
|
14
155
|
}
|
|
15
156
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capgo/camera-preview",
|
|
3
|
-
"version": "8.11.
|
|
3
|
+
"version": "8.11.10",
|
|
4
4
|
"description": "Camera preview",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
43
|
"verify": "bun run verify:ios && bun run verify:android && bun run verify:web",
|
|
44
|
-
"verify:ios": "xcodebuild -scheme CapgoCameraPreview -destination generic/platform=iOS",
|
|
44
|
+
"verify:ios": "xcodebuild -scheme CapgoCameraPreview -destination generic/platform=iOS && bun run test:ios",
|
|
45
|
+
"test:ios": "bash ./scripts/test-ios.sh",
|
|
45
46
|
"verify:android": "cd android && ./gradlew clean build test && cd ..",
|
|
46
47
|
"verify:web": "bun run build",
|
|
47
48
|
"lint": "bun run eslint && bun run prettier -- --check && bun run swiftlint -- lint",
|