@capgo/camera-preview 8.6.0 → 8.8.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 CHANGED
@@ -279,6 +279,21 @@ In your `ios/App/App/Info.plist`, you must provide descriptions for the permissi
279
279
  <string>To add location data to your photos</string>
280
280
  ```
281
281
 
282
+ - **Haptics during video recording** (`allowHapticsAndSystemSoundsDuringRecording: true`):
283
+ When recording video with audio enabled (`disableAudio: false`), iOS suppresses haptic feedback by default. Opt in on `startRecordVideo()` to allow haptics while recording, for example to warn users before a time limit expires:
284
+
285
+ ```typescript
286
+ import { Haptics, ImpactStyle } from '@capacitor/haptics';
287
+
288
+ await CameraPreview.startRecordVideo({
289
+ disableAudio: false,
290
+ allowHapticsAndSystemSoundsDuringRecording: true,
291
+ });
292
+
293
+ // Later, while recording:
294
+ await Haptics.impact({ style: ImpactStyle.Medium });
295
+ ```
296
+
282
297
  ## Extra Android installation steps
283
298
 
284
299
  **Important** `camera-preview` 3+ requires Gradle 7.
@@ -412,6 +427,9 @@ Documentation for the [uploader](https://github.com/Cap-go/capacitor-uploader)
412
427
  * [`getExposureCompensationRange()`](#getexposurecompensationrange)
413
428
  * [`getExposureCompensation()`](#getexposurecompensation)
414
429
  * [`setExposureCompensation(...)`](#setexposurecompensation)
430
+ * [`getSupportedVideoFrameRates()`](#getsupportedvideoframerates)
431
+ * [`getVideoFrameRate()`](#getvideoframerate)
432
+ * [`setVideoFrameRate(...)`](#setvideoframerate)
415
433
  * [`getPluginVersion()`](#getpluginversion)
416
434
  * [Interfaces](#interfaces)
417
435
  * [Type Aliases](#type-aliases)
@@ -759,9 +777,11 @@ startRecordVideo(options: CameraPreviewOptions) => Promise<void>
759
777
 
760
778
  Starts recording a video.
761
779
 
762
- | Param | Type | Description |
763
- | ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
764
- | **`options`** | <code><a href="#camerapreviewoptions">CameraPreviewOptions</a></code> | - The options for video recording. Supports `videoCodec`, `videoStabilizationMode`, `maxDuration`, `maxFileSize`, and `disableAudio`. |
780
+ Supports `frameRate`, `videoCodec`, `videoStabilizationMode`, `maxDuration`, `maxFileSize`, `disableAudio`, and `allowHapticsAndSystemSoundsDuringRecording` on each call.
781
+
782
+ | Param | Type | Description |
783
+ | ------------- | --------------------------------------------------------------------- | ---------------------------------- |
784
+ | **`options`** | <code><a href="#camerapreviewoptions">CameraPreviewOptions</a></code> | - The options for video recording. |
765
785
 
766
786
  **Since:** 0.0.1
767
787
 
@@ -1359,6 +1379,57 @@ Sets the exposure compensation (EV bias). Value will be clamped to range.
1359
1379
  --------------------
1360
1380
 
1361
1381
 
1382
+ ### getSupportedVideoFrameRates()
1383
+
1384
+ ```typescript
1385
+ getSupportedVideoFrameRates() => Promise<{ frameRates: number[]; }>
1386
+ ```
1387
+
1388
+ Lists the video frame rates supported by the active camera for the current format.
1389
+ Supported values depend on the selected camera, lens, and video quality.
1390
+
1391
+ **Returns:** <code>Promise&lt;{ frameRates: number[]; }&gt;</code>
1392
+
1393
+ **Since:** 8.6.0
1394
+
1395
+ --------------------
1396
+
1397
+
1398
+ ### getVideoFrameRate()
1399
+
1400
+ ```typescript
1401
+ getVideoFrameRate() => Promise<{ frameRate: number; }>
1402
+ ```
1403
+
1404
+ Returns the configured video frame rate for the active camera.
1405
+ On Android the actual recording frame rate can still vary in low light or under thermal pressure.
1406
+
1407
+ **Returns:** <code>Promise&lt;{ frameRate: number; }&gt;</code>
1408
+
1409
+ **Since:** 8.6.0
1410
+
1411
+ --------------------
1412
+
1413
+
1414
+ ### setVideoFrameRate(...)
1415
+
1416
+ ```typescript
1417
+ setVideoFrameRate(options: { frameRate: number; }) => Promise<void>
1418
+ ```
1419
+
1420
+ Sets the target video frame rate for the active camera session.
1421
+ Prefer passing `frameRate` to `startRecordVideo()` when starting a recording.
1422
+ Rejects unsupported values with a clear error.
1423
+
1424
+ | Param | Type |
1425
+ | ------------- | ----------------------------------- |
1426
+ | **`options`** | <code>{ frameRate: number; }</code> |
1427
+
1428
+ **Since:** 8.6.0
1429
+
1430
+ --------------------
1431
+
1432
+
1362
1433
  ### getPluginVersion()
1363
1434
 
1364
1435
  ```typescript
