capacitor-camera-view 2.2.0 → 2.3.0
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 +196 -10
- package/android/build.gradle +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +309 -3
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +112 -2
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/VideoRecordingQuality.kt +10 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +21 -1
- package/dist/docs.json +200 -8
- package/dist/esm/definitions.d.ts +84 -6
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +20 -4
- package/dist/esm/web.js +157 -16
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +157 -16
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +157 -16
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CameraViewPlugin/CameraError.swift +28 -0
- package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +8 -8
- package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +13 -14
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +302 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +159 -150
- package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +114 -15
- package/ios/Sources/CameraViewPlugin/TempFileManager.swift +68 -34
- package/package.json +1 -1
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
public enum VideoRecordingQuality: String {
|
|
5
|
+
case lowest
|
|
6
|
+
case sd
|
|
7
|
+
case hd
|
|
8
|
+
case fhd
|
|
9
|
+
case uhd
|
|
10
|
+
case highest
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
extension CameraViewManager: AVCaptureFileOutputRecordingDelegate {
|
|
14
|
+
|
|
15
|
+
// MARK: - Public API
|
|
16
|
+
|
|
17
|
+
/// Starts video recording to a temporary file.
|
|
18
|
+
///
|
|
19
|
+
/// - Parameters:
|
|
20
|
+
/// - enableAudio: Whether to include audio in the recording
|
|
21
|
+
/// - videoQuality: Desired video recording quality preset
|
|
22
|
+
/// - completion: Called when recording starts (nil) or fails (error)
|
|
23
|
+
public func startRecording(
|
|
24
|
+
enableAudio: Bool,
|
|
25
|
+
videoQuality: VideoRecordingQuality,
|
|
26
|
+
completion: @escaping (Error?) -> Void
|
|
27
|
+
) {
|
|
28
|
+
sessionQueue.async { [weak self] in
|
|
29
|
+
guard let self = self else { return }
|
|
30
|
+
|
|
31
|
+
guard self.captureSession.isRunning else {
|
|
32
|
+
// Session may be temporarily stopped (e.g. iOS stops the capture session
|
|
33
|
+
// when reconfiguring audio after a microphone permission grant). Wait for
|
|
34
|
+
// it to resume and retry rather than failing immediately.
|
|
35
|
+
self.waitForSessionThenStartRecording(
|
|
36
|
+
enableAudio: enableAudio,
|
|
37
|
+
videoQuality: videoQuality,
|
|
38
|
+
completion: completion
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
guard !self.avMovieOutput.isRecording else {
|
|
44
|
+
DispatchQueue.main.async { completion(CameraError.recordingAlreadyInProgress) }
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
self.captureSession.beginConfiguration()
|
|
49
|
+
self.sessionPresetBeforeRecording = self.captureSession.sessionPreset
|
|
50
|
+
|
|
51
|
+
let recordingPreset = self.resolveRecordingPreset(for: videoQuality)
|
|
52
|
+
if self.captureSession.canSetSessionPreset(recordingPreset) {
|
|
53
|
+
self.captureSession.sessionPreset = recordingPreset
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Add movie output if not already added
|
|
57
|
+
if !self.captureSession.outputs.contains(self.avMovieOutput) {
|
|
58
|
+
guard self.captureSession.canAddOutput(self.avMovieOutput) else {
|
|
59
|
+
self.restoreSessionPreset()
|
|
60
|
+
self.captureSession.commitConfiguration()
|
|
61
|
+
DispatchQueue.main.async {
|
|
62
|
+
completion(CameraError.outputAdditionFailed)
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
self.captureSession.addOutput(self.avMovieOutput)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Add audio input if requested
|
|
70
|
+
if enableAudio {
|
|
71
|
+
do {
|
|
72
|
+
try self.addAudioInput()
|
|
73
|
+
} catch {
|
|
74
|
+
self.restoreSessionPreset()
|
|
75
|
+
self.captureSession.commitConfiguration()
|
|
76
|
+
DispatchQueue.main.async {
|
|
77
|
+
completion(error)
|
|
78
|
+
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
self.captureSession.commitConfiguration()
|
|
84
|
+
|
|
85
|
+
// Set orientation on the movie output connection
|
|
86
|
+
if let connection = self.avMovieOutput.connection(with: .video),
|
|
87
|
+
let previewConnection = self.videoPreviewLayer.connection {
|
|
88
|
+
if connection.isVideoOrientationSupported {
|
|
89
|
+
connection.videoOrientation = previewConnection.videoOrientation
|
|
90
|
+
}
|
|
91
|
+
if connection.isVideoMirroringSupported {
|
|
92
|
+
connection.isVideoMirrored = self.currentCameraDevice?.position == .front
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Create a temp file URL and start recording
|
|
97
|
+
let outputURL = TempFileManager.shared.createTempVideoFile()
|
|
98
|
+
self.avMovieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
|
99
|
+
|
|
100
|
+
DispatchQueue.main.async {
|
|
101
|
+
completion(nil)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/// Stops the current video recording.
|
|
107
|
+
///
|
|
108
|
+
/// - Parameter completion: Called with the output file URL or an error
|
|
109
|
+
public func stopRecording(completion: @escaping (URL?, Error?) -> Void) {
|
|
110
|
+
// The entire body runs on sessionQueue so that the isRecording
|
|
111
|
+
// check and the handler assignment are serialised with startRecording
|
|
112
|
+
// and with the delegate callback that reads the handler.
|
|
113
|
+
sessionQueue.async { [weak self] in
|
|
114
|
+
guard let self = self else { return }
|
|
115
|
+
|
|
116
|
+
guard self.avMovieOutput.isRecording else {
|
|
117
|
+
DispatchQueue.main.async { completion(nil, CameraError.noRecordingInProgress) }
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
self.videoRecordingCompletionHandler = completion
|
|
122
|
+
self.avMovieOutput.stopRecording()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// MARK: - AVCaptureFileOutputRecordingDelegate
|
|
127
|
+
|
|
128
|
+
public func fileOutput(
|
|
129
|
+
_ output: AVCaptureFileOutput,
|
|
130
|
+
didFinishRecordingTo outputFileURL: URL,
|
|
131
|
+
from connections: [AVCaptureConnection],
|
|
132
|
+
error: Error?
|
|
133
|
+
) {
|
|
134
|
+
// AVFoundation may invoke this delegate on an arbitrary thread.
|
|
135
|
+
// Use sessionQueue.sync to read and clear the handler and the
|
|
136
|
+
// recordingWithAudio flag while holding the same serial queue that
|
|
137
|
+
// stopRecording uses to write them, preventing a data race.
|
|
138
|
+
var handler: ((URL?, Error?) -> Void)?
|
|
139
|
+
var hadAudio = false
|
|
140
|
+
|
|
141
|
+
sessionQueue.sync { [weak self] in
|
|
142
|
+
guard let self = self else { return }
|
|
143
|
+
handler = self.videoRecordingCompletionHandler
|
|
144
|
+
self.videoRecordingCompletionHandler = nil
|
|
145
|
+
hadAudio = self.recordingWithAudio
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Remove movie output (and audio input if it was added) from session.
|
|
149
|
+
sessionQueue.async { [weak self] in
|
|
150
|
+
guard let self = self else { return }
|
|
151
|
+
self.captureSession.beginConfiguration()
|
|
152
|
+
if hadAudio {
|
|
153
|
+
self.removeAudioInput()
|
|
154
|
+
}
|
|
155
|
+
self.captureSession.removeOutput(self.avMovieOutput)
|
|
156
|
+
self.restoreSessionPreset()
|
|
157
|
+
self.recordingWithAudio = false
|
|
158
|
+
self.captureSession.commitConfiguration()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if let error = error {
|
|
162
|
+
DispatchQueue.main.async {
|
|
163
|
+
handler?(nil, error)
|
|
164
|
+
}
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
DispatchQueue.main.async {
|
|
169
|
+
handler?(outputFileURL, nil)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// MARK: - Private Helpers
|
|
174
|
+
|
|
175
|
+
/// Waits for the capture session to start running, then retries `startRecording`.
|
|
176
|
+
///
|
|
177
|
+
/// Called when `startRecording` finds the session temporarily stopped (e.g. after
|
|
178
|
+
/// iOS reconfigures audio following a first-time microphone permission grant).
|
|
179
|
+
/// Both the notification path and the timeout path serialize through `sessionQueue`,
|
|
180
|
+
/// so `handled` is accessed on a single serial queue and needs no additional lock.
|
|
181
|
+
private func waitForSessionThenStartRecording(
|
|
182
|
+
enableAudio: Bool,
|
|
183
|
+
videoQuality: VideoRecordingQuality,
|
|
184
|
+
completion: @escaping (Error?) -> Void
|
|
185
|
+
) {
|
|
186
|
+
let sessionQueue = self.sessionQueue
|
|
187
|
+
// Keep the token so we can remove the observer on success and timeout paths.
|
|
188
|
+
var observerToken: NSObjectProtocol?
|
|
189
|
+
var handled = false
|
|
190
|
+
|
|
191
|
+
observerToken = NotificationCenter.default.addObserver(
|
|
192
|
+
forName: .AVCaptureSessionDidStartRunning,
|
|
193
|
+
object: captureSession,
|
|
194
|
+
queue: nil
|
|
195
|
+
) { [weak self] _ in
|
|
196
|
+
sessionQueue.async {
|
|
197
|
+
guard !handled else { return }
|
|
198
|
+
handled = true
|
|
199
|
+
if let token = observerToken {
|
|
200
|
+
NotificationCenter.default.removeObserver(token)
|
|
201
|
+
observerToken = nil
|
|
202
|
+
}
|
|
203
|
+
guard let self = self else { return }
|
|
204
|
+
self.startRecording(
|
|
205
|
+
enableAudio: enableAudio,
|
|
206
|
+
videoQuality: videoQuality,
|
|
207
|
+
completion: completion
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Timeout: if the session hasn't restarted within 2 seconds, give up.
|
|
213
|
+
sessionQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
|
214
|
+
guard !handled else { return }
|
|
215
|
+
handled = true
|
|
216
|
+
if let token = observerToken {
|
|
217
|
+
NotificationCenter.default.removeObserver(token)
|
|
218
|
+
observerToken = nil
|
|
219
|
+
}
|
|
220
|
+
guard let self = self else { return }
|
|
221
|
+
// One final check in case the session started just as we timed out.
|
|
222
|
+
if self.captureSession.isRunning {
|
|
223
|
+
self.startRecording(
|
|
224
|
+
enableAudio: enableAudio,
|
|
225
|
+
videoQuality: videoQuality,
|
|
226
|
+
completion: completion
|
|
227
|
+
)
|
|
228
|
+
} else {
|
|
229
|
+
DispatchQueue.main.async { completion(CameraError.sessionNotRunning) }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// Resolves the appropriate AVCaptureSession.Preset for the given VideoRecordingQuality,
|
|
235
|
+
private func resolveRecordingPreset(for videoQuality: VideoRecordingQuality) -> AVCaptureSession.Preset {
|
|
236
|
+
let preferredPresets: [AVCaptureSession.Preset]
|
|
237
|
+
switch videoQuality {
|
|
238
|
+
case .lowest:
|
|
239
|
+
preferredPresets = [.low]
|
|
240
|
+
case .sd:
|
|
241
|
+
preferredPresets = [.vga640x480, .medium, .low]
|
|
242
|
+
case .hd:
|
|
243
|
+
preferredPresets = [.hd1280x720, .high, .medium]
|
|
244
|
+
case .fhd:
|
|
245
|
+
preferredPresets = [.hd1920x1080, .hd1280x720, .high]
|
|
246
|
+
case .uhd:
|
|
247
|
+
preferredPresets = [.hd4K3840x2160, .hd1920x1080, .hd1280x720, .high]
|
|
248
|
+
case .highest:
|
|
249
|
+
preferredPresets = [.hd4K3840x2160, .hd1920x1080, .hd1280x720, .high, .medium, .low]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for preset in preferredPresets where captureSession.canSetSessionPreset(preset) {
|
|
253
|
+
return preset
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return captureSession.sessionPreset
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// Restores the session preset to its previous value before recording if it was changed for recording.
|
|
260
|
+
private func restoreSessionPreset() {
|
|
261
|
+
if let previousPreset = sessionPresetBeforeRecording,
|
|
262
|
+
captureSession.canSetSessionPreset(previousPreset) {
|
|
263
|
+
captureSession.sessionPreset = previousPreset
|
|
264
|
+
}
|
|
265
|
+
sessionPresetBeforeRecording = nil
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/// Adds microphone input to the capture session.
|
|
269
|
+
///
|
|
270
|
+
/// - Throws: `CameraError.audioDeviceUnavailable` if no microphone is present,
|
|
271
|
+
/// `CameraError.audioInputAdditionFailed` if the session rejects the input,
|
|
272
|
+
/// or the underlying `AVCaptureDeviceInput` error if device configuration fails.
|
|
273
|
+
private func addAudioInput() throws {
|
|
274
|
+
// Check if audio input already exists; nothing to do if so.
|
|
275
|
+
let hasAudioInput = captureSession.inputs.contains { input in
|
|
276
|
+
(input as? AVCaptureDeviceInput)?.device.hasMediaType(.audio) == true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
guard !hasAudioInput else { return }
|
|
280
|
+
|
|
281
|
+
guard let microphone = AVCaptureDevice.default(for: .audio) else {
|
|
282
|
+
throw CameraError.audioDeviceUnavailable
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let audioInput = try AVCaptureDeviceInput(device: microphone)
|
|
286
|
+
|
|
287
|
+
guard captureSession.canAddInput(audioInput) else {
|
|
288
|
+
throw CameraError.audioInputAdditionFailed
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
captureSession.addInput(audioInput)
|
|
292
|
+
recordingWithAudio = true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/// Removes microphone input from the capture session.
|
|
296
|
+
private func removeAudioInput() {
|
|
297
|
+
captureSession.inputs
|
|
298
|
+
.compactMap { $0 as? AVCaptureDeviceInput }
|
|
299
|
+
.filter { $0.device.hasMediaType(.audio) }
|
|
300
|
+
.forEach { captureSession.removeInput($0) }
|
|
301
|
+
}
|
|
302
|
+
}
|