capacitor-camera-view 2.1.0 → 2.2.0-rc.1

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 CHANGED
@@ -29,6 +29,7 @@
29
29
 
30
30
  - 📹 Embed a **live camera feed** directly into your app.
31
31
  - 📸 Capture photos or frames from the camera preview.
32
+ - 🎥 **Video recording** with optional audio support.
32
33
  - 🔍 **Barcode detection** support.
33
34
  - 📱 **Virtual device support** for automatic lens selection based on zoom level and focus (iOS only).
34
35
  - 🔦 Control **zoom**, **flash** and **torch** modes programmatically.
@@ -62,12 +63,70 @@ Add the following keys to your app's `Info.plist` file:
62
63
  <string>To capture photos and videos</string>
63
64
  ```
64
65
 
66
+ If you plan to use `startRecording` with `enableAudio: true`, also add:
67
+
68
+ ```xml
69
+ <key>NSMicrophoneUsageDescription</key>
70
+ <string>To record audio with video</string>
71
+ ```
72
+
73
+ > [!IMPORTANT]
74
+ > The `NSMicrophoneUsageDescription` key must be present in `Info.plist` **before** microphone permission is ever requested — even if the request happens automatically when starting a recording with audio. Omitting it will cause your app to crash at runtime.
75
+
65
76
  #### Android
66
77
 
67
- The `CAMERA` permission is automatically added by Capacitor. Ensure your `AndroidManifest.xml` includes it if needed for specific configurations:
78
+ The `CAMERA` permission is added to your app's `AndroidManifest.xml` automatically by the plugin. If you plan to use `startRecording` with `enableAudio: true`, the `RECORD_AUDIO` permission is also declared automatically by the plugin. You can verify these in your merged manifest:
68
79
 
69
80
  ```xml
70
81
  <uses-permission android:name="android.permission.CAMERA" />