@@ -1379,40 +1450,42 @@ Get the native Capacitor plugin version
1379
1450
 
1380
1451
  Defines the configuration options for starting the camera preview.
1381
1452
 
1382
- | Prop | Type | Description | Default | Since |
1383
- | ----------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------ |
1384
- | **`parent`** | <code>string</code> | The parent element to attach the video preview to. | | |
1385
- | **`className`** | <code>string</code> | A CSS class name to add to the preview element. | | |
1386
- | **`width`** | <code>number</code> | The width of the preview in pixels. Defaults to the screen width. | | |
1387
- | **`height`** | <code>number</code> | The height of the preview in pixels. Defaults to the screen height. | | |
1388
- | **`x`** | <code>number</code> | The horizontal origin of the preview, in pixels. | | |
1389
- | **`y`** | <code>number</code> | The vertical origin of the preview, in pixels. | | |
1390
- | **`aspectRatio`** | <code><a href="#camerapreviewaspectratio">CameraPreviewAspectRatio</a></code> | The aspect ratio of the camera preview, '4:3' or '16:9' or 'fill'. Cannot be set if width or height is provided, otherwise the call will be rejected. Use setPreviewSize to adjust size after starting. | | 2.0.0 |
1391
- | **`aspectMode`** | <code>'cover' \| 'contain'</code> | Controls how the camera preview fills the available space. - 'contain': Fits the entire preview within the space, may show letterboxing (default). - 'cover': Fills the entire space, may crop edges of the preview. | <code>"contain"</code> | |
1392
- | **`gridMode`** | <code><a href="#gridmode">GridMode</a></code> | The grid overlay to display on the camera preview. | <code>"none"</code> | 2.1.0 |
1393
- | **`includeSafeAreaInsets`** | <code>boolean</code> | Adjusts the y-position to account for safe areas (e.g., notches). | <code>false</code> | |
1394
- | **`toBack`** | <code>boolean</code> | If true, places the preview behind the webview. | <code>true</code> | |
1395
- | **`paddingBottom`** | <code>number</code> | Bottom padding for the preview, in pixels. | | |
1396
- | **`rotateWhenOrientationChanged`** | <code>boolean</code> | Whether to rotate the preview when the device orientation changes. | <code>true</code> | |
1397
- | **`position`** | <code>string</code> | The camera to use. | <code>"rear"</code> | |
1398
- | **`storeToFile`** | <code>boolean</code> | If true, saves the captured image to a file and returns the file path. If false, returns a base64 encoded string. | <code>false</code> | |
1399
- | **`disableExifHeaderStripping`** | <code>boolean</code> | If true, prevents the plugin from rotating the image based on EXIF data. | <code>false</code> | |
1400
- | **`disableAudio`** | <code>boolean</code> | If true, disables the audio stream, preventing audio permission requests. | <code>true</code> | |
1401
- | **`lockAndroidOrientation`** | <code>boolean</code> | If true, locks the device orientation while the camera is active. | <code>false</code> | |
1402
- | **`enableOpacity`** | <code>boolean</code> | If true, allows the camera preview's opacity to be changed. | <code>false</code> | |
1403
- | **`disableFocusIndicator`** | <code>boolean</code> | If true, disables the visual focus indicator when tapping to focus. | <code>false</code> | |
1404
- | **`deviceId`** | <code>string</code> | The `deviceId` of the camera to use. If provided, `position` is ignored. | | |
1405
- | **`enablePhysicalDeviceSelection`** | <code>boolean</code> | On Android, attempts to bind a physical camera directly when `deviceId` refers to a physical lens. Disabled by default because OEM support is inconsistent; when false, Android keeps the current logical-camera fallback behavior. | <code>false</code> | |
1406
- | **`initialZoomLevel`** | <code>number</code> | The initial zoom level when starting the camera preview. If the requested zoom level is not available, the native plugin will reject. | <code>1.0</code> | 2.2.0 |
1407
- | **`positioning`** | <code><a href="#camerapositioning">CameraPositioning</a></code> | The vertical positioning of the camera preview. | <code>"center"</code> | 2.3.0 |
1408
- | **`enableVideoMode`** | <code>boolean</code> | If true, enables video capture capabilities when the camera starts. | <code>false</code> | 7.11.0 |
1409
- | **`force`** | <code>boolean</code> | If true, forces the camera to start/restart even if it's already running or busy. This will kill the current camera session and start a new one, ignoring all state checks. | <code>false</code> | |
1410
- | **`videoQuality`** | <code><a href="#videoquality">VideoQuality</a></code> | Sets the quality of video for recording. Options: 'low', 'medium', 'high', '2160p', '1080p', '720p', '480p', '4:3' | <code>"high"</code> | |
1411
- | **`maxDuration`** | <code>number</code> | Maximum recording duration in seconds. Recording stops automatically when reached. | | |
1412
- | **`maxFileSize`** | <code>number</code> | Maximum recording file size in bytes. Recording stops automatically when reached. | | |
1413
- | **`videoCodec`** | <code><a href="#videocodec">VideoCodec</a></code> | Preferred video codec for recording. | <code>"avc1"</code> | |
1414
- | **`videoStabilizationMode`** | <code><a href="#videostabilizationmode">VideoStabilizationMode</a></code> | Preferred video stabilization mode for recording. | <code>"off"</code> | |
1415
- | **`barcodeScanner`** | <code>boolean \| <a href="#barcodescanneroptions">BarcodeScannerOptions</a></code> | Starts barcode scanning together with the camera preview. Set to `true` or pass options to scan all supported formats. Omit this option to keep barcode scanning disabled at startup. | | 8.8.0 |
1453
+ | Prop | Type | Description | Default | Since |
1454
+ | ------------------------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ------ |
1455
+ | **`parent`** | <code>string</code> | The parent element to attach the video preview to. | | |
1456
+ | **`className`** | <code>string</code> | A CSS class name to add to the preview element. | | |
1457
+ | **`width`** | <code>number</code> | The width of the preview in pixels. Defaults to the screen width. | | |
1458
+ | **`height`** | <code>number</code> | The height of the preview in pixels. Defaults to the screen height. | | |
1459
+ | **`x`** | <code>number</code> | The horizontal origin of the preview, in pixels. | | |
1460
+ | **`y`** | <code>number</code> | The vertical origin of the preview, in pixels. | | |
1461
+ | **`aspectRatio`** | <code><a href="#camerapreviewaspectratio">CameraPreviewAspectRatio</a></code> | The aspect ratio of the camera preview, '4:3' or '16:9' or 'fill'. Cannot be set if width or height is provided, otherwise the call will be rejected. Use setPreviewSize to adjust size after starting. | | 2.0.0 |
1462
+ | **`aspectMode`** | <code>'cover' \| 'contain'</code> | Controls how the camera preview fills the available space. - 'contain': Fits the entire preview within the space, may show letterboxing (default). - 'cover': Fills the entire space, may crop edges of the preview. | <code>"contain"</code> | |
1463
+ | **`gridMode`** | <code><a href="#gridmode">GridMode</a></code> | The grid overlay to display on the camera preview. | <code>"none"</code> | 2.1.0 |
1464
+ | **`includeSafeAreaInsets`** | <code>boolean</code> | Adjusts the y-position to account for safe areas (e.g., notches). | <code>false</code> | |
1465
+ | **`toBack`** | <code>boolean</code> | If true, places the preview behind the webview. | <code>true</code> | |
1466
+ | **`paddingBottom`** | <code>number</code> | Bottom padding for the preview, in pixels. | | |
1467
+ | **`rotateWhenOrientationChanged`** | <code>boolean</code> | Whether to rotate the preview when the device orientation changes. | <code>true</code> | |
1468
+ | **`position`** | <code>string</code> | The camera to use. | <code>"rear"</code> | |
1469
+ | **`storeToFile`** | <code>boolean</code> | If true, saves the captured image to a file and returns the file path. If false, returns a base64 encoded string. | <code>false</code> | |
1470
+ | **`disableExifHeaderStripping`** | <code>boolean</code> | If true, prevents the plugin from rotating the image based on EXIF data. | <code>false</code> | |
1471
+ | **`disableAudio`** | <code>boolean</code> | If true, disables the audio stream, preventing audio permission requests. | <code>true</code> | |
1472
+ | **`lockAndroidOrientation`** | <code>boolean</code> | If true, locks the device orientation while the camera is active. | <code>false</code> | |
1473
+ | **`enableOpacity`** | <code>boolean</code> | If true, allows the camera preview's opacity to be changed. | <code>false</code> | |
1474
+ | **`disableFocusIndicator`** | <code>boolean</code> | If true, disables the visual focus indicator when tapping to focus. | <code>false</code> | |
1475
+ | **`deviceId`** | <code>string</code> | The `deviceId` of the camera to use. If provided, `position` is ignored. | | |
1476
+ | **`enablePhysicalDeviceSelection`** | <code>boolean</code> | On Android, attempts to bind a physical camera directly when `deviceId` refers to a physical lens. Disabled by default because OEM support is inconsistent; when false, Android keeps the current logical-camera fallback behavior. | <code>false</code> | |
1477
+ | **`initialZoomLevel`** | <code>number</code> | The initial zoom level when starting the camera preview. If the requested zoom level is not available, the native plugin will reject. | <code>1.0</code> | 2.2.0 |
1478
+ | **`positioning`** | <code><a href="#camerapositioning">CameraPositioning</a></code> | The vertical positioning of the camera preview. | <code>"center"</code> | 2.3.0 |
1479
+ | **`enableVideoMode`** | <code>boolean</code> | If true, enables video capture capabilities when the camera starts. | <code>false</code> | 7.11.0 |
1480
+ | **`force`** | <code>boolean</code> | If true, forces the camera to start/restart even if it's already running or busy. This will kill the current camera session and start a new one, ignoring all state checks. | <code>false</code> | |
1481
+ | **`videoQuality`** | <code><a href="#videoquality">VideoQuality</a></code> | Sets the quality of video for recording. Options: 'low', 'medium', 'high', '2160p', '1080p', '720p', '480p', '4:3' | <code>"high"</code> | |
1482
+ | **`maxDuration`** | <code>number</code> | Maximum recording duration in seconds. Recording stops automatically when reached. | | |
1483
+ | **`maxFileSize`** | <code>number</code> | Maximum recording file size in bytes. Recording stops automatically when reached. | | |
1484
+ | **`videoCodec`** | <code><a href="#videocodec">VideoCodec</a></code> | Preferred video codec for recording. | <code>"avc1"</code> | |
1485
+ | **`frameRate`** | <code>number</code> | Target video frame rate in frames per second. Applied when recording starts. Use `getSupportedVideoFrameRates()` to list supported values. | | 8.6.0 |
1486
+ | **`videoStabilizationMode`** | <code><a href="#videostabilizationmode">VideoStabilizationMode</a></code> | Preferred video stabilization mode for recording. | <code>"off"</code> | |
1487
+ | **`allowHapticsAndSystemSoundsDuringRecording`** | <code>boolean</code> | When `true`, allows haptic feedback and system sounds while recording video with audio. iOS suppresses haptics during audio input recording unless this is enabled. Requires `disableAudio: false`. | <code>false</code> | 8.8.0 |
1488
+ | **`barcodeScanner`** | <code>boolean \| <a href="#barcodescanneroptions">BarcodeScannerOptions</a></code> | Starts barcode scanning together with the camera preview. Set to `true` or pass options to scan all supported formats. Omit this option to keep barcode scanning disabled at startup. | | 8.8.0 |
1416
1489
 
