capacitor-camera-multicapture 0.12.2
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/CameraMultiCapture.podspec +17 -0
- package/LICENSE +21 -0
- package/Package.swift +28 -0
- package/README.md +551 -0
- package/android/build.gradle +69 -0
- package/android/src/main/AndroidManifest.xml +6 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/CameraConfig.java +45 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/CameraConfigMapper.java +98 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/CameraMultiCapture.java +10 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/CameraMultiCapturePlugin.java +1340 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/ExifWrapper.java +108 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/GenericUploadWorker.java +173 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/ImageUtils.java +215 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/PluginVersion.java +6 -0
- package/android/src/main/java/dev/hemang/cameramulticapture/ThumbnailGenerator.java +48 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/esm/controllers/camera-controller.d.ts +127 -0
- package/dist/esm/controllers/camera-controller.js +388 -0
- package/dist/esm/controllers/camera-controller.js.map +1 -0
- package/dist/esm/controllers/gallery-controller.d.ts +66 -0
- package/dist/esm/controllers/gallery-controller.js +333 -0
- package/dist/esm/controllers/gallery-controller.js.map +1 -0
- package/dist/esm/definitions.d.ts +372 -0
- package/dist/esm/definitions.js +6 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/overlay-manager.d.ts +124 -0
- package/dist/esm/overlay-manager.js +732 -0
- package/dist/esm/overlay-manager.js.map +1 -0
- package/dist/esm/overlay.d.ts +2 -0
- package/dist/esm/overlay.js +39 -0
- package/dist/esm/overlay.js.map +1 -0
- package/dist/esm/registration.d.ts +5 -0
- package/dist/esm/registration.js +8 -0
- package/dist/esm/registration.js.map +1 -0
- package/dist/esm/types/ui-types.d.ts +78 -0
- package/dist/esm/types/ui-types.js +2 -0
- package/dist/esm/types/ui-types.js.map +1 -0
- package/dist/esm/ui/capture-gesture-handler.d.ts +15 -0
- package/dist/esm/ui/capture-gesture-handler.js +87 -0
- package/dist/esm/ui/capture-gesture-handler.js.map +1 -0
- package/dist/esm/ui/default-styles.d.ts +27 -0
- package/dist/esm/ui/default-styles.js +134 -0
- package/dist/esm/ui/default-styles.js.map +1 -0
- package/dist/esm/ui/image-editor.d.ts +10 -0
- package/dist/esm/ui/image-editor.js +103 -0
- package/dist/esm/ui/image-editor.js.map +1 -0
- package/dist/esm/ui/layout-manager.d.ts +42 -0
- package/dist/esm/ui/layout-manager.js +199 -0
- package/dist/esm/ui/layout-manager.js.map +1 -0
- package/dist/esm/ui/media-viewer.d.ts +2 -0
- package/dist/esm/ui/media-viewer.js +267 -0
- package/dist/esm/ui/media-viewer.js.map +1 -0
- package/dist/esm/ui/pinch-zoom-handler.d.ts +37 -0
- package/dist/esm/ui/pinch-zoom-handler.js +118 -0
- package/dist/esm/ui/pinch-zoom-handler.js.map +1 -0
- package/dist/esm/ui/ui-factory.d.ts +39 -0
- package/dist/esm/ui/ui-factory.js +272 -0
- package/dist/esm/ui/ui-factory.js.map +1 -0
- package/dist/esm/version.d.ts +1 -0
- package/dist/esm/version.js +3 -0
- package/dist/esm/version.js.map +1 -0
- package/dist/esm/web.d.ts +69 -0
- package/dist/esm/web.js +122 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +6274 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +6276 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/CameraMultiCapturePlugin/CameraMultiCapture.swift +8 -0
- package/ios/Sources/CameraMultiCapturePlugin/CameraMultiCapturePlugin.swift +1551 -0
- package/ios/Sources/CameraMultiCapturePlugin/PluginVersion.swift +2 -0
- package/ios/Tests/CameraMultiCapturePluginTests/CameraMultiCapturePluginTests.swift +15 -0
- package/package.json +93 -0
|
@@ -0,0 +1,1551 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import CallKit
|
|
3
|
+
import Capacitor
|
|
4
|
+
import Foundation
|
|
5
|
+
import Photos
|
|
6
|
+
import BackgroundTasks
|
|
7
|
+
|
|
8
|
+
struct CameraConfig {
|
|
9
|
+
var x: CGFloat
|
|
10
|
+
var y: CGFloat
|
|
11
|
+
var width: CGFloat
|
|
12
|
+
var height: CGFloat
|
|
13
|
+
var cameraPosition: AVCaptureDevice.Position
|
|
14
|
+
var quality: AVCaptureSession.Preset
|
|
15
|
+
var zoom: CGFloat
|
|
16
|
+
var jpegQuality: CGFloat
|
|
17
|
+
var autoFocus: Bool
|
|
18
|
+
var orientation: AVCaptureVideoOrientation
|
|
19
|
+
var flashMode: AVCaptureDevice.FlashMode
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@objc(CameraMultiCapturePlugin)
|
|
23
|
+
public class CameraMultiCapturePlugin: CAPPlugin, CAPBridgedPlugin {
|
|
24
|
+
public let identifier = "CameraMultiCapturePlugin"
|
|
25
|
+
public let jsName = "CameraMultiCapture"
|
|
26
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
27
|
+
CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
|
|
28
|
+
CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
|
|
29
|
+
CAPPluginMethod(name: "capture", returnType: CAPPluginReturnPromise),
|
|
30
|
+
CAPPluginMethod(name: "startVideoRecording", returnType: CAPPluginReturnPromise),
|
|
31
|
+
CAPPluginMethod(name: "stopVideoRecording", returnType: CAPPluginReturnPromise),
|
|
32
|
+
CAPPluginMethod(name: "setZoom", returnType: CAPPluginReturnPromise),
|
|
33
|
+
CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
|
|
34
|
+
CAPPluginMethod(name: "updatePreviewRect", returnType: CAPPluginReturnPromise),
|
|
35
|
+
CAPPluginMethod(name: "setFlash", returnType: CAPPluginReturnPromise),
|
|
36
|
+
CAPPluginMethod(name: "getFlash", returnType: CAPPluginReturnPromise),
|
|
37
|
+
CAPPluginMethod(name: "setTorch", returnType: CAPPluginReturnPromise),
|
|
38
|
+
CAPPluginMethod(name: "getTorch", returnType: CAPPluginReturnPromise),
|
|
39
|
+
CAPPluginMethod(name: "getAvailableZoomLevels", returnType: CAPPluginReturnPromise),
|
|
40
|
+
CAPPluginMethod(name: "getAvailableCameras", returnType: CAPPluginReturnPromise),
|
|
41
|
+
CAPPluginMethod(name: "switchToPhysicalCamera", returnType: CAPPluginReturnPromise),
|
|
42
|
+
CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
|
|
43
|
+
CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise),
|
|
44
|
+
CAPPluginMethod(name: "queueBackgroundUpload", returnType: CAPPluginReturnPromise),
|
|
45
|
+
CAPPluginMethod(name: "getUploadStatus", returnType: CAPPluginReturnPromise),
|
|
46
|
+
CAPPluginMethod(name: "getVersion", returnType: CAPPluginReturnPromise),
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
var captureSession: AVCaptureSession?
|
|
50
|
+
var currentInput: AVCaptureDeviceInput?
|
|
51
|
+
var audioInput: AVCaptureDeviceInput?
|
|
52
|
+
var photoOutput: AVCapturePhotoOutput?
|
|
53
|
+
var movieOutput: AVCaptureMovieFileOutput?
|
|
54
|
+
var previewLayer: AVCaptureVideoPreviewLayer?
|
|
55
|
+
var cameraPreviewView: UIView?
|
|
56
|
+
var cameraPosition: AVCaptureDevice.Position = .back
|
|
57
|
+
var currentFlashMode: AVCaptureDevice.FlashMode = .off
|
|
58
|
+
var currentOrientation: AVCaptureVideoOrientation = .portrait
|
|
59
|
+
let sessionQueue = DispatchQueue(label: "camera.session.queue")
|
|
60
|
+
var captureDelegate: PhotoCaptureDelegate?
|
|
61
|
+
var videoCaptureDelegate: VideoCaptureDelegate?
|
|
62
|
+
var pendingVideoStopCall: CAPPluginCall?
|
|
63
|
+
var autoStoppedVideoResult: [String: Any]?
|
|
64
|
+
var maxRecordingDurationSeconds: Double = 0
|
|
65
|
+
var enableSaving: Bool = false
|
|
66
|
+
var galleryAlbumName: String = "Camera"
|
|
67
|
+
var isUsingVirtualDevice: Bool = false
|
|
68
|
+
// Divisor to convert between API zoom factors and user-facing values.
|
|
69
|
+
// On triple-camera virtual devices (e.g. iPhone 15 Pro), the API scale
|
|
70
|
+
// starts at 1.0 for ultrawide, with switchover at [2, 6]. The first
|
|
71
|
+
// switchover factor (2) represents "1x" (wide), so we divide API values
|
|
72
|
+
// by 2 to get user-facing labels: 0.5x, 1x, 3x.
|
|
73
|
+
// On devices without ultrawide, this stays 1.0 (no conversion needed).
|
|
74
|
+
var zoomScaleDivisor: CGFloat = 1.0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
private func detectCurrentOrientation() -> AVCaptureVideoOrientation {
|
|
79
|
+
switch UIDevice.current.orientation {
|
|
80
|
+
case .portrait:
|
|
81
|
+
return .portrait
|
|
82
|
+
case .portraitUpsideDown:
|
|
83
|
+
return .portraitUpsideDown
|
|
84
|
+
case .landscapeLeft:
|
|
85
|
+
return .landscapeRight
|
|
86
|
+
case .landscapeRight:
|
|
87
|
+
return .landscapeLeft
|
|
88
|
+
default:
|
|
89
|
+
if #available(iOS 13.0, *) {
|
|
90
|
+
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
|
91
|
+
switch windowScene.interfaceOrientation {
|
|
92
|
+
case .portrait:
|
|
93
|
+
return .portrait
|
|
94
|
+
case .portraitUpsideDown:
|
|
95
|
+
return .portraitUpsideDown
|
|
96
|
+
case .landscapeLeft:
|
|
97
|
+
return .landscapeRight
|
|
98
|
+
case .landscapeRight:
|
|
99
|
+
return .landscapeLeft
|
|
100
|
+
default:
|
|
101
|
+
return .portrait
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return .portrait
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@objc func start(_ call: CAPPluginCall) {
|
|
110
|
+
print("Received data from JS: \(call.dictionaryRepresentation)")
|
|
111
|
+
|
|
112
|
+
// Check camera permission before starting
|
|
113
|
+
let cameraAuthStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
114
|
+
if cameraAuthStatus != .authorized {
|
|
115
|
+
call.reject("Camera permission not granted. Please call requestPermissions() first.")
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
guard let previewRect = call.getObject("previewRect") else {
|
|
120
|
+
call.reject("Missing previewRect")
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let x = CGFloat((previewRect["x"] as? NSNumber)?.floatValue ?? 0)
|
|
125
|
+
let y = CGFloat((previewRect["y"] as? NSNumber)?.floatValue ?? 0)
|
|
126
|
+
let width = CGFloat(
|
|
127
|
+
(previewRect["width"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.width))
|
|
128
|
+
let height = CGFloat(
|
|
129
|
+
(previewRect["height"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.height))
|
|
130
|
+
|
|
131
|
+
let position =
|
|
132
|
+
(call.getString("cameraPosition") == "front") ? AVCaptureDevice.Position.front : .back
|
|
133
|
+
let qualityName = call.getString("captureMode") ?? "high"
|
|
134
|
+
let qualityPreset: AVCaptureSession.Preset = (qualityName == "low") ? .medium : .high
|
|
135
|
+
let zoom = CGFloat(call.getFloat("zoom") ?? 1.0)
|
|
136
|
+
let jpegQuality = CGFloat(call.getFloat("jpegQuality") ?? 0.8)
|
|
137
|
+
let autofocus = call.getBool("autoFocus") ?? true
|
|
138
|
+
let maxRecordingDuration = Double(call.getFloat("maxRecordingDuration") ?? 0)
|
|
139
|
+
|
|
140
|
+
let orientation: AVCaptureVideoOrientation
|
|
141
|
+
if let rotation = call.getInt("rotation") {
|
|
142
|
+
switch rotation {
|
|
143
|
+
case 90: orientation = .landscapeRight
|
|
144
|
+
case 180: orientation = .portraitUpsideDown
|
|
145
|
+
case 270: orientation = .landscapeLeft
|
|
146
|
+
default: orientation = .portrait
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
orientation = detectCurrentOrientation()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let flashModeString = call.getString("flash") ?? "off"
|
|
153
|
+
let flashMode: AVCaptureDevice.FlashMode
|
|
154
|
+
switch flashModeString {
|
|
155
|
+
case "on": flashMode = .on
|
|
156
|
+
case "auto": flashMode = .auto
|
|
157
|
+
default: flashMode = .off
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let config = CameraConfig(
|
|
161
|
+
x: x, y: y, width: width, height: height,
|
|
162
|
+
cameraPosition: position, quality: qualityPreset,
|
|
163
|
+
zoom: zoom, jpegQuality: jpegQuality, autoFocus: autofocus,
|
|
164
|
+
orientation: orientation, flashMode: flashMode
|
|
165
|
+
)
|
|
166
|
+
self.cameraPosition = config.cameraPosition
|
|
167
|
+
self.currentFlashMode = config.flashMode
|
|
168
|
+
self.currentOrientation = config.orientation
|
|
169
|
+
self.maxRecordingDurationSeconds = maxRecordingDuration > 0 ? maxRecordingDuration : 0
|
|
170
|
+
self.enableSaving = call.getBool("enableSaving") ?? false
|
|
171
|
+
self.galleryAlbumName = call.getString("galleryAlbumName") ?? "Camera"
|
|
172
|
+
|
|
173
|
+
self.sessionQueue.async {
|
|
174
|
+
do {
|
|
175
|
+
try self.configureSession(with: config, call: call)
|
|
176
|
+
DispatchQueue.main.async {
|
|
177
|
+
call.resolve()
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
DispatchQueue.main.async {
|
|
181
|
+
call.reject(
|
|
182
|
+
"Camera configuration failed: \(error.localizedDescription)")
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@objc func stop(_ call: CAPPluginCall) {
|
|
189
|
+
DispatchQueue.main.async {
|
|
190
|
+
if let device = self.currentInput?.device, device.hasTorch, device.torchMode != .off {
|
|
191
|
+
do {
|
|
192
|
+
try device.lockForConfiguration()
|
|
193
|
+
device.torchMode = .off
|
|
194
|
+
device.unlockForConfiguration()
|
|
195
|
+
} catch { /* best effort */ }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if self.movieOutput?.isRecording == true {
|
|
199
|
+
self.movieOutput?.stopRecording()
|
|
200
|
+
}
|
|
201
|
+
self.captureSession?.stopRunning()
|
|
202
|
+
self.previewLayer?.removeFromSuperlayer()
|
|
203
|
+
self.captureSession = nil
|
|
204
|
+
self.currentInput = nil
|
|
205
|
+
self.audioInput = nil
|
|
206
|
+
self.photoOutput = nil
|
|
207
|
+
self.movieOutput = nil
|
|
208
|
+
self.videoCaptureDelegate = nil
|
|
209
|
+
self.pendingVideoStopCall = nil
|
|
210
|
+
self.autoStoppedVideoResult = nil
|
|
211
|
+
self.isUsingVirtualDevice = false
|
|
212
|
+
|
|
213
|
+
// Restore webView background after camera preview is removed
|
|
214
|
+
if let webView = self.bridge?.webView {
|
|
215
|
+
webView.isOpaque = true
|
|
216
|
+
webView.backgroundColor = nil
|
|
217
|
+
webView.scrollView.backgroundColor = nil
|
|
218
|
+
webView.layer.backgroundColor = nil
|
|
219
|
+
webView.evaluateJavaScript("document.documentElement.style.backgroundColor = ''", completionHandler: nil)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
call.resolve()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
@objc func capture(_ call: CAPPluginCall) {
|
|
227
|
+
guard let session = captureSession, photoOutput != nil else {
|
|
228
|
+
call.reject("Camera not initialized")
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if !session.isRunning {
|
|
233
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
234
|
+
session.startRunning()
|
|
235
|
+
DispatchQueue.main.async {
|
|
236
|
+
self.performCapture(call)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
performCapture(call)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private func performCapture(_ call: CAPPluginCall) {
|
|
246
|
+
guard let photoOutput = photoOutput else {
|
|
247
|
+
call.reject("Photo output not initialized")
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Turn off torch before capture when flash is enabled; the LED is shared
|
|
252
|
+
// hardware so an active torch prevents the flash from firing correctly.
|
|
253
|
+
if currentFlashMode != .off,
|
|
254
|
+
let device = currentInput?.device, device.hasTorch, device.torchMode == .on {
|
|
255
|
+
do {
|
|
256
|
+
try device.lockForConfiguration()
|
|
257
|
+
device.torchMode = .off
|
|
258
|
+
device.unlockForConfiguration()
|
|
259
|
+
} catch {
|
|
260
|
+
print("[CameraMultiCapture] Failed to turn off torch before flash capture: \(error)")
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let resultType = call.getString("resultType") ?? "base64"
|
|
265
|
+
let settings = AVCapturePhotoSettings()
|
|
266
|
+
settings.isHighResolutionPhotoEnabled = photoOutput.isHighResolutionCaptureEnabled
|
|
267
|
+
|
|
268
|
+
// Apply flash settings
|
|
269
|
+
if photoOutput.supportedFlashModes.contains(currentFlashMode) {
|
|
270
|
+
settings.flashMode = currentFlashMode
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Set photo orientation to match current preview orientation
|
|
274
|
+
if let connection = photoOutput.connection(with: .video) {
|
|
275
|
+
connection.videoOrientation = currentOrientation
|
|
276
|
+
// Prevent front camera from capturing mirrored images
|
|
277
|
+
if self.cameraPosition == .front {
|
|
278
|
+
connection.isVideoMirrored = false
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let delegate = PhotoCaptureDelegate(plugin: self, call: call, resultType: resultType, isFrontCamera: cameraPosition == .front, enableSaving: enableSaving, galleryAlbumName: galleryAlbumName)
|
|
283
|
+
self.captureDelegate = delegate
|
|
284
|
+
|
|
285
|
+
photoOutput.capturePhoto(with: settings, delegate: delegate)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
@objc func startVideoRecording(_ call: CAPPluginCall) {
|
|
289
|
+
let audioAuthStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
290
|
+
if audioAuthStatus == .denied || audioAuthStatus == .restricted {
|
|
291
|
+
call.reject("Microphone permission denied. Please enable microphone access in Settings.")
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
if audioAuthStatus == .notDetermined {
|
|
295
|
+
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
296
|
+
DispatchQueue.main.async {
|
|
297
|
+
if granted {
|
|
298
|
+
self.startVideoRecordingInternal(call)
|
|
299
|
+
} else {
|
|
300
|
+
call.reject("Microphone permission denied.")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
startVideoRecordingInternal(call)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// True while a phone/VoIP call currently owns the audio hardware.
|
|
311
|
+
private let callObserver = CXCallObserver()
|
|
312
|
+
private var isCallActive: Bool {
|
|
313
|
+
callObserver.calls.contains { !$0.hasEnded }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Adds/removes the microphone lazily around video recording. Keeping the mic
|
|
317
|
+
// out of the session during photo capture / preview prevents iOS from
|
|
318
|
+
// interrupting (and freezing) the whole session when a phone call grabs the
|
|
319
|
+
// audio hardware. See configureSession for the full rationale.
|
|
320
|
+
private func addAudioInput() {
|
|
321
|
+
// During a call the audio hardware belongs to the call; adding an audio
|
|
322
|
+
// input would let iOS interrupt the whole session, freezing the preview and
|
|
323
|
+
// stalling the recording at 00:00. Record video-only in that case instead.
|
|
324
|
+
guard !isCallActive else {
|
|
325
|
+
print("[CameraMultiCapture] Active call detected — recording video without audio")
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
guard let session = captureSession, audioInput == nil,
|
|
329
|
+
let audioDevice = AVCaptureDevice.default(for: .audio) else { return }
|
|
330
|
+
do {
|
|
331
|
+
let input = try AVCaptureDeviceInput(device: audioDevice)
|
|
332
|
+
session.beginConfiguration()
|
|
333
|
+
if session.canAddInput(input) {
|
|
334
|
+
session.addInput(input)
|
|
335
|
+
self.audioInput = input
|
|
336
|
+
}
|
|
337
|
+
session.commitConfiguration()
|
|
338
|
+
} catch {
|
|
339
|
+
print("[CameraMultiCapture] Audio input config error: \(error)")
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private func removeAudioInput() {
|
|
344
|
+
guard let session = captureSession, let input = audioInput else { return }
|
|
345
|
+
session.beginConfiguration()
|
|
346
|
+
session.removeInput(input)
|
|
347
|
+
session.commitConfiguration()
|
|
348
|
+
self.audioInput = nil
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private func startVideoRecordingInternal(_ call: CAPPluginCall) {
|
|
352
|
+
guard let movieOutput = movieOutput else {
|
|
353
|
+
call.reject("Video output not initialized")
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
guard !movieOutput.isRecording else {
|
|
357
|
+
call.reject("Video recording is already in progress")
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
addAudioInput()
|
|
362
|
+
|
|
363
|
+
currentOrientation = detectCurrentOrientation()
|
|
364
|
+
if let videoConnection = movieOutput.connection(with: .video) {
|
|
365
|
+
videoConnection.videoOrientation = currentOrientation
|
|
366
|
+
if cameraPosition == .front {
|
|
367
|
+
videoConnection.isVideoMirrored = false
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if maxRecordingDurationSeconds > 0 {
|
|
372
|
+
movieOutput.maxRecordedDuration = CMTime(
|
|
373
|
+
seconds: maxRecordingDurationSeconds,
|
|
374
|
+
preferredTimescale: 600
|
|
375
|
+
)
|
|
376
|
+
} else {
|
|
377
|
+
movieOutput.maxRecordedDuration = .invalid
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
let tempDir = FileManager.default.temporaryDirectory
|
|
381
|
+
let fileName = UUID().uuidString + ".mp4"
|
|
382
|
+
let fileURL = tempDir.appendingPathComponent(fileName)
|
|
383
|
+
|
|
384
|
+
if currentFlashMode == .on, let device = currentInput?.device, device.hasTorch, device.isTorchAvailable {
|
|
385
|
+
do {
|
|
386
|
+
try device.lockForConfiguration()
|
|
387
|
+
device.torchMode = .on
|
|
388
|
+
device.unlockForConfiguration()
|
|
389
|
+
} catch {
|
|
390
|
+
print("[CameraMultiCapture] Failed to enable torch for video recording: \(error)")
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let delegate = VideoCaptureDelegate { [weak self] outputURL, error in
|
|
395
|
+
self?.handleVideoRecordingFinished(outputURL: outputURL, error: error)
|
|
396
|
+
}
|
|
397
|
+
self.videoCaptureDelegate = delegate
|
|
398
|
+
_ = movieOutput.connection(with: .audio) // ensure audio connection is active before recording
|
|
399
|
+
movieOutput.startRecording(to: fileURL, recordingDelegate: delegate)
|
|
400
|
+
call.resolve()
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@objc func stopVideoRecording(_ call: CAPPluginCall) {
|
|
404
|
+
// If recording was already auto-stopped (e.g. by maxRecordingDuration),
|
|
405
|
+
// return the buffered result immediately.
|
|
406
|
+
if let buffered = autoStoppedVideoResult {
|
|
407
|
+
autoStoppedVideoResult = nil
|
|
408
|
+
call.resolve(buffered)
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
guard let movieOutput = movieOutput else {
|
|
413
|
+
call.reject("Video output not initialized")
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
guard movieOutput.isRecording else {
|
|
417
|
+
call.reject("No active video recording to stop")
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
pendingVideoStopCall = call
|
|
422
|
+
movieOutput.stopRecording()
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private func handleVideoRecordingFinished(outputURL: URL, error: Error?) {
|
|
426
|
+
// Release the mic as soon as recording ends so the preview session is no
|
|
427
|
+
// longer interruptible by an incoming phone call.
|
|
428
|
+
removeAudioInput()
|
|
429
|
+
|
|
430
|
+
if let device = currentInput?.device, device.hasTorch, device.torchMode != .off {
|
|
431
|
+
do {
|
|
432
|
+
try device.lockForConfiguration()
|
|
433
|
+
device.torchMode = .off
|
|
434
|
+
device.unlockForConfiguration()
|
|
435
|
+
} catch { /* best effort */ }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
defer { videoCaptureDelegate = nil }
|
|
439
|
+
|
|
440
|
+
// When maxRecordedDuration is reached, AVFoundation reports
|
|
441
|
+
// AVError.maximumDurationReached. The video file is still valid,
|
|
442
|
+
// so we treat it as a successful recording.
|
|
443
|
+
if let error = error {
|
|
444
|
+
let nsError = error as NSError
|
|
445
|
+
let isDurationLimit = nsError.domain == AVFoundationErrorDomain
|
|
446
|
+
&& nsError.code == AVError.maximumDurationReached.rawValue
|
|
447
|
+
if !isDurationLimit {
|
|
448
|
+
if let call = pendingVideoStopCall {
|
|
449
|
+
pendingVideoStopCall = nil
|
|
450
|
+
call.reject("Video recording failed: \(error.localizedDescription)")
|
|
451
|
+
}
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let thumbnail = generateVideoThumbnail(from: outputURL, size: 200) ?? ""
|
|
457
|
+
let duration = getVideoDuration(from: outputURL)
|
|
458
|
+
|
|
459
|
+
if enableSaving {
|
|
460
|
+
saveVideoToGallery(fileURL: outputURL)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let result: [String: Any] = [
|
|
464
|
+
"value": [
|
|
465
|
+
"uri": outputURL.absoluteString,
|
|
466
|
+
"thumbnail": thumbnail,
|
|
467
|
+
"duration": duration
|
|
468
|
+
]
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
if let call = pendingVideoStopCall {
|
|
472
|
+
pendingVideoStopCall = nil
|
|
473
|
+
call.resolve(result)
|
|
474
|
+
} else {
|
|
475
|
+
// Recording was auto-stopped by maxRecordedDuration.
|
|
476
|
+
// Buffer the result for the next stopVideoRecording call.
|
|
477
|
+
autoStoppedVideoResult = result
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private func saveVideoToGallery(fileURL: URL) {
|
|
482
|
+
PHPhotoLibrary.shared().performChanges({
|
|
483
|
+
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
|
|
484
|
+
}) { success, error in
|
|
485
|
+
if let error = error {
|
|
486
|
+
print("[CameraMultiCapture] Failed to save video to gallery: \(error.localizedDescription)")
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
@objc func setZoom(_ call: CAPPluginCall) {
|
|
492
|
+
guard let input = currentInput else {
|
|
493
|
+
call.reject("Camera not initialized")
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
let userZoom = CGFloat(call.getFloat("zoom") ?? 1.0)
|
|
497
|
+
// Convert user-facing zoom value back to API scale
|
|
498
|
+
let zoomFactor = userZoom * zoomScaleDivisor
|
|
499
|
+
do {
|
|
500
|
+
try input.device.lockForConfiguration()
|
|
501
|
+
// Clamp zoom factor to valid range
|
|
502
|
+
let minZoom = input.device.minAvailableVideoZoomFactor
|
|
503
|
+
let maxZoom = input.device.activeFormat.videoMaxZoomFactor
|
|
504
|
+
input.device.videoZoomFactor = max(minZoom, min(zoomFactor, maxZoom))
|
|
505
|
+
input.device.unlockForConfiguration()
|
|
506
|
+
// Return the user-facing zoom value
|
|
507
|
+
call.resolve(["zoom": Float(input.device.videoZoomFactor / zoomScaleDivisor)])
|
|
508
|
+
} catch {
|
|
509
|
+
call.reject("Failed to set zoom: \(error.localizedDescription)")
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
@objc func getAvailableZoomLevels(_ call: CAPPluginCall) {
|
|
514
|
+
guard let input = currentInput else {
|
|
515
|
+
call.reject("Camera not initialized")
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
let device = input.device
|
|
520
|
+
// Convert API zoom range to user-facing values
|
|
521
|
+
let minZoom = device.minAvailableVideoZoomFactor / zoomScaleDivisor
|
|
522
|
+
let maxZoom = device.activeFormat.videoMaxZoomFactor / zoomScaleDivisor
|
|
523
|
+
|
|
524
|
+
// Generate suggested preset levels based on device capabilities
|
|
525
|
+
var presetLevels: [Float] = []
|
|
526
|
+
|
|
527
|
+
// Add ultra-wide if available (0.5x or 0.7x depending on device)
|
|
528
|
+
let userMin = Float(minZoom)
|
|
529
|
+
if userMin < 1.0 {
|
|
530
|
+
let ultraWide: Float
|
|
531
|
+
if userMin > 0.6 && userMin < 0.8 {
|
|
532
|
+
ultraWide = 0.7
|
|
533
|
+
} else if userMin < 0.6 {
|
|
534
|
+
ultraWide = 0.5
|
|
535
|
+
} else {
|
|
536
|
+
ultraWide = userMin
|
|
537
|
+
}
|
|
538
|
+
presetLevels.append(ultraWide)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Always add 1x
|
|
542
|
+
presetLevels.append(1.0)
|
|
543
|
+
|
|
544
|
+
// Add telephoto presets based on max zoom
|
|
545
|
+
if maxZoom >= 2.0 {
|
|
546
|
+
presetLevels.append(2.0)
|
|
547
|
+
}
|
|
548
|
+
if maxZoom >= 3.0 {
|
|
549
|
+
presetLevels.append(3.0)
|
|
550
|
+
}
|
|
551
|
+
if maxZoom >= 5.0 {
|
|
552
|
+
presetLevels.append(5.0)
|
|
553
|
+
}
|
|
554
|
+
if maxZoom >= 10.0 {
|
|
555
|
+
presetLevels.append(10.0)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
call.resolve([
|
|
559
|
+
"minZoom": Float(minZoom),
|
|
560
|
+
"maxZoom": Float(maxZoom),
|
|
561
|
+
"presetLevels": presetLevels
|
|
562
|
+
])
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
@objc func switchCamera(_ call: CAPPluginCall) {
|
|
566
|
+
guard let session = captureSession,
|
|
567
|
+
let currentInput = currentInput
|
|
568
|
+
else {
|
|
569
|
+
call.reject("Camera not initialized")
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if movieOutput?.isRecording == true {
|
|
574
|
+
call.reject("Cannot switch camera while recording")
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Turn off torch on the current device before switching cameras
|
|
579
|
+
let device = currentInput.device
|
|
580
|
+
if device.hasTorch && device.torchMode == .on {
|
|
581
|
+
do {
|
|
582
|
+
try device.lockForConfiguration()
|
|
583
|
+
device.torchMode = .off
|
|
584
|
+
device.unlockForConfiguration()
|
|
585
|
+
} catch {
|
|
586
|
+
// Ignore failure here; we are switching cameras anyway
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
session.beginConfiguration()
|
|
591
|
+
session.removeInput(currentInput)
|
|
592
|
+
self.cameraPosition = (self.cameraPosition == .back) ? .front : .back
|
|
593
|
+
|
|
594
|
+
do {
|
|
595
|
+
// Re-select a virtual device for the back camera to preserve
|
|
596
|
+
// seamless zoom across all physical lenses
|
|
597
|
+
var newDevice: AVCaptureDevice? = nil
|
|
598
|
+
if self.cameraPosition == .back {
|
|
599
|
+
let virtualDeviceTypes: [AVCaptureDevice.DeviceType] = [
|
|
600
|
+
.builtInTripleCamera,
|
|
601
|
+
.builtInDualWideCamera,
|
|
602
|
+
.builtInDualCamera
|
|
603
|
+
]
|
|
604
|
+
for deviceType in virtualDeviceTypes {
|
|
605
|
+
if let device = AVCaptureDevice.default(deviceType, for: .video, position: .back) {
|
|
606
|
+
newDevice = device
|
|
607
|
+
self.isUsingVirtualDevice = true
|
|
608
|
+
// Recompute zoom scale divisor for the new device
|
|
609
|
+
let switchOvers = device.virtualDeviceSwitchOverVideoZoomFactors
|
|
610
|
+
if switchOvers.count > 0 && device.minAvailableVideoZoomFactor >= 1.0 {
|
|
611
|
+
self.zoomScaleDivisor = CGFloat(switchOvers[0].floatValue)
|
|
612
|
+
} else {
|
|
613
|
+
self.zoomScaleDivisor = 1.0
|
|
614
|
+
}
|
|
615
|
+
break
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Fallback to wide-angle (also used for front camera)
|
|
621
|
+
if newDevice == nil {
|
|
622
|
+
newDevice = AVCaptureDevice.default(
|
|
623
|
+
.builtInWideAngleCamera, for: .video, position: self.cameraPosition)
|
|
624
|
+
self.isUsingVirtualDevice = false
|
|
625
|
+
self.zoomScaleDivisor = 1.0
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
guard let device = newDevice else {
|
|
629
|
+
session.commitConfiguration()
|
|
630
|
+
call.reject("Desired camera not available")
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
let newInput = try AVCaptureDeviceInput(device: device)
|
|
635
|
+
if session.canAddInput(newInput) {
|
|
636
|
+
session.addInput(newInput)
|
|
637
|
+
self.currentInput = newInput
|
|
638
|
+
session.commitConfiguration()
|
|
639
|
+
call.resolve()
|
|
640
|
+
} else {
|
|
641
|
+
session.commitConfiguration()
|
|
642
|
+
call.reject("Cannot add new camera input")
|
|
643
|
+
}
|
|
644
|
+
} catch {
|
|
645
|
+
session.commitConfiguration()
|
|
646
|
+
call.reject("Switch camera error: \(error.localizedDescription)")
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
@objc func getAvailableCameras(_ call: CAPPluginCall) {
|
|
651
|
+
// Check for available cameras on the current position
|
|
652
|
+
let hasUltrawide = AVCaptureDevice.default(
|
|
653
|
+
.builtInUltraWideCamera, for: .video, position: cameraPosition
|
|
654
|
+
) != nil
|
|
655
|
+
|
|
656
|
+
let hasWide = AVCaptureDevice.default(
|
|
657
|
+
.builtInWideAngleCamera, for: .video, position: cameraPosition
|
|
658
|
+
) != nil
|
|
659
|
+
|
|
660
|
+
let hasTelephoto = AVCaptureDevice.default(
|
|
661
|
+
.builtInTelephotoCamera, for: .video, position: cameraPosition
|
|
662
|
+
) != nil
|
|
663
|
+
|
|
664
|
+
var result = [String: Any]()
|
|
665
|
+
result["hasUltrawide"] = hasUltrawide
|
|
666
|
+
result["hasWide"] = hasWide
|
|
667
|
+
result["hasTelephoto"] = hasTelephoto
|
|
668
|
+
|
|
669
|
+
// Derive user-facing zoom factors from the virtual device's switch-over points.
|
|
670
|
+
// On triple-camera devices (e.g. iPhone 15 Pro), the API zoom scale is:
|
|
671
|
+
// 1.0 = ultrawide, switchOverFactors = [2, 6]
|
|
672
|
+
// meaning UW→W at API 2.0, W→Tele at API 6.0
|
|
673
|
+
// Apple's Camera app normalizes by dividing by the first switchover
|
|
674
|
+
// so users see 0.5x, 1x, 3x. We do the same.
|
|
675
|
+
if let device = currentInput?.device,
|
|
676
|
+
isUsingVirtualDevice {
|
|
677
|
+
let switchOverFactors = device.virtualDeviceSwitchOverVideoZoomFactors
|
|
678
|
+
|
|
679
|
+
// The first switchover factor is the API zoom value for the wide lens ("1x").
|
|
680
|
+
// We divide all API values by this to get user-facing labels.
|
|
681
|
+
if hasUltrawide && switchOverFactors.count > 0 {
|
|
682
|
+
let wideApiZoom = CGFloat(switchOverFactors[0].floatValue)
|
|
683
|
+
self.zoomScaleDivisor = wideApiZoom
|
|
684
|
+
|
|
685
|
+
let minApiZoom = device.minAvailableVideoZoomFactor
|
|
686
|
+
result["ultrawideZoomFactor"] = Float(minApiZoom / wideApiZoom)
|
|
687
|
+
} else if hasUltrawide {
|
|
688
|
+
self.zoomScaleDivisor = 1.0
|
|
689
|
+
result["ultrawideZoomFactor"] = 0.5
|
|
690
|
+
} else {
|
|
691
|
+
self.zoomScaleDivisor = 1.0
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
result["wideZoomFactor"] = 1.0
|
|
695
|
+
|
|
696
|
+
if hasTelephoto && switchOverFactors.count > 0 {
|
|
697
|
+
let teleApiZoom = CGFloat(switchOverFactors.last!.floatValue)
|
|
698
|
+
result["telephotoZoomFactor"] = Float(teleApiZoom / self.zoomScaleDivisor)
|
|
699
|
+
} else if hasTelephoto {
|
|
700
|
+
result["telephotoZoomFactor"] = 2.0
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
self.zoomScaleDivisor = 1.0
|
|
704
|
+
// Fallback for non-virtual device sessions
|
|
705
|
+
if hasUltrawide {
|
|
706
|
+
result["ultrawideZoomFactor"] = 0.5
|
|
707
|
+
}
|
|
708
|
+
result["wideZoomFactor"] = 1.0
|
|
709
|
+
if hasTelephoto {
|
|
710
|
+
result["telephotoZoomFactor"] = 2.0
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
call.resolve(result)
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
@objc func switchToPhysicalCamera(_ call: CAPPluginCall) {
|
|
718
|
+
guard let input = currentInput,
|
|
719
|
+
let userZoomFactor = call.getFloat("zoomFactor") else {
|
|
720
|
+
call.reject("Camera not initialized or missing zoomFactor parameter")
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// When using a virtual device, simply set the zoom factor.
|
|
725
|
+
// iOS automatically activates the correct physical camera at the right
|
|
726
|
+
// switch-over points. No need to swap device inputs.
|
|
727
|
+
if isUsingVirtualDevice {
|
|
728
|
+
do {
|
|
729
|
+
let device = input.device
|
|
730
|
+
// Convert user-facing zoom value back to API scale
|
|
731
|
+
let apiZoom = CGFloat(userZoomFactor) * zoomScaleDivisor
|
|
732
|
+
try device.lockForConfiguration()
|
|
733
|
+
let clampedZoom = max(
|
|
734
|
+
device.minAvailableVideoZoomFactor,
|
|
735
|
+
min(apiZoom, device.activeFormat.videoMaxZoomFactor)
|
|
736
|
+
)
|
|
737
|
+
device.videoZoomFactor = clampedZoom
|
|
738
|
+
device.unlockForConfiguration()
|
|
739
|
+
// Return user-facing zoom value
|
|
740
|
+
call.resolve(["zoom": Float(clampedZoom / zoomScaleDivisor)])
|
|
741
|
+
} catch {
|
|
742
|
+
call.reject("Failed to set zoom: \(error.localizedDescription)")
|
|
743
|
+
}
|
|
744
|
+
return
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if movieOutput?.isRecording == true {
|
|
748
|
+
call.reject("Cannot switch camera while recording")
|
|
749
|
+
return
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Fallback: manually switch physical cameras for non-virtual device sessions
|
|
753
|
+
guard let session = captureSession else {
|
|
754
|
+
call.reject("Camera session not available")
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
let deviceType: AVCaptureDevice.DeviceType
|
|
759
|
+
if userZoomFactor < 1.0 {
|
|
760
|
+
deviceType = .builtInUltraWideCamera
|
|
761
|
+
} else if userZoomFactor >= 2.0 {
|
|
762
|
+
deviceType = .builtInTelephotoCamera
|
|
763
|
+
} else {
|
|
764
|
+
deviceType = .builtInWideAngleCamera
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
guard let newDevice = AVCaptureDevice.default(
|
|
768
|
+
deviceType, for: .video, position: cameraPosition
|
|
769
|
+
) else {
|
|
770
|
+
guard let fallbackDevice = AVCaptureDevice.default(
|
|
771
|
+
.builtInWideAngleCamera, for: .video, position: cameraPosition
|
|
772
|
+
) else {
|
|
773
|
+
call.reject("No suitable camera available")
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
switchToDevice(fallbackDevice, in: session, call: call)
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
switchToDevice(newDevice, in: session, call: call)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private func switchToDevice(_ device: AVCaptureDevice, in session: AVCaptureSession, call: CAPPluginCall) {
|
|
784
|
+
session.beginConfiguration()
|
|
785
|
+
|
|
786
|
+
// Remove current input
|
|
787
|
+
if let currentInput = currentInput {
|
|
788
|
+
session.removeInput(currentInput)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
do {
|
|
792
|
+
let newInput = try AVCaptureDeviceInput(device: device)
|
|
793
|
+
if session.canAddInput(newInput) {
|
|
794
|
+
session.addInput(newInput)
|
|
795
|
+
self.currentInput = newInput
|
|
796
|
+
|
|
797
|
+
session.commitConfiguration()
|
|
798
|
+
call.resolve()
|
|
799
|
+
} else {
|
|
800
|
+
session.commitConfiguration()
|
|
801
|
+
call.reject("Cannot add new camera input")
|
|
802
|
+
}
|
|
803
|
+
} catch {
|
|
804
|
+
session.commitConfiguration()
|
|
805
|
+
call.reject("Failed to switch camera: \(error.localizedDescription)")
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
@objc func updatePreviewRect(_ call: CAPPluginCall) {
|
|
810
|
+
guard let previewRect = call.getObject("previewRect") ?? (call.options as? JSObject) else {
|
|
811
|
+
call.reject("Missing previewRect")
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
let x = CGFloat((previewRect["x"] as? NSNumber)?.floatValue ?? 0)
|
|
816
|
+
let y = CGFloat((previewRect["y"] as? NSNumber)?.floatValue ?? 0)
|
|
817
|
+
let width = CGFloat(
|
|
818
|
+
(previewRect["width"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.width))
|
|
819
|
+
let height = CGFloat(
|
|
820
|
+
(previewRect["height"] as? NSNumber)?.floatValue ?? Float(UIScreen.main.bounds.height))
|
|
821
|
+
|
|
822
|
+
DispatchQueue.main.async {
|
|
823
|
+
if let previewView = self.cameraPreviewView, let videoLayer = self.previewLayer {
|
|
824
|
+
previewView.frame = CGRect(x: x, y: y, width: width, height: height)
|
|
825
|
+
videoLayer.frame = previewView.bounds
|
|
826
|
+
|
|
827
|
+
if call.getInt("rotation") == nil {
|
|
828
|
+
let newOrientation = self.detectCurrentOrientation()
|
|
829
|
+
videoLayer.connection?.videoOrientation = newOrientation
|
|
830
|
+
self.currentOrientation = newOrientation
|
|
831
|
+
} else {
|
|
832
|
+
let rotation = call.getInt("rotation") ?? 0
|
|
833
|
+
let newOrientation: AVCaptureVideoOrientation
|
|
834
|
+
switch rotation {
|
|
835
|
+
case 90: newOrientation = .landscapeRight
|
|
836
|
+
case 180: newOrientation = .portraitUpsideDown
|
|
837
|
+
case 270: newOrientation = .landscapeLeft
|
|
838
|
+
default: newOrientation = .portrait
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
videoLayer.connection?.videoOrientation = newOrientation
|
|
842
|
+
self.currentOrientation = newOrientation
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
call.resolve()
|
|
846
|
+
} else {
|
|
847
|
+
call.reject("Preview view not initialized")
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
func configureSession(with config: CameraConfig, call: CAPPluginCall) throws {
|
|
853
|
+
let session = AVCaptureSession()
|
|
854
|
+
session.beginConfiguration()
|
|
855
|
+
session.sessionPreset = config.quality
|
|
856
|
+
|
|
857
|
+
// Prefer virtual multi-camera device for seamless zoom across all lenses.
|
|
858
|
+
// Virtual devices expose a single continuous zoom scale where iOS
|
|
859
|
+
// automatically switches physical cameras at the correct switch-over points.
|
|
860
|
+
let camera: AVCaptureDevice
|
|
861
|
+
let virtualDeviceTypes: [AVCaptureDevice.DeviceType] = [
|
|
862
|
+
.builtInTripleCamera,
|
|
863
|
+
.builtInDualWideCamera,
|
|
864
|
+
.builtInDualCamera
|
|
865
|
+
]
|
|
866
|
+
var foundVirtualDevice: AVCaptureDevice? = nil
|
|
867
|
+
for deviceType in virtualDeviceTypes {
|
|
868
|
+
if let device = AVCaptureDevice.default(deviceType, for: .video, position: config.cameraPosition) {
|
|
869
|
+
foundVirtualDevice = device
|
|
870
|
+
break
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if let virtualDevice = foundVirtualDevice {
|
|
874
|
+
camera = virtualDevice
|
|
875
|
+
self.isUsingVirtualDevice = true
|
|
876
|
+
// Compute zoom scale divisor from switchover factors
|
|
877
|
+
let switchOvers = virtualDevice.virtualDeviceSwitchOverVideoZoomFactors
|
|
878
|
+
if switchOvers.count > 0 && virtualDevice.minAvailableVideoZoomFactor >= 1.0 {
|
|
879
|
+
// First switchover = the "1x" wide lens in API zoom scale
|
|
880
|
+
self.zoomScaleDivisor = CGFloat(switchOvers[0].floatValue)
|
|
881
|
+
} else {
|
|
882
|
+
self.zoomScaleDivisor = 1.0
|
|
883
|
+
}
|
|
884
|
+
} else if let wideCamera = AVCaptureDevice.default(
|
|
885
|
+
.builtInWideAngleCamera, for: .video, position: config.cameraPosition) {
|
|
886
|
+
camera = wideCamera
|
|
887
|
+
self.isUsingVirtualDevice = false
|
|
888
|
+
} else {
|
|
889
|
+
throw NSError(
|
|
890
|
+
domain: "Camera", code: 0,
|
|
891
|
+
userInfo: [NSLocalizedDescriptionKey: "Camera not available"])
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let input = try AVCaptureDeviceInput(device: camera)
|
|
895
|
+
if session.canAddInput(input) {
|
|
896
|
+
session.addInput(input)
|
|
897
|
+
self.currentInput = input
|
|
898
|
+
// Audio input is added lazily in startVideoRecordingInternal, not here.
|
|
899
|
+
// A session that holds the microphone gets fully interrupted by iOS when
|
|
900
|
+
// a phone call takes the audio hardware (InterruptionReason
|
|
901
|
+
// .audioDeviceInUseByAnotherClient), which freezes the video preview.
|
|
902
|
+
// Photo capture / preview never need audio, so we keep the mic out of the
|
|
903
|
+
// session except while recording video.
|
|
904
|
+
} else {
|
|
905
|
+
throw NSError(
|
|
906
|
+
domain: "Camera", code: 0,
|
|
907
|
+
userInfo: [NSLocalizedDescriptionKey: "Unable to add camera input"])
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
let photoOut = AVCapturePhotoOutput()
|
|
911
|
+
if session.canAddOutput(photoOut) {
|
|
912
|
+
session.addOutput(photoOut)
|
|
913
|
+
photoOut.isHighResolutionCaptureEnabled = true
|
|
914
|
+
self.photoOutput = photoOut
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
let movieOut = AVCaptureMovieFileOutput()
|
|
918
|
+
if session.canAddOutput(movieOut) {
|
|
919
|
+
session.addOutput(movieOut)
|
|
920
|
+
self.movieOutput = movieOut
|
|
921
|
+
}
|
|
922
|
+
session.commitConfiguration()
|
|
923
|
+
self.captureSession = session
|
|
924
|
+
|
|
925
|
+
do {
|
|
926
|
+
try camera.lockForConfiguration()
|
|
927
|
+
// Convert user-facing zoom to API scale
|
|
928
|
+
camera.videoZoomFactor = config.zoom * zoomScaleDivisor
|
|
929
|
+
if config.autoFocus && camera.isFocusModeSupported(.continuousAutoFocus) {
|
|
930
|
+
camera.focusMode = .continuousAutoFocus
|
|
931
|
+
} else if camera.isFocusModeSupported(.locked) {
|
|
932
|
+
camera.focusMode = .locked
|
|
933
|
+
}
|
|
934
|
+
camera.unlockForConfiguration()
|
|
935
|
+
} catch {
|
|
936
|
+
print("Focus/zoom config error: \(error)")
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
DispatchQueue.main.async {
|
|
940
|
+
guard let webView = self.bridge?.webView else {
|
|
941
|
+
return
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
let previewView = UIView(
|
|
945
|
+
frame: CGRect(x: 0, y: 0, width: config.width, height: config.height))
|
|
946
|
+
|
|
947
|
+
webView.isOpaque = false
|
|
948
|
+
webView.backgroundColor = UIColor.clear
|
|
949
|
+
webView.scrollView.backgroundColor = UIColor.clear
|
|
950
|
+
webView.layer.backgroundColor = UIColor.clear.cgColor
|
|
951
|
+
|
|
952
|
+
let javascript = "document.documentElement.style.backgroundColor = 'transparent'"
|
|
953
|
+
webView.evaluateJavaScript(javascript) { (_, error) in
|
|
954
|
+
if let error = error {
|
|
955
|
+
print("[CameraMultiCapture] JS evaluation error: \(error)")
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Add camera preview to webView's scrollView and send to back
|
|
960
|
+
// This ensures camera is behind the web content
|
|
961
|
+
webView.scrollView.addSubview(previewView)
|
|
962
|
+
webView.scrollView.sendSubviewToBack(previewView)
|
|
963
|
+
|
|
964
|
+
// Position the preview view correctly within the scrollView
|
|
965
|
+
previewView.frame = CGRect(x: config.x, y: config.y, width: config.width, height: config.height)
|
|
966
|
+
|
|
967
|
+
let videoLayer = AVCaptureVideoPreviewLayer(session: session)
|
|
968
|
+
videoLayer.frame = previewView.bounds
|
|
969
|
+
videoLayer.videoGravity = .resizeAspectFill
|
|
970
|
+
videoLayer.connection?.videoOrientation = config.orientation
|
|
971
|
+
previewView.layer.addSublayer(videoLayer)
|
|
972
|
+
self.previewLayer = videoLayer
|
|
973
|
+
self.cameraPreviewView = previewView
|
|
974
|
+
|
|
975
|
+
if let parentView = webView.superview {
|
|
976
|
+
parentView.bringSubviewToFront(webView)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
980
|
+
self.captureSession?.startRunning()
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
@objc public override func checkPermissions(_ call: CAPPluginCall) {
|
|
986
|
+
let cameraAuthStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
987
|
+
let photosAuthStatus = PHPhotoLibrary.authorizationStatus()
|
|
988
|
+
let audioAuthStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
989
|
+
|
|
990
|
+
let cameraPermission: String
|
|
991
|
+
switch cameraAuthStatus {
|
|
992
|
+
case .authorized:
|
|
993
|
+
cameraPermission = "granted"
|
|
994
|
+
case .denied, .restricted:
|
|
995
|
+
cameraPermission = "denied"
|
|
996
|
+
case .notDetermined:
|
|
997
|
+
cameraPermission = "prompt"
|
|
998
|
+
@unknown default:
|
|
999
|
+
cameraPermission = "prompt"
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
let photosPermission: String
|
|
1003
|
+
switch photosAuthStatus {
|
|
1004
|
+
case .authorized, .limited:
|
|
1005
|
+
photosPermission = "granted"
|
|
1006
|
+
case .denied, .restricted:
|
|
1007
|
+
photosPermission = "denied"
|
|
1008
|
+
case .notDetermined:
|
|
1009
|
+
photosPermission = "prompt"
|
|
1010
|
+
@unknown default:
|
|
1011
|
+
photosPermission = "prompt"
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
let audioPermission: String
|
|
1015
|
+
switch audioAuthStatus {
|
|
1016
|
+
case .authorized:
|
|
1017
|
+
audioPermission = "granted"
|
|
1018
|
+
case .denied, .restricted:
|
|
1019
|
+
audioPermission = "denied"
|
|
1020
|
+
case .notDetermined:
|
|
1021
|
+
audioPermission = "prompt"
|
|
1022
|
+
@unknown default:
|
|
1023
|
+
audioPermission = "prompt"
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
call.resolve([
|
|
1027
|
+
"camera": cameraPermission,
|
|
1028
|
+
"photos": photosPermission,
|
|
1029
|
+
"audio": audioPermission
|
|
1030
|
+
])
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
@objc public override func requestPermissions(_ call: CAPPluginCall) {
|
|
1034
|
+
let group = DispatchGroup()
|
|
1035
|
+
var cameraResult = "prompt"
|
|
1036
|
+
var photosResult = "prompt"
|
|
1037
|
+
var audioResult = "prompt"
|
|
1038
|
+
|
|
1039
|
+
// Request camera permission
|
|
1040
|
+
group.enter()
|
|
1041
|
+
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
1042
|
+
cameraResult = granted ? "granted" : "denied"
|
|
1043
|
+
group.leave()
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Request photos permission
|
|
1047
|
+
group.enter()
|
|
1048
|
+
PHPhotoLibrary.requestAuthorization { status in
|
|
1049
|
+
switch status {
|
|
1050
|
+
case .authorized, .limited:
|
|
1051
|
+
photosResult = "granted"
|
|
1052
|
+
case .denied, .restricted:
|
|
1053
|
+
photosResult = "denied"
|
|
1054
|
+
case .notDetermined:
|
|
1055
|
+
photosResult = "prompt"
|
|
1056
|
+
@unknown default:
|
|
1057
|
+
photosResult = "denied"
|
|
1058
|
+
}
|
|
1059
|
+
group.leave()
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// Request microphone permission
|
|
1063
|
+
group.enter()
|
|
1064
|
+
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
1065
|
+
audioResult = granted ? "granted" : "denied"
|
|
1066
|
+
group.leave()
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Wait for both permissions and return result
|
|
1070
|
+
group.notify(queue: .main) {
|
|
1071
|
+
call.resolve([
|
|
1072
|
+
"camera": cameraResult,
|
|
1073
|
+
"photos": photosResult,
|
|
1074
|
+
"audio": audioResult
|
|
1075
|
+
])
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
@objc func setFlash(_ call: CAPPluginCall) {
|
|
1080
|
+
guard let modeString = call.getString("flashMode") else {
|
|
1081
|
+
call.reject("Missing flash mode parameter")
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
let flashMode: AVCaptureDevice.FlashMode
|
|
1086
|
+
switch modeString {
|
|
1087
|
+
case "on": flashMode = .on
|
|
1088
|
+
case "auto": flashMode = .auto
|
|
1089
|
+
default: flashMode = .off
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// Check if the current device supports flash
|
|
1093
|
+
guard let device = currentInput?.device, device.hasFlash else {
|
|
1094
|
+
call.reject("Flash not available on current camera")
|
|
1095
|
+
return
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Update the current flash mode
|
|
1099
|
+
currentFlashMode = flashMode
|
|
1100
|
+
|
|
1101
|
+
call.resolve(["flashMode": modeString])
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
@objc func getFlash(_ call: CAPPluginCall) {
|
|
1105
|
+
let modeString: String
|
|
1106
|
+
switch currentFlashMode {
|
|
1107
|
+
case .on: modeString = "on"
|
|
1108
|
+
case .auto: modeString = "auto"
|
|
1109
|
+
default: modeString = "off"
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
call.resolve(["flashMode": modeString])
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
@objc func setTorch(_ call: CAPPluginCall) {
|
|
1116
|
+
guard let enabled = call.getBool("enabled") else {
|
|
1117
|
+
call.reject("Missing enabled parameter")
|
|
1118
|
+
return
|
|
1119
|
+
}
|
|
1120
|
+
guard let device = currentInput?.device, device.hasTorch, device.isTorchAvailable else {
|
|
1121
|
+
call.reject("Torch not available on current camera")
|
|
1122
|
+
return
|
|
1123
|
+
}
|
|
1124
|
+
do {
|
|
1125
|
+
try device.lockForConfiguration()
|
|
1126
|
+
device.torchMode = enabled ? .on : .off
|
|
1127
|
+
device.unlockForConfiguration()
|
|
1128
|
+
call.resolve()
|
|
1129
|
+
} catch {
|
|
1130
|
+
call.reject("Failed to set torch: \(error.localizedDescription)")
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
@objc func getTorch(_ call: CAPPluginCall) {
|
|
1135
|
+
guard let device = currentInput?.device, device.hasTorch else {
|
|
1136
|
+
call.resolve(["enabled": false])
|
|
1137
|
+
return
|
|
1138
|
+
}
|
|
1139
|
+
let enabled = device.torchMode == .on
|
|
1140
|
+
call.resolve(["enabled": enabled])
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
@objc func queueBackgroundUpload(_ call: CAPPluginCall) {
|
|
1144
|
+
guard let imageUri = call.getString("imageUri"),
|
|
1145
|
+
let uploadEndpoint = call.getString("uploadEndpoint"),
|
|
1146
|
+
let headers = call.getObject("headers") else {
|
|
1147
|
+
call.reject("Missing required parameters")
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
let formData = call.getObject("formData") ?? [:]
|
|
1152
|
+
let method = call.getString("method") ?? "POST"
|
|
1153
|
+
let deleteAfterUpload = call.getBool("deleteAfterUpload") ?? true // Default: true
|
|
1154
|
+
let jobId = UUID().uuidString
|
|
1155
|
+
|
|
1156
|
+
// Generate unique filename from imageUri or timestamp
|
|
1157
|
+
let uniqueFileName = self.generateUniqueFileName(from: imageUri)
|
|
1158
|
+
|
|
1159
|
+
// Store upload job for background processing
|
|
1160
|
+
let uploadJob: [String: Any] = [
|
|
1161
|
+
"jobId": jobId,
|
|
1162
|
+
"imageUri": imageUri,
|
|
1163
|
+
"uploadEndpoint": uploadEndpoint,
|
|
1164
|
+
"headers": headers,
|
|
1165
|
+
"formData": formData,
|
|
1166
|
+
"method": method,
|
|
1167
|
+
"fileName": uniqueFileName,
|
|
1168
|
+
"deleteAfterUpload": deleteAfterUpload,
|
|
1169
|
+
"status": "pending",
|
|
1170
|
+
"createdAt": Date().timeIntervalSince1970
|
|
1171
|
+
]
|
|
1172
|
+
|
|
1173
|
+
UserDefaults.standard.set(uploadJob, forKey: "uploadJob_\(jobId)")
|
|
1174
|
+
|
|
1175
|
+
// Schedule proper background task
|
|
1176
|
+
self.scheduleBackgroundUpload(jobId: jobId, uploadJob: uploadJob)
|
|
1177
|
+
|
|
1178
|
+
call.resolve(["jobId": jobId])
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
private func generateUniqueFileName(from imageUri: String) -> String {
|
|
1182
|
+
// Extract filename from URI if possible
|
|
1183
|
+
if let url = URL(string: imageUri) {
|
|
1184
|
+
let pathExtension = url.pathExtension.isEmpty ? "jpg" : url.pathExtension
|
|
1185
|
+
let baseName = url.deletingPathExtension().lastPathComponent
|
|
1186
|
+
|
|
1187
|
+
// If we have a meaningful filename, use it with timestamp
|
|
1188
|
+
if !baseName.isEmpty && baseName != "image" {
|
|
1189
|
+
return "\(baseName)_\(Int(Date().timeIntervalSince1970)).\(pathExtension)"
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// Fallback to timestamp-based naming
|
|
1194
|
+
let timestamp = Int(Date().timeIntervalSince1970)
|
|
1195
|
+
let randomId = UUID().uuidString.prefix(8)
|
|
1196
|
+
return "photo_\(timestamp)_\(randomId).jpg"
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private func scheduleBackgroundUpload(jobId: String, uploadJob: [String: Any]) {
|
|
1200
|
+
// iOS 13+ only - use modern BGTaskScheduler
|
|
1201
|
+
if #available(iOS 13.0, *) {
|
|
1202
|
+
let request = BGProcessingTaskRequest(identifier: "com.cameramulticapture.upload")
|
|
1203
|
+
request.requiresNetworkConnectivity = true
|
|
1204
|
+
request.requiresExternalPower = false
|
|
1205
|
+
|
|
1206
|
+
do {
|
|
1207
|
+
try BGTaskScheduler.shared.submit(request)
|
|
1208
|
+
print("✅ Background upload task scheduled: \(jobId)")
|
|
1209
|
+
|
|
1210
|
+
// Start immediate upload attempt
|
|
1211
|
+
self.performHttpUpload(jobId: jobId, uploadJob: uploadJob)
|
|
1212
|
+
} catch {
|
|
1213
|
+
print("❌ Failed to schedule background task: \(error)")
|
|
1214
|
+
// Fallback to immediate upload
|
|
1215
|
+
self.performHttpUpload(jobId: jobId, uploadJob: uploadJob)
|
|
1216
|
+
}
|
|
1217
|
+
} else {
|
|
1218
|
+
// iOS 12 and below - not supported, but still attempt immediate upload
|
|
1219
|
+
print("⚠️ Background uploads require iOS 13+. Attempting immediate upload only.")
|
|
1220
|
+
self.performHttpUpload(jobId: jobId, uploadJob: uploadJob)
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
@objc func getUploadStatus(_ call: CAPPluginCall) {
|
|
1225
|
+
guard let jobId = call.getString("jobId") else {
|
|
1226
|
+
call.reject("Missing jobId parameter")
|
|
1227
|
+
return
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if let uploadJob = UserDefaults.standard.dictionary(forKey: "uploadJob_\(jobId)") {
|
|
1231
|
+
let status = uploadJob["status"] as? String ?? "failed"
|
|
1232
|
+
var result: [String: Any] = ["status": status]
|
|
1233
|
+
|
|
1234
|
+
if let error = uploadJob["error"] as? String {
|
|
1235
|
+
result["error"] = error
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
call.resolve(result)
|
|
1239
|
+
} else {
|
|
1240
|
+
call.resolve(["status": "failed", "error": "Job not found"])
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
@objc func getVersion(_ call: CAPPluginCall) {
|
|
1245
|
+
call.resolve(["version": pluginVersion])
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
private func performHttpUpload(jobId: String, uploadJob: [String: Any]) {
|
|
1249
|
+
guard let imageUri = uploadJob["imageUri"] as? String,
|
|
1250
|
+
let uploadEndpoint = uploadJob["uploadEndpoint"] as? String,
|
|
1251
|
+
let headers = uploadJob["headers"] as? [String: Any],
|
|
1252
|
+
let method = uploadJob["method"] as? String else {
|
|
1253
|
+
updateJobStatus(jobId: jobId, status: "failed", error: "Invalid job data")
|
|
1254
|
+
return
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
updateJobStatus(jobId: jobId, status: "uploading")
|
|
1258
|
+
|
|
1259
|
+
guard let url = URL(string: uploadEndpoint),
|
|
1260
|
+
let imageUrl = URL(string: imageUri) else {
|
|
1261
|
+
updateJobStatus(jobId: jobId, status: "failed", error: "Invalid URLs")
|
|
1262
|
+
return
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// 🚀 Move file I/O to background thread to prevent UI blocking
|
|
1266
|
+
DispatchQueue.global(qos: .background).async {
|
|
1267
|
+
do {
|
|
1268
|
+
let imageData = try Data(contentsOf: imageUrl)
|
|
1269
|
+
|
|
1270
|
+
var request = URLRequest(url: url)
|
|
1271
|
+
request.httpMethod = method
|
|
1272
|
+
request.timeoutInterval = 60
|
|
1273
|
+
|
|
1274
|
+
// Add headers
|
|
1275
|
+
for (key, value) in headers {
|
|
1276
|
+
if let stringValue = value as? String {
|
|
1277
|
+
request.setValue(stringValue, forHTTPHeaderField: key)
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Set body based on method
|
|
1282
|
+
if method.uppercased() == "PUT" {
|
|
1283
|
+
// For PUT requests (Azure, S3), send raw image data
|
|
1284
|
+
request.httpBody = imageData
|
|
1285
|
+
} else {
|
|
1286
|
+
// For POST requests, create multipart form data
|
|
1287
|
+
let boundary = UUID().uuidString
|
|
1288
|
+
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
1289
|
+
|
|
1290
|
+
var body = Data()
|
|
1291
|
+
|
|
1292
|
+
// Add form data fields
|
|
1293
|
+
if let formData = uploadJob["formData"] as? [String: Any] {
|
|
1294
|
+
for (key, value) in formData {
|
|
1295
|
+
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
1296
|
+
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
|
|
1297
|
+
body.append("\(value)\r\n".data(using: .utf8)!)
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Add image file with unique filename
|
|
1302
|
+
let fileName = uploadJob["fileName"] as? String ?? "photo_\(Int(Date().timeIntervalSince1970)).jpg"
|
|
1303
|
+
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
1304
|
+
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
|
|
1305
|
+
body.append("Content-Type: image/jpeg\r\n\r\n".data(using: .utf8)!)
|
|
1306
|
+
body.append(imageData)
|
|
1307
|
+
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
|
|
1308
|
+
|
|
1309
|
+
request.httpBody = body
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// Perform upload
|
|
1313
|
+
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
|
1314
|
+
if let error = error {
|
|
1315
|
+
self.updateJobStatus(jobId: jobId, status: "failed", error: error.localizedDescription)
|
|
1316
|
+
return
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if let httpResponse = response as? HTTPURLResponse {
|
|
1320
|
+
if httpResponse.statusCode >= 200 && httpResponse.statusCode < 300 {
|
|
1321
|
+
self.updateJobStatus(jobId: jobId, status: "completed")
|
|
1322
|
+
|
|
1323
|
+
// 🚀 Clean up file after successful upload (if enabled)
|
|
1324
|
+
if let deleteAfterUpload = uploadJob["deleteAfterUpload"] as? Bool, deleteAfterUpload,
|
|
1325
|
+
let imageUri = uploadJob["imageUri"] as? String,
|
|
1326
|
+
let imageUrl = URL(string: imageUri) {
|
|
1327
|
+
do {
|
|
1328
|
+
try FileManager.default.removeItem(at: imageUrl)
|
|
1329
|
+
print("✅ Cleaned up file after successful upload: \(imageUri)")
|
|
1330
|
+
} catch {
|
|
1331
|
+
print("⚠️ Failed to clean up file: \(error.localizedDescription)")
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Clean up completed job after some time
|
|
1336
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 300) { // 5 minutes
|
|
1337
|
+
UserDefaults.standard.removeObject(forKey: "uploadJob_\(jobId)")
|
|
1338
|
+
}
|
|
1339
|
+
} else {
|
|
1340
|
+
self.updateJobStatus(jobId: jobId, status: "failed", error: "HTTP \(httpResponse.statusCode)")
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
task.resume()
|
|
1346
|
+
|
|
1347
|
+
} catch {
|
|
1348
|
+
self.updateJobStatus(jobId: jobId, status: "failed", error: error.localizedDescription)
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
private func updateJobStatus(jobId: String, status: String, error: String? = nil) {
|
|
1354
|
+
var uploadJob = UserDefaults.standard.dictionary(forKey: "uploadJob_\(jobId)") ?? [:]
|
|
1355
|
+
uploadJob["status"] = status
|
|
1356
|
+
if let error = error {
|
|
1357
|
+
uploadJob["error"] = error
|
|
1358
|
+
}
|
|
1359
|
+
UserDefaults.standard.set(uploadJob, forKey: "uploadJob_\(jobId)")
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// MARK: - Thumbnail Generation
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Generate thumbnail using native iOS APIs
|
|
1366
|
+
* Uses UIImage scaling with UIGraphicsImageRenderer for optimal performance
|
|
1367
|
+
*/
|
|
1368
|
+
func generateThumbnail(from imageData: Data, size: CGFloat) -> String? {
|
|
1369
|
+
guard let originalImage = UIImage(data: imageData) else {
|
|
1370
|
+
return nil
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// Calculate thumbnail size maintaining aspect ratio (center crop)
|
|
1374
|
+
let thumbnailSize = CGSize(width: size, height: size)
|
|
1375
|
+
|
|
1376
|
+
// Use UIGraphicsImageRenderer for efficient thumbnail generation
|
|
1377
|
+
let renderer = UIGraphicsImageRenderer(size: thumbnailSize)
|
|
1378
|
+
|
|
1379
|
+
let thumbnail = renderer.image { context in
|
|
1380
|
+
// Calculate scaling and cropping
|
|
1381
|
+
let originalSize = originalImage.size
|
|
1382
|
+
let scale = max(thumbnailSize.width / originalSize.width, thumbnailSize.height / originalSize.height)
|
|
1383
|
+
|
|
1384
|
+
let scaledSize = CGSize(
|
|
1385
|
+
width: originalSize.width * scale,
|
|
1386
|
+
height: originalSize.height * scale
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
let drawRect = CGRect(
|
|
1390
|
+
x: (thumbnailSize.width - scaledSize.width) / 2,
|
|
1391
|
+
y: (thumbnailSize.height - scaledSize.height) / 2,
|
|
1392
|
+
width: scaledSize.width,
|
|
1393
|
+
height: scaledSize.height
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
originalImage.draw(in: drawRect)
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Convert to JPEG data with compression
|
|
1400
|
+
guard let thumbnailData = thumbnail.jpegData(compressionQuality: 0.85) else {
|
|
1401
|
+
return nil
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// Convert to Base64 data URI
|
|
1405
|
+
let base64String = thumbnailData.base64EncodedString()
|
|
1406
|
+
return "data:image/jpeg;base64,\(base64String)"
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Generate thumbnail image from a video file.
|
|
1411
|
+
*/
|
|
1412
|
+
func generateVideoThumbnail(from videoURL: URL, size: CGFloat) -> String? {
|
|
1413
|
+
let asset = AVAsset(url: videoURL)
|
|
1414
|
+
let imageGenerator = AVAssetImageGenerator(asset: asset)
|
|
1415
|
+
imageGenerator.appliesPreferredTrackTransform = true
|
|
1416
|
+
imageGenerator.maximumSize = CGSize(width: size, height: size)
|
|
1417
|
+
|
|
1418
|
+
do {
|
|
1419
|
+
let cgImage = try imageGenerator.copyCGImage(at: .zero, actualTime: nil)
|
|
1420
|
+
let image = UIImage(cgImage: cgImage)
|
|
1421
|
+
guard let jpegData = image.jpegData(compressionQuality: 0.85) else {
|
|
1422
|
+
return nil
|
|
1423
|
+
}
|
|
1424
|
+
return "data:image/jpeg;base64,\(jpegData.base64EncodedString())"
|
|
1425
|
+
} catch {
|
|
1426
|
+
return nil
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Get video duration in seconds.
|
|
1432
|
+
*/
|
|
1433
|
+
func getVideoDuration(from videoURL: URL) -> Double {
|
|
1434
|
+
let asset = AVAsset(url: videoURL)
|
|
1435
|
+
let duration = asset.duration
|
|
1436
|
+
if duration.isValid && duration.seconds.isFinite {
|
|
1437
|
+
return duration.seconds
|
|
1438
|
+
}
|
|
1439
|
+
return 0
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
class VideoCaptureDelegate: NSObject, AVCaptureFileOutputRecordingDelegate {
|
|
1444
|
+
private let completion: (URL, Error?) -> Void
|
|
1445
|
+
|
|
1446
|
+
init(completion: @escaping (URL, Error?) -> Void) {
|
|
1447
|
+
self.completion = completion
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
func fileOutput(
|
|
1451
|
+
_ output: AVCaptureFileOutput,
|
|
1452
|
+
didFinishRecordingTo outputFileURL: URL,
|
|
1453
|
+
from connections: [AVCaptureConnection],
|
|
1454
|
+
error: Error?
|
|
1455
|
+
) {
|
|
1456
|
+
completion(outputFileURL, error)
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
|
1461
|
+
weak var plugin: CameraMultiCapturePlugin?
|
|
1462
|
+
var call: CAPPluginCall
|
|
1463
|
+
var resultType: String
|
|
1464
|
+
var isFrontCamera: Bool
|
|
1465
|
+
var enableSaving: Bool
|
|
1466
|
+
var galleryAlbumName: String
|
|
1467
|
+
|
|
1468
|
+
init(plugin: CameraMultiCapturePlugin, call: CAPPluginCall, resultType: String, isFrontCamera: Bool, enableSaving: Bool, galleryAlbumName: String) {
|
|
1469
|
+
self.plugin = plugin
|
|
1470
|
+
self.call = call
|
|
1471
|
+
self.resultType = resultType
|
|
1472
|
+
self.isFrontCamera = isFrontCamera
|
|
1473
|
+
self.enableSaving = enableSaving
|
|
1474
|
+
self.galleryAlbumName = galleryAlbumName
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
func photoOutput(
|
|
1478
|
+
_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto,
|
|
1479
|
+
error: Error?
|
|
1480
|
+
) {
|
|
1481
|
+
if let error = error {
|
|
1482
|
+
call.reject("Photo capture error: \(error.localizedDescription)")
|
|
1483
|
+
return
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
guard let data = photo.fileDataRepresentation() else {
|
|
1487
|
+
call.reject("No image data")
|
|
1488
|
+
return
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
1492
|
+
let tempDir = FileManager.default.temporaryDirectory
|
|
1493
|
+
let fileName = UUID().uuidString + ".jpg"
|
|
1494
|
+
let fileURL = tempDir.appendingPathComponent(fileName)
|
|
1495
|
+
|
|
1496
|
+
do {
|
|
1497
|
+
try data.write(to: fileURL)
|
|
1498
|
+
|
|
1499
|
+
if self.enableSaving {
|
|
1500
|
+
self.saveImageToGallery(fileURL: fileURL)
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
var imageData = [String: String]()
|
|
1504
|
+
imageData["uri"] = fileURL.absoluteString
|
|
1505
|
+
|
|
1506
|
+
if let thumbnailDataUri = self.plugin?.generateThumbnail(from: data, size: 200) {
|
|
1507
|
+
imageData["thumbnail"] = thumbnailDataUri
|
|
1508
|
+
} else {
|
|
1509
|
+
imageData["thumbnail"] = ""
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
DispatchQueue.main.async {
|
|
1513
|
+
self.call.resolve(["value": imageData])
|
|
1514
|
+
}
|
|
1515
|
+
} catch {
|
|
1516
|
+
DispatchQueue.main.async {
|
|
1517
|
+
self.call.reject("Failed to process image: \(error.localizedDescription)")
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
private func saveImageToGallery(fileURL: URL) {
|
|
1524
|
+
PHPhotoLibrary.shared().performChanges({
|
|
1525
|
+
PHAssetCreationRequest.forAsset().addResource(with: .photo, fileURL: fileURL, options: nil)
|
|
1526
|
+
}) { success, error in
|
|
1527
|
+
if let error = error {
|
|
1528
|
+
print("[CameraMultiCapture] Failed to save photo to gallery: \(error.localizedDescription)")
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
extension UIImage {
|
|
1535
|
+
func mirrorHorizontally() -> UIImage {
|
|
1536
|
+
UIGraphicsBeginImageContextWithOptions(self.size, false, self.scale)
|
|
1537
|
+
guard let context = UIGraphicsGetCurrentContext() else {
|
|
1538
|
+
return self
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
context.translateBy(x: self.size.width, y: 0)
|
|
1542
|
+
context.scaleBy(x: -1.0, y: 1.0)
|
|
1543
|
+
|
|
1544
|
+
self.draw(in: CGRect(origin: .zero, size: self.size))
|
|
1545
|
+
|
|
1546
|
+
let mirroredImage = UIGraphicsGetImageFromCurrentImageContext()
|
|
1547
|
+
UIGraphicsEndImageContext()
|
|
1548
|
+
|
|
1549
|
+
return mirroredImage ?? self
|
|
1550
|
+
}
|
|
1551
|
+
}
|