82
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
83
+ ```
84
+
85
+ > [!IMPORTANT]
86
+ > Declaring a permission in `AndroidManifest.xml` is required for the system to allow requesting it at runtime. The plugin handles this for you, but make sure you are not accidentally stripping the permission in your build configuration.
87
+
88
+ ## 🔒 Permissions
89
+
90
+ The plugin handles permissions for you automatically when a feature that requires them is used. However, you can also request permissions explicitly in advance.
91
+
92
+ ### Requesting permissions explicitly
93
+
94
+ By default, `requestPermissions()` only requests **camera** permission, preserving backward compatibility:
95
+
96
+ ```typescript
97
+ // Request camera permission only (default behavior)
98
+ const status = await CameraView.requestPermissions();
99
+ console.log(status.camera); // 'granted' | 'denied' | 'prompt'
100
+ ```
101
+
102
+ To also request **microphone** permission (needed for video recording with audio), pass the `permissions` option:
103
+
104
+ ```typescript
105
+ // Request both camera and microphone permissions
106
+ const status = await CameraView.requestPermissions({
107
+ permissions: ['camera', 'microphone'],
108
+ });
109
+ console.log(status.camera); // 'granted' | 'denied' | 'prompt'
110
+ console.log(status.microphone); // 'granted' | 'denied' | 'prompt'
111
+ ```
112
+
113
+ ### Automatic permission requests
114
+
115
+ You do not need to call `requestPermissions()` manually. The plugin will automatically request the required permissions when a feature is first used:
116
+
117
+ - **Camera** permission is requested automatically when `start()` is called.
118
+ - **Microphone** permission is requested automatically when `startRecording({ enableAudio: true })` is called.
119
+
120
+ Regardless of whether you request permissions manually or rely on the automatic flow, the corresponding entries **must** be declared in your app's platform configuration (`Info.plist` on iOS, `AndroidManifest.xml` on Android) as described above.
121
+
122
+ ### Checking permission status
123
+
124
+ Use `checkPermissions()` to query the current permission state without triggering a system prompt:
125
+
126
+ ```typescript
127
+ const status = await CameraView.checkPermissions();
128
+ console.log(status.camera); // 'granted' | 'denied' | 'prompt'
129
+ console.log(status.microphone); // 'granted' | 'denied' | 'prompt'
71
130
  ```
72
131
 
73
132
  ## ▶️ Basic Usage
@@ -174,6 +233,54 @@ CameraView.addListener('barcodeDetected', (data) => {
174
233
 
175
234
  See the [`BarcodeDetectionData`](#barcodedetectiondata) interface for details on the event payload.
176
235
 
236
+ ## 🎥 Video Recording
237
+
238
+ This plugin supports recording video directly from the live camera feed.
239
+
240
+ **How it works:**
241
+ * **iOS:** Uses `AVCaptureMovieFileOutput` on top of the existing `AVCaptureSession`. Output is saved as `.mp4`.
242
+ * **Android:** Uses the CameraX `VideoCapture` use case via `LifecycleCameraController`. Output is saved as `.mp4`.
243
+ * **Web:** Uses the browser `MediaRecorder` API on the existing `MediaStream`. Output is a `.webm` blob URL (MP4 is not broadly supported by browsers).
244
+
245
+ **Basic usage:**
246
+
247
+ ```typescript
248
+ import { CameraView } from 'capacitor-camera-view';
249
+
250
+ // Start recording (camera must already be running)
251
+ await CameraView.startRecording({ enableAudio: false });
252
+
253
+ // Stop recording and get the result
254
+ const result = await CameraView.stopRecording();
255
+ console.log('Video saved to:', result.webPath);
256
+ ```
257
+
258
+ **Playing back the recorded video:**
259
+
260
+ ```html
261
+ <video [src]="videoPath" controls></video>
262
+ ```
263
+
264
+ **Recording with audio:**
265
+
266
+ ```typescript
267
+ await CameraView.startRecording({ enableAudio: true });
268
+ ```
269
+
270
+ **Recording with explicit quality preset (native):**
271
+
272
+ ```typescript
273
+ await CameraView.startRecording({
274
+ enableAudio: true,
275
+ videoQuality: 'fhd', // lowest | sd | hd | fhd | uhd | highest
276
+ });
277
+ ```
278
+
279
+ > [!NOTE]
280
+ > When `enableAudio: true` is used, the plugin automatically requests microphone permission from the user if it has not been granted yet. The permission declaration must still be present in your platform configuration — see the [Permissions](#-permissions) section for details.
281
+
282
+ See the [`VideoRecordingOptions`](#videorecordingoptions) and [`VideoRecordingResponse`](#videorecordingresponse) interfaces in the API section for the full set of options.
283
+
177
284
  ## 🧪 Example App
178
285
 
179
286
  To see the plugin in action, check out the example app in the `example-app` folder. The app demonstrates how to integrate and use the Capacitor Camera View plugin in an Ionic Angular project.
@@ -206,6 +313,8 @@ chore: update dependencies
206
313
  * [`isRunning()`](#isrunning)
207
314
  * [`capture(...)`](#capture)
208
315
  * [`captureSample(...)`](#capturesample)
316
+ * [`startRecording(...)`](#startrecording)
317
+ * [`stopRecording()`](#stoprecording)
209
318
  * [`flipCamera()`](#flipcamera)
210
319
  * [`getAvailableDevices()`](#getavailabledevices)
211
320
  * [`getZoom()`](#getzoom)
@@ -217,7 +326,7 @@ chore: update dependencies
217
326
  * [`getTorchMode()`](#gettorchmode)
218
327
  * [`setTorchMode(...)`](#settorchmode)
219
328
  * [`checkPermissions()`](#checkpermissions)
220
- * [`requestPermissions()`](#requestpermissions)
329
+ * [`requestPermissions(...)`](#requestpermissions)
221
330
  * [`addListener('barcodeDetected', ...)`](#addlistenerbarcodedetected-)
222
331
  * [`removeAllListeners(...)`](#removealllisteners)
223
332
  * [Interfaces](#interfaces)
@@ -321,6 +430,40 @@ not yet well supported on the web.
321
430
  --------------------
322
431
 
323
432
 
433
+ ### startRecording(...)
434
+
435
+ ```typescript
436
+ startRecording(options?: VideoRecordingOptions | undefined) => Promise<void>
437
+ ```
438
+
439
+ Start recording video from the current camera.
440
+ Camera must be running. Throws if already recording.
441
+
442
+ | Param | Type | Description |
443
+ | ------------- | ----------------------------------------------------------------------- | ---------------------------------- |
444
+ | **`options`** | <code><a href="#videorecordingoptions">VideoRecordingOptions</a></code> | - Optional recording configuration |
445
+
446
+ **Since:** 2.2.0
447
+
448
+ --------------------
449
+
450
+
451
+ ### stopRecording()
452
+
453
+ ```typescript
454
+ stopRecording() => Promise<VideoRecordingResponse>
455
+ ```
456
+
457
+ Stop the current video recording and return the result.
458
+ Throws if no recording is in progress.
459
+
460
+ **Returns:** <code>Promise&lt;<a href="#videorecordingresponse">VideoRecordingResponse</a>&gt;</code>
461
+
462
+ **Since:** 2.2.0
463
+
464
+ --------------------
465
+
466
+
324
467
  ### flipCamera()
325
468
 
326
469
  ```typescript