1417
1490
 
1418
1491
  #### BarcodeScannerOptions
@@ -291,6 +291,56 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
291
291
  }
292
292
  }
293
293
 
294
+ @PluginMethod
295
+ public void getSupportedVideoFrameRates(PluginCall call) {
296
+ if (cameraXView == null || !cameraXView.isRunning()) {
297
+ call.reject("Camera is not running");
298
+ return;
299
+ }
300
+ try {
301
+ List<Integer> frameRates = cameraXView.getSupportedVideoFrameRates();
302
+ JSObject ret = new JSObject();
303
+ JSONArray frameRatesArray = new JSONArray();
304
+ for (Integer frameRate : frameRates) {
305
+ frameRatesArray.put(frameRate);
306
+ }
307
+ ret.put("frameRates", frameRatesArray);
308
+ call.resolve(ret);
309
+ } catch (Exception e) {
310
+ call.reject("Failed to get supported video frame rates: " + e.getMessage());
311
+ }
312
+ }
313
+
314
+ @PluginMethod
315
+ public void getVideoFrameRate(PluginCall call) {
316
+ if (cameraXView == null || !cameraXView.isRunning()) {
317
+ call.reject("Camera is not running");
318
+ return;
319
+ }
320
+ try {
321
+ int frameRate = cameraXView.getVideoFrameRate();
322
+ JSObject ret = new JSObject();
323
+ ret.put("frameRate", frameRate);
324
+ call.resolve(ret);
325
+ } catch (Exception e) {
326
+ call.reject("Failed to get video frame rate: " + e.getMessage());
327
+ }
328
+ }
329
+
330
+ @PluginMethod
331
+ public void setVideoFrameRate(PluginCall call) {
332
+ if (cameraXView == null || !cameraXView.isRunning()) {
333
+ call.reject("Camera is not running");
334
+ return;
335
+ }
336
+ Integer frameRate = call.getInt("frameRate");
337
+ if (frameRate == null) {
338
+ call.reject("frameRate parameter is required");
339
+ return;
340
+ }
341
+ cameraXView.setVideoFrameRate(frameRate, call::resolve, call::reject);
342
+ }
343
+
294
344
  @PluginMethod
295
345
  public void getOrientation(PluginCall call) {
296
346
  String o = getDeviceOrientationString();
@@ -2773,14 +2823,7 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
2773
2823
  String permissionAlias = disableAudio ? CAMERA_ONLY_PERMISSION_ALIAS : CAMERA_WITH_AUDIO_PERMISSION_ALIAS;
2774
2824
 
2775
2825
  if (PermissionState.GRANTED.equals(getPermissionState(permissionAlias))) {
2776
- try {
2777
- applyVideoCodecFromCall(call);
2778
- applyVideoStabilizationFromCall(call);
2779
- cameraXView.startRecordVideo(getMaxDurationMillis(call), getMaxFileSize(call));
2780
- call.resolve();
2781
- } catch (Exception e) {
2782
- call.reject("Failed to start video recording: " + e.getMessage());
2783
- }
2826
+ beginVideoRecording(call);
2784
2827
  } else {
2785
2828
  requestPermissionForAlias(permissionAlias, call, "handleVideoRecordingPermissionResult");
2786
2829
  }
@@ -2858,6 +2901,23 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
2858
2901
  return bytes > 0L ? bytes : null;
2859
2902
  }
2860
2903
 
2904
+ private void beginVideoRecording(PluginCall call) {
2905
+ if (cameraXView == null || !cameraXView.isRunning()) {
2906
+ call.reject("Camera is not running");
2907
+ return;
2908
+ }
2909
+ try {
2910
+ applyVideoCodecFromCall(call);
2911
+ applyVideoStabilizationFromCall(call);
2912
+ Integer frameRate = call.getInt("frameRate");
2913
+ cameraXView.startRecordVideo(getMaxDurationMillis(call), getMaxFileSize(call), frameRate, call::resolve, (message) ->
2914
+ call.reject("Failed to start video recording: " + message)
2915
+ );
2916
+ } catch (Exception e) {
2917
+ call.reject("Failed to start video recording: " + e.getMessage());
2918
+ }
2919
+ }
2920
+
2861
2921
  @PermissionCallback