@@ -481,7 +624,7 @@ Set the torch (flashlight) mode and intensity.
481
624
  checkPermissions() => Promise<PermissionStatus>
482
625
  ```
483
626
 
484
- Check camera permission status without requesting permissions.
627
+ Check camera and microphone permission status without requesting permissions.
485
628
 
486
629
  **Returns:** <code>Promise&lt;<a href="#permissionstatus">PermissionStatus</a>&gt;</code>
487
630
 
@@ -490,13 +633,20 @@ Check camera permission status without requesting permissions.
490
633
  --------------------
491
634
 
492
635
 
493
- ### requestPermissions()
636
+ ### requestPermissions(...)
494
637
 
495
638
  ```typescript
496
- requestPermissions() => Promise<PermissionStatus>
639
+ requestPermissions(options?: { permissions?: CameraPermissionType[] | undefined; } | undefined) => Promise<PermissionStatus>
497
640
  ```
498
641
 
499
- Request camera permission from the user.
642
+ Request camera and/or microphone permissions from the user.
643
+
644
+ By default, only camera permission is requested. To also request microphone
645
+ permission (needed for video recording with audio), pass `{ permissions: ['camera', 'microphone'] }`.
646
+
647
+ | Param | Type | Description |
648
+ | ------------- | ------------------------------------------------------ | --------------------------------------------------------- |
649
+ | **`options`** | <code>{ permissions?: CameraPermissionType[]; }</code> | - Optional object specifying which permissions to request |
500
650
 
501
651
  **Returns:** <code>Promise&lt;<a href="#permissionstatus">PermissionStatus</a>&gt;</code>
502
652
 
@@ -581,6 +731,25 @@ Configuration options for capturing photos and samples.
581
731
  | **`saveToFile`** | <code>boolean</code> | If true, saves to a temporary file and returns the web path instead of base64. The web path can be used to set the src attribute of an image for efficient loading and rendering. This reduces the data that needs to be transferred over the bridge, which can improve performance especially for high-resolution images. | <code>false</code> | 1.1.0 |
582
732
 
583
733
 
734
+ #### VideoRecordingOptions
735
+
736
+ Configuration options for video recording.
737
+
738
+ | Prop | Type | Description | Default | Since |
739
+ | ------------------ | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------- | ----- |
740
+ | **`enableAudio`** | <code>boolean</code> | Whether to record audio with the video. Requires microphone permission. | <code>false</code> | 2.2.0 |
741
+ | **`videoQuality`** | <code><a href="#videorecordingquality">VideoRecordingQuality</a></code> | Video recording quality preset. Native platforms only (iOS/Android). Ignored on web. | <code>'highest'</code> | 2.2.0 |
742
+
743
+
744
+ #### VideoRecordingResponse
745
+
746
+ Response from stopping a video recording.
747
+
748
+ | Prop | Type | Description | Since |
749
+ | ------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
750
+ | **`webPath`** | <code>string</code> | Web-accessible path to the recorded video file. On web, this is a blob URL. On iOS/Android, this is a path accessible via Capacitor's filesystem. | 2.2.0 |
751
+
752
+
584
753
  #### GetAvailableDevicesResponse
585
754
 
586
755
  Response for getting available camera devices.
@@ -652,11 +821,12 @@ Response for getting the current torch mode.
652
821
 
653
822
  #### PermissionStatus
654
823
 
655
- Response for the camera permission status.
824
+ Response for the camera and microphone permission status.
656
825
 
657
- | Prop | Type | Description |
658
- | ------------ | ----------------------------------------------------------- | ---------------------------------- |
659
- | **`camera`** | <code><a href="#permissionstate">PermissionState</a></code> | The state of the camera permission |
826
+ | Prop | Type | Description |
827
+ | ---------------- | ----------------------------------------------------------- | -------------------------------------- |
828
+ | **`camera`** | <code><a href="#permissionstate">PermissionState</a></code> | The state of the camera permission |
829
+ | **`microphone`** | <code><a href="#permissionstate">PermissionState</a></code> | The state of the microphone permission |
660
830
 
661
831
 
662
832
  #### PluginListenerHandle
@@ -729,6 +899,13 @@ depending on the `saveToFile` option in the <a href="#captureoptions">CaptureOpt
729
899
  <code>T['saveToFile'] extends true ? { /** The web path to the captured photo that can be used to set the src attribute of an image for efficient loading and rendering (when saveToFile is true) */ webPath: string; } : { /** The base64 encoded string of the captured photo (when saveToFile is false or undefined) */ photo: string; }</code>
730
900
 
731
901
 
902
+ #### VideoRecordingQuality
903
+
904
+ Video recording quality presets.
905
+
906
+ <code>'lowest' | 'sd' | 'hd' | 'fhd' | 'uhd' | 'highest'</code>
907
+
908
+
732
909
  #### FlashMode
733
910
 
734
911
  Flash mode options for the camera.
@@ -743,4 +920,13 @@ Flash mode options for the camera.
743
920
 
744
921
  <code>'prompt' | 'prompt-with-rationale' | 'granted' | 'denied'</code>
745
922
 
923
+
924
+ #### CameraPermissionType
925
+
926
+ Permission types that can be requested.
927
+ - 'camera': Camera access permission
928
+ - 'microphone': Microphone access permission (needed for video recording with audio)
929
+
930
+ <code>'camera' | 'microphone'</code>
931
+
746
932
  </docgen-api>
@@ -78,6 +78,7 @@ dependencies {
78
78
  implementation "androidx.camera:camera-lifecycle:${camerax_version}"
79
79
  implementation "androidx.camera:camera-view:${camerax_version}"
80
80
  implementation "androidx.camera:camera-mlkit-vision:${camerax_version}"
81
+ implementation "androidx.camera:camera-video:${camerax_version}"
81
82
 
82
83
  // ML Kit for barcode scanning
83
84
  implementation "com.google.mlkit:barcode-scanning:17.3.0"
@@ -1,2 +1,3 @@
1
1
  <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
2
3
  </manifest>
@@ -1,7 +1,9 @@
1
1
  package com.michaelwolz.capacitorcameraview
2
2
 
3
+ import android.Manifest
3
4
  import android.content.Context
4
5
  import android.content.Context.CAMERA_SERVICE
6
+ import android.content.pm.PackageManager
5
7
  import android.graphics.Bitmap
6
8
  import android.hardware.camera2.CameraCharacteristics
7
9
  import android.hardware.camera2.CameraManager
@@ -23,8 +25,16 @@ import androidx.camera.core.TorchState
23
25
  import androidx.camera.core.resolutionselector.AspectRatioStrategy
24
26
  import androidx.camera.core.resolutionselector.ResolutionSelector
25
27
  import androidx.camera.mlkit.vision.MlKitAnalyzer
28
+ import androidx.camera.video.FallbackStrategy
29
+ import androidx.camera.video.FileOutputOptions
30
+ import androidx.camera.video.Quality
31
+ import androidx.camera.video.QualitySelector
32
+ import androidx.camera.video.Recording
33
+ import androidx.camera.video.VideoRecordEvent
34
+ import androidx.camera.view.CameraController
26
35
  import androidx.camera.view.LifecycleCameraController
27
36
  import androidx.camera.view.PreviewView
37
+ import androidx.camera.view.video.AudioConfig
28
38
  import androidx.core.content.ContextCompat
29
39
  import androidx.lifecycle.LifecycleOwner
30
40
  import com.getcapacitor.FileUtils
@@ -38,7 +48,9 @@ import com.michaelwolz.capacitorcameraview.model.BarcodeDetectionResult
38
48
  import com.michaelwolz.capacitorcameraview.model.CameraDevice
39
49
  import com.michaelwolz.capacitorcameraview.model.CameraResult
40
50
  import com.michaelwolz.capacitorcameraview.model.CameraSessionConfiguration
51
+ import com.michaelwolz.capacitorcameraview.model.VideoRecordingQuality
41
52
  import com.michaelwolz.capacitorcameraview.model.ZoomFactors
53
+ import kotlinx.coroutines.CancellableContinuation
42
54
  import kotlinx.coroutines.CoroutineScope
43
55
  import kotlinx.coroutines.Dispatchers
44
56
  import kotlinx.coroutines.SupervisorJob
@@ -55,6 +67,7 @@ import java.io.File
55
67
  import java.io.FileOutputStream
56
68
  import java.util.concurrent.ExecutorService
57
69
  import java.util.concurrent.Executors
70
+ import java.util.concurrent.atomic.AtomicBoolean
58
71
  import java.util.concurrent.atomic.AtomicLong
59
72
  import java.util.concurrent.atomic.AtomicReference
60
73
  import kotlin.coroutines.resume
@@ -72,7 +85,9 @@ class CameraView(plugin: Plugin) {
72
85
  // Camera components (using atomic reference for thread safety)
73
86
  private var cameraController: LifecycleCameraController?
74
87
  get() = cameraControllerRef.get()
75
- set(value) { cameraControllerRef.set(value) }
88
+ set(value) {
89
+ cameraControllerRef.set(value)
90
+ }
76
91
 
77
92
  private val cameraExecutor: ExecutorService by lazy { Executors.newSingleThreadExecutor() }
78
93
  private var previewView: PreviewView? = null
@@ -81,6 +96,18 @@ class CameraView(plugin: Plugin) {
81
96
  private var currentCameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
82
97
  private var currentFlashMode: Int = ImageCapture.FLASH_MODE_OFF
83
98
 
99
+ // Active video recording
100
+ private var activeRecording: Recording? = null
101
+
102
+ /**
103
+ * Holds the pending stop-recording continuation result handler.
104
+ * Needed because CameraX delivers the final recording outcome asynchronously via Finalize.
105
+ */
106
+ private var pendingStopCallback: ((CameraResult<JSObject>) -> Unit)? = null
107
+
108
+ // Track the output file for the current recording
109
+ private var currentRecordingFile: File? = null
110
+
84
111
  // Plugin context
85
112
  private var lifecycleOwner: LifecycleOwner? = null
86
113
  private var pluginDelegate: Plugin = plugin
@@ -130,6 +157,15 @@ class CameraView(plugin: Plugin) {
130
157
  /** Stop the camera session and release resources. */
131
158
  suspend fun stopSessionAsync(): CameraResult<Unit> = withContext(Dispatchers.Main) {
132
159
  try {
160
+ // Stop any active recording before unbinding
161
+ activeRecording?.stop()
162
+ activeRecording = null
163
+ pendingStopCallback?.invoke(
164
+ CameraResult.Error(Exception("Recording was interrupted because the camera session stopped"))
165
+ )
166
+ pendingStopCallback = null
167
+ currentRecordingFile = null
168
+
133
169
  cameraController?.unbind()
134
170
 
135
171
  previewView?.let { view ->
@@ -242,11 +278,15 @@ class CameraView(plugin: Plugin) {
242
278
  "Image captured successfully in ${System.currentTimeMillis() - startTime}ms"
243
279
  )
244
280
  try {
245
- val base64String = imageProxyToBase64(image, quality, imageRotationDegrees)
281
+ val base64String =
282
+ imageProxyToBase64(image, quality, imageRotationDegrees)
246
283
  val result = JSObject().apply {
247
284
  put("photo", base64String)
248
285
  }
249
- Log.d(TAG, "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms")
286
+ Log.d(
287
+ TAG,
288
+ "Image processed to Base64 in ${System.currentTimeMillis() - startTime}ms"
289
+ )
250
290
  continuation.resume(CameraResult.Success(result))
251
291
  } catch (e: Exception) {
252
292
  Log.e(TAG, "Error processing captured image", e)
@@ -346,6 +386,244 @@ class CameraView(plugin: Plugin) {
346
386
  }
347
387
  }
348
388
 
389
+ /**
390
+ * Starts video recording to a temporary file.
391
+ */
392
+ suspend fun startRecordingAsync(
393
+ enableAudio: Boolean,
394
+ videoQuality: VideoRecordingQuality,
395
+ ): CameraResult<Unit> = suspendCancellableCoroutine { continuation ->
396
+ mainHandler.post {
397
+ startRecordingOnMainThread(enableAudio, videoQuality, continuation)
398
+ }
399
+ }
400
+
401
+ private fun startRecordingOnMainThread(
402
+ enableAudio: Boolean,
403
+ videoQuality: VideoRecordingQuality,
404
+ continuation: CancellableContinuation<CameraResult<Unit>>
405
+ ) {
406
+ val controller = validateRecordingPreconditions(continuation) ?: return
407
+
408
+ try {
409
+ controller.videoCaptureQualitySelector = videoQuality.toQualitySelector()
410
+
411
+ // Enable VIDEO_CAPTURE use case alongside IMAGE_CAPTURE
412
+ controller.setEnabledUseCases(
413
+ CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE
414
+ )
415
+
416
+ val outputOptions = createRecordingOutputOptions()
417
+ val audioConfig = resolveAudioConfig(enableAudio, continuation) ?: return
418
+
419
+ startCameraRecording(controller, outputOptions, audioConfig, continuation)
420
+ } catch (e: SecurityException) {
421
+ Log.e(TAG, "Security exception when starting recording. Missing permission?", e)
422
+ // Restore normal use cases on permission error
423
+ cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
424
+ continuation.resume(CameraResult.Error(e))
425
+ } catch (e: Exception) {
426
+ Log.e(TAG, "Error starting recording", e)
427
+ // Restore normal use cases on error
428
+ cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
429
+ continuation.resume(CameraResult.Error(e))
430
+ }
431
+ }
432
+
433
+ private fun validateRecordingPreconditions(
434
+ continuation: CancellableContinuation<CameraResult<Unit>>
435
+ ): LifecycleCameraController? {
436
+ val controller = cameraController
437
+ if (controller == null) {
438
+ continuation.resume(CameraResult.Error(CameraError.CameraNotInitialized()))
439
+ return null
440
+ }
441
+
442
+ if (activeRecording != null) {
443
+ continuation.resume(CameraResult.Error(Exception("Recording is already in progress")))
444
+ return null
445
+ }
446
+
447
+ return controller
448
+ }
449
+
450
+ private fun createRecordingOutputOptions(): FileOutputOptions {
451
+ val tempFile = File.createTempFile(
452
+ "camera_recording_",
453
+ ".mp4",
454
+ context.cacheDir
455
+ )
456
+ currentRecordingFile = tempFile
457
+ return FileOutputOptions.Builder(tempFile).build()
458
+ }
459
+
460
+ private fun resolveAudioConfig(
461
+ enableAudio: Boolean,
462
+ continuation: CancellableContinuation<CameraResult<Unit>>
463
+ ): AudioConfig? {
464
+ if (!enableAudio) {
465
+ return AudioConfig.AUDIO_DISABLED
466
+ }
467
+
468
+ if (hasMicrophonePermission()) {
469
+ return try {
470
+ AudioConfig.create(true)
471
+ } catch (e: SecurityException) {
472
+ continuation.resume(CameraResult.Error(e))
473
+ null
474
+ }
475
+ }
476
+
477
+ continuation.resume(
478
+ CameraResult.Error(
479
+ SecurityException("Microphone permission is required for audio recording")
480
+ )
481
+ )
482
+ return null
483
+ }
484
+
485
+ private fun hasMicrophonePermission(): Boolean {
486
+ return ContextCompat.checkSelfPermission(
487
+ context,
488
+ Manifest.permission.RECORD_AUDIO
489
+ ) == PackageManager.PERMISSION_GRANTED
490
+ }
491
+
492
+ private fun startCameraRecording(
493
+ controller: LifecycleCameraController,
494
+ outputOptions: FileOutputOptions,
495
+ audioConfig: AudioConfig,
496
+ continuation: CancellableContinuation<CameraResult<Unit>>
497
+ ) {
498
+ val startResumed = AtomicBoolean(false)
499
+ activeRecording = controller.startRecording(
500
+ outputOptions,
501
+ audioConfig,
502
+ cameraExecutor
503
+ ) { event ->
504
+ when (event) {
505
+ is VideoRecordEvent.Start -> handleRecordingStartEvent(startResumed, continuation)
506
+ is VideoRecordEvent.Finalize -> {
507
+ handleRecordingFinalizeEvent(event, startResumed, continuation)
508
+ }
509
+
510
+ else -> Unit
511
+ }
512
+ }
513
+ }
514
+
515
+ private fun handleRecordingStartEvent(
516
+ startResumed: AtomicBoolean,
517
+ continuation: CancellableContinuation<CameraResult<Unit>>
518
+ ) {
519
+ Log.d(TAG, "Video recording started")
520
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
521
+ continuation.resume(CameraResult.Success(Unit))
522
+ }
523
+ }
524
+
525
+ private fun handleRecordingFinalizeEvent(
526
+ event: VideoRecordEvent.Finalize,
527
+ startResumed: AtomicBoolean,
528
+ continuation: CancellableContinuation<CameraResult<Unit>>
529
+ ) {
530
+ // If recording finalized before Start was emitted, resume the
531
+ // startRecording continuation with an error
532
+ if (continuation.isActive && startResumed.compareAndSet(false, true)) {
533
+ continuation.resume(
534
+ CameraResult.Error(
535
+ Exception("Recording failed to start: error code ${event.error}")
536
+ )
537
+ )
538
+ }
539
+
540
+ finalizeRecordingAndNotifyStopCallback(event)
541
+ }
542
+
543
+ private fun finalizeRecordingAndNotifyStopCallback(event: VideoRecordEvent.Finalize) {
544
+ mainHandler.post {
545
+ // CameraX requires use case changes on the main thread.
546
+ cameraController?.setEnabledUseCases(CameraController.IMAGE_CAPTURE)
547
+
548
+ val callback = pendingStopCallback
549
+ pendingStopCallback = null
550
+ // Always clean up recording state
551
+ activeRecording = null
552
+
553
+ if (event.hasError()) {
554
+ Log.e(TAG, "Recording error: ${event.error}")
555
+ currentRecordingFile = null
556
+ callback?.invoke(CameraResult.Error(Exception("Recording failed with error code: ${event.error}")))
557
+ return@post
558
+ }
559
+
560
+ val file = currentRecordingFile
561
+ currentRecordingFile = null
562
+ if (file == null) {
563
+ callback?.invoke(CameraResult.Error(Exception("Recording file not found")))
564
+ return@post
565
+ }
566
+
567
+ val capacitorFilePath = FileUtils.getPortablePath(
568
+ context,
569
+ pluginDelegate.bridge.localUrl,
570
+ Uri.fromFile(file)
571
+ )
572
+ val result = JSObject().apply {
573
+ put("webPath", capacitorFilePath)
574
+ }
575
+ callback?.invoke(CameraResult.Success(result))
576
+ }
577
+ }
578
+
579
+ private fun VideoRecordingQuality.toQualitySelector(): QualitySelector {
580
+ return when (this) {
581
+ VideoRecordingQuality.LOWEST -> QualitySelector.from(Quality.LOWEST)
582
+ VideoRecordingQuality.SD -> QualitySelector.from(
583
+ Quality.SD,
584
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.SD)
585
+ )
586
+
587
+ VideoRecordingQuality.HD -> QualitySelector.from(
588
+ Quality.HD,
589
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)
590
+ )
591
+
592
+ VideoRecordingQuality.FHD -> QualitySelector.from(
593
+ Quality.FHD,
594
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)
595
+ )
596
+
597
+ VideoRecordingQuality.UHD -> QualitySelector.from(
598
+ Quality.UHD,
599
+ FallbackStrategy.lowerQualityOrHigherThan(Quality.UHD)
600
+ )
601
+
602
+ VideoRecordingQuality.HIGHEST -> QualitySelector.from(Quality.HIGHEST)
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Stops the current video recording and returns the file path.
608
+ */
609
+ suspend fun stopRecordingAsync(): CameraResult<JSObject> =
610
+ suspendCancellableCoroutine { continuation ->
611
+ mainHandler.post {
612
+ val recording = activeRecording
613
+ if (recording == null) {
614
+ continuation.resume(CameraResult.Error(Exception("No recording is in progress")))
615
+ return@post
616
+ }
617
+
618
+ pendingStopCallback = { result ->
619
+ continuation.resume(result)
620
+ }
621
+
622
+ activeRecording = null
623
+ recording.stop()
624
+ }
625
+ }
626
+
349
627
  /** Flip between front and back cameras */
350
628
  fun flipCamera(callback: (Exception?) -> Unit) {
351
629
  currentCameraSelector = when (currentCameraSelector) {
@@ -533,6 +811,12 @@ class CameraView(plugin: Plugin) {
533
811
 
534
812
  mainHandler.post {
535
813
  try {
814
+ // Stop any active recording before cleanup
815
+ activeRecording?.stop()
816
+ activeRecording = null
817
+ pendingStopCallback = null
818
+ currentRecordingFile = null
819
+
536
820
  // Stop camera session
537
821
  cameraController?.unbind()
538
822
  cameraController = null