2862
2922
  private void handleVideoRecordingPermissionResult(PluginCall call) {
2863
2923
  // Use the persisted session value to determine which permission we requested
@@ -2868,14 +2928,7 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
2868
2928
  PermissionState.GRANTED.equals(getPermissionState(CAMERA_ONLY_PERMISSION_ALIAS)) ||
2869
2929
  PermissionState.GRANTED.equals(getPermissionState(CAMERA_WITH_AUDIO_PERMISSION_ALIAS))
2870
2930
  ) {
2871
- try {
2872
- applyVideoCodecFromCall(call);
2873
- applyVideoStabilizationFromCall(call);
2874
- cameraXView.startRecordVideo(getMaxDurationMillis(call), getMaxFileSize(call));
2875
- call.resolve();
2876
- } catch (Exception e) {
2877
- call.reject("Failed to start video recording: " + e.getMessage());
2878
- }
2931
+ beginVideoRecording(call);
2879
2932
  } else {
2880
2933
  call.reject("camera permission denied. enable camera access in Settings.", "cameraPermissionDenied");
2881
2934
  }
@@ -45,6 +45,7 @@ import androidx.annotation.NonNull;
45
45
  import androidx.annotation.OptIn;
46
46
  import androidx.camera.camera2.interop.Camera2CameraControl;
47
47
  import androidx.camera.camera2.interop.Camera2CameraInfo;
48
+ import androidx.camera.camera2.interop.Camera2Interop;
48
49
  import androidx.camera.camera2.interop.CaptureRequestOptions;
49
50
  import androidx.camera.camera2.interop.ExperimentalCamera2Interop;
50
51
  import androidx.camera.core.AspectRatio;
@@ -110,6 +111,7 @@ import java.util.Locale;
110
111
  import java.util.Map;
111
112
  import java.util.Objects;
112
113
  import java.util.Set;
114
+ import java.util.TreeSet;
113
115
  import java.util.concurrent.ConcurrentHashMap;
114
116
  import java.util.concurrent.Executor;
115
117
  import java.util.concurrent.ExecutorService;
@@ -184,6 +186,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
184
186
  private boolean isRunning = false;
185
187
  private Size currentPreviewResolution = null;
186
188
  private ListenableFuture<FocusMeteringResult> currentFocusFuture = null; // Track current focus operation
189
+ private Integer configuredVideoFrameRate = null;
190
+ private Range<Integer> configuredVideoFrameRateRange = null;
191
+ private Runnable pendingFrameRateBindSuccess;
192
+ private java.util.function.Consumer<String> pendingFrameRateBindError;
187
193
  private String currentExposureMode = "CONTINUOUS"; // Default behavior
188
194
  // Capture/stop coordination
189
195
  private final Object captureLock = new Object();
@@ -1071,7 +1077,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1071
1077
  ? previewView.getDisplay().getRotation()
1072
1078
  : android.view.Surface.ROTATION_0;
1073
1079
 
1074
- Preview preview = new Preview.Builder().setResolutionSelector(resolutionSelector).setTargetRotation(rotation).build();
1080
+ Preview.Builder previewBuilder = new Preview.Builder()
1081
+ .setResolutionSelector(resolutionSelector)
1082
+ .setTargetRotation(rotation);
1083
+ previewBuilder = applyTargetFpsToPreviewBuilder(previewBuilder);
1084
+ Preview preview = previewBuilder.build();
1075
1085
  // Keep reference to preview use case for later re-binding (e.g., when enabling video)
1076
1086
  imageCapture = new ImageCapture.Builder()
1077
1087
  .setResolutionSelector(resolutionSelector)
@@ -1140,6 +1150,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1140
1150
  }
1141
1151
  Recorder recorder = recorderBuilder.build();
1142
1152
  VideoCapture.Builder<Recorder> videoCaptureBuilder = new VideoCapture.Builder<>(recorder);
1153
+ videoCaptureBuilder = applyTargetFpsToVideoCaptureBuilder(videoCaptureBuilder);
1143
1154
  videoCaptureBuilder.setVideoStabilizationEnabled(isVideoStabilizationEnabledForSession());
1144
1155
  videoCapture = videoCaptureBuilder.build();
1145
1156
  }
@@ -1289,6 +1300,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1289
1300
  }
1290
1301
 
1291
1302
  isRunning = true;
1303
+ completePendingFrameRateBindSuccess();
1292
1304
  Log.d(TAG, "bindCameraUseCases: Camera bound successfully");
1293
1305
  if (listener != null) {
1294
1306
  if (sessionConfig != null && sessionConfig.isToBack()) {
@@ -1326,6 +1338,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1326
1338
  } catch (Exception e) {
1327
1339
  // Restore webView background on error
1328
1340
  restoreWebViewBackground();
1341
+ completePendingFrameRateBindError("Error binding camera: " + e.getMessage());
1329
1342
  if (listener != null) listener.onCameraStartError(this, "Error binding camera: " + e.getMessage());
1330
1343
  }
1331
1344
  });
@@ -3357,6 +3370,185 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3357
3370
  }
3358
3371
  }
3359
3372
 
3373
+ private void clearConfiguredVideoFrameRate() {
3374
+ configuredVideoFrameRate = null;
3375
+ configuredVideoFrameRateRange = null;
3376
+ }
3377
+
3378
+ private Range<Integer>[] getAdvertisedFpsRanges() throws Exception {
3379
+ CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
3380
+ String cameraId = resolveActiveCameraIdForCharacteristics();
3381
+ CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraId);
3382
+ return characteristics.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES);
3383
+ }
3384
+
3385
+ private Range<Integer> findAdvertisedFpsRangeForRate(int frameRate, Range<Integer>[] ranges) {
3386
+ if (ranges == null) {
3387
+ return null;
3388
+ }
3389
+
3390
+ Range<Integer> containingRange = null;
3391
+ for (Range<Integer> range : ranges) {
3392
+ if (range.getLower() > frameRate || range.getUpper() < frameRate) {
3393
+ continue;
3394
+ }
3395
+ if (range.getLower().equals(range.getUpper()) && range.getLower() == frameRate) {
3396
+ return range;
3397
+ }
3398
+ containingRange = range;
3399
+ }
3400
+ return containingRange;
3401
+ }
3402
+
3403
+ private Range<Integer> resolveConfiguredVideoFrameRateRange() {
3404
+ if (configuredVideoFrameRate == null) {
3405
+ return null;
3406
+ }
3407
+ try {
3408
+ Range<Integer>[] ranges = getAdvertisedFpsRanges();
3409
+ return findAdvertisedFpsRangeForRate(configuredVideoFrameRate, ranges);
3410
+ } catch (Exception e) {
3411
+ Log.w(TAG, "resolveConfiguredVideoFrameRateRange: Failed to resolve FPS range", e);
3412
+ return null;
3413
+ }
3414
+ }
3415
+
3416
+ private void completePendingFrameRateBindSuccess() {
3417
+ if (pendingFrameRateBindSuccess == null) {
3418
+ return;
3419
+ }
3420
+ Runnable callback = pendingFrameRateBindSuccess;
3421
+ pendingFrameRateBindSuccess = null;
3422
+ pendingFrameRateBindError = null;
3423
+ callback.run();
3424
+ }
3425
+
3426
+ private void completePendingFrameRateBindError(String message) {
3427
+ if (pendingFrameRateBindError == null) {
3428
+ return;
3429
+ }
3430
+ java.util.function.Consumer<String> callback = pendingFrameRateBindError;
3431
+ pendingFrameRateBindSuccess = null;
3432
+ pendingFrameRateBindError = null;
3433
+ callback.accept(message);
3434
+ }
3435
+
3436
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
3437
+ private Preview.Builder applyTargetFpsToPreviewBuilder(Preview.Builder builder) {
3438
+ Range<Integer> fpsRange = resolveConfiguredVideoFrameRateRange();
3439
+ if (fpsRange == null) {
3440
+ return builder;
3441
+ }
3442
+
3443
+ Camera2Interop.Extender<Preview> extender = new Camera2Interop.Extender<>(builder);
3444
+ extender.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, fpsRange);
3445
+ return builder;
3446
+ }
3447
+
3448
+ @OptIn(markerClass = ExperimentalCamera2Interop.class)
3449
+ private VideoCapture.Builder<Recorder> applyTargetFpsToVideoCaptureBuilder(VideoCapture.Builder<Recorder> builder) {
3450
+ Range<Integer> fpsRange = resolveConfiguredVideoFrameRateRange();
3451
+ if (fpsRange == null) {
3452
+ return builder;
3453
+ }
3454
+
3455
+ Camera2Interop.Extender<VideoCapture<Recorder>> extender = new Camera2Interop.Extender<>(builder);
3456
+ extender.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, fpsRange);
3457
+ return builder;
3458
+ }
3459
+
3460
+ private String resolveActiveCameraIdForCharacteristics() throws Exception {
3461
+ if (currentLogicalDeviceId != null) {
3462
+ return currentLogicalDeviceId;
3463
+ }
3464
+ if (camera != null) {
3465
+ return Camera2CameraInfo.from(camera.getCameraInfo()).getCameraId();
3466
+ }
3467
+ throw new Exception("Camera not initialized");
3468
+ }
3469
+
3470
+ private List<Integer> frameRatesFromFpsRanges(Range<Integer>[] ranges) {
3471
+ TreeSet<Integer> rates = new TreeSet<>();
3472
+ int[] standardRates = new int[] { 24, 25, 30, 50, 60, 120 };
3473
+
3474
+ if (ranges == null) {
3475
+ return new ArrayList<>();
3476
+ }
3477
+
3478
+ for (Range<Integer> range : ranges) {
3479
+ int min = range.getLower();
3480
+ int max = range.getUpper();
3481
+ if (min == max) {
3482
+ rates.add(min);
3483
+ continue;
3484
+ }
3485
+
3486
+ rates.add(min);
3487
+ rates.add(max);
3488
+ for (int standardRate : standardRates) {
3489
+ if (standardRate >= min && standardRate <= max) {
3490
+ rates.add(standardRate);
3491
+ }
3492
+ }
3493
+ }
3494
+
3495
+ return new ArrayList<>(rates);
3496
+ }
3497
+
3498
+ public List<Integer> getSupportedVideoFrameRates() throws Exception {
3499
+ return frameRatesFromFpsRanges(getAdvertisedFpsRanges());
3500
+ }
3501
+
3502
+ public int getVideoFrameRate() throws Exception {
3503
+ if (configuredVideoFrameRate != null) {
3504
+ return configuredVideoFrameRate;
3505
+ }
3506
+ return 30;
3507
+ }
3508
+
3509
+ public void setVideoFrameRate(int frameRate, Runnable onSuccess, java.util.function.Consumer<String> onError) {
3510
+ mainExecutor.execute(() -> {
3511
+ Integer previousFrameRate = configuredVideoFrameRate;
3512
+ Range<Integer> previousRange = configuredVideoFrameRateRange;
3513
+ try {
3514
+ if (currentRecording != null) {
3515
+ throw new Exception("Cannot change video frame rate while recording");
3516
+ }
3517
+ if (frameRate <= 0) {
3518
+ throw new Exception("frameRate must be greater than 0");
3519
+ }
3520
+
3521
+ List<Integer> supportedRates = getSupportedVideoFrameRates();
3522
+ if (!supportedRates.contains(frameRate)) {
3523
+ throw new Exception("Unsupported frame rate " + frameRate + ". Supported values: " + supportedRates);
3524
+ }
3525
+
3526
+ Range<Integer> advertisedRange = findAdvertisedFpsRangeForRate(frameRate, getAdvertisedFpsRanges());
3527
+ if (advertisedRange == null) {
3528
+ throw new Exception("Unsupported frame rate " + frameRate + " for the active camera");
3529
+ }
3530
+
3531
+ configuredVideoFrameRate = frameRate;
3532
+ configuredVideoFrameRateRange = advertisedRange;
3533
+ if (isRunning && cameraProvider != null) {
3534
+ pendingFrameRateBindSuccess = onSuccess;
3535
+ pendingFrameRateBindError = (message) -> {
3536
+ configuredVideoFrameRate = previousFrameRate;
3537
+ configuredVideoFrameRateRange = previousRange;
3538
+ onError.accept(message);
3539
+ };
3540
+ bindCameraUseCases();
3541
+ return;
3542
+ }
3543
+ onSuccess.run();
3544
+ } catch (Exception e) {
3545
+ configuredVideoFrameRate = previousFrameRate;
3546
+ configuredVideoFrameRateRange = previousRange;
3547
+ onError.accept(e.getMessage());
3548
+ }
3549
+ });
3550
+ }
3551
+
3360
3552
  private long showFocusIndicator(float x, float y) {
3361
3553
  // If preview is gone (e.g., stopping/closing), bail out safely
3362
3554
  if (previewContainer == null) {
@@ -3648,6 +3840,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3648
3840
 
3649
3841
  mainExecutor.execute(() -> {
3650
3842
  try {
3843
+ String previousDeviceId = currentDeviceId;
3651
3844
  CameraSessionConfiguration previousConfig = sessionConfig;
3652
3845
  CameraInfo targetCameraInfo = findAvailableCameraInfoById(deviceId);
3653
3846
  String fallbackPosition = resolveFallbackPositionForDeviceId(deviceId);
@@ -3690,6 +3883,9 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3690
3883
  );
3691
3884
  copyMutableSessionConfigState(previousConfig, updatedConfig);
3692
3885
  updatedConfig.setTargetZoom(1.0f);
3886
+ if (!Objects.equals(deviceId, previousDeviceId)) {
3887
+ clearConfiguredVideoFrameRate();
3888
+ }
3693
3889
  sessionConfig = updatedConfig;
3694
3890
 
3695
3891
  Log.d(TAG, "switchToDevice: Updated sessionConfig with deviceId: " + deviceId);
@@ -3703,6 +3899,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3703
3899
 
3704
3900
  public void flipCamera() {
3705
3901
  Log.d(TAG, "flipCamera: Flipping camera");
3902
+ clearConfiguredVideoFrameRate();
3706
3903
 
3707
3904
  // Determine current position based on session config and flip it
3708
3905
  String currentPosition = sessionConfig.getPosition();
@@ -4665,6 +4862,30 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
4665
4862
  }
4666
4863
  }
4667
4864
 
4865
+ public void startRecordVideo(
4866
+ Long maxDurationMillis,
4867
+ Long maxFileSize,
4868
+ Integer frameRate,
4869
+ Runnable onSuccess,
4870
+ java.util.function.Consumer<String> onError
4871
+ ) {
4872
+ Runnable startRecording = () -> {
4873
+ try {
4874
+ startRecordVideo(maxDurationMillis, maxFileSize);
4875
+ onSuccess.run();
4876
+ } catch (Exception e) {
4877
+ onError.accept(e.getMessage());
4878
+ }
4879
+ };
4880
+
4881
+ if (frameRate == null) {
4882
+ mainExecutor.execute(startRecording);
4883
+ return;
4884
+ }
4885
+
4886
+ setVideoFrameRate(frameRate, startRecording, onError);
4887
+ }
4888
+
4668
4889
  /** @noinspection ResultOfMethodCallIgnored*/
4669
4890
  public void startRecordVideo(Long maxDurationMillis, Long maxFileSize) throws Exception {
4670
4891
  if (videoCapture == null) {