@capgo/camera-preview 8.0.9 → 8.0.11

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
@@ -1317,3 +1317,14 @@ Reusable exposure mode type for cross-platform support.
1317
1317
  | **`TRIPLE`** | <code>'triple'</code> |
1318
1318
 
1319
1319
  </docgen-api>
1320
+
1321
+ ## Compatibility
1322
+
1323
+ | Plugin version | Capacitor compatibility | Maintained |
1324
+ | -------------- | ----------------------- | ---------- |
1325
+ | v8.\*.\* | v8.\*.\* | ✅ |
1326
+ | v7.\*.\* | v7.\*.\* | On demand |
1327
+ | v6.\*.\* | v6.\*.\* | ❌ |
1328
+ | v5.\*.\* | v5.\*.\* | ❌ |
1329
+
1330
+ > **Note:** The major version of this plugin follows the major version of Capacitor. Use the version that matches your Capacitor installation (e.g., plugin v8 for Capacitor 8). Only the latest major version is actively maintained.
@@ -936,7 +936,9 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
936
936
  if (originalWindowBackground == null) {
937
937
  originalWindowBackground = getBridge().getActivity().getWindow().getDecorView().getBackground();
938
938
  }
939
- getBridge().getActivity().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
939
+ // Set to solid black first to prevent flickering during transition
940
+ // This provides a stable base before camera preview is ready
941
+ getBridge().getActivity().getWindow().setBackgroundDrawable(new ColorDrawable(Color.BLACK));
940
942
  } catch (Exception ignored) {}
941
943
  }
942
944
  DisplayMetrics metrics = getBridge().getActivity().getResources().getDisplayMetrics();
@@ -1492,6 +1494,16 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
1492
1494
  return ret;
1493
1495
  }
1494
1496
 
1497
+ private boolean isToBackMode() {
1498
+ if (cameraXView != null) {
1499
+ CameraSessionConfiguration config = cameraXView.getSessionConfig();
1500
+ if (config != null) {
1501
+ return config.isToBack();
1502
+ }
1503
+ }
1504
+ return false;
1505
+ }
1506
+
1495
1507
  @Override
1496
1508
  public void onCameraStarted(int width, int height, int x, int y) {
1497
1509
  PluginCall call = bridge.getSavedCall(cameraStartCallbackId);
@@ -1624,6 +1636,20 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
1624
1636
  ")"
1625
1637
  );
1626
1638
 
1639
+ // Transition window background to transparent now that camera is ready
1640
+ // This prevents flickering during camera initialization
1641
+ if (isToBackMode()) {
1642
+ getBridge()
1643
+ .getActivity()
1644
+ .runOnUiThread(() -> {
1645
+ try {
1646
+ getBridge().getActivity().getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
1647
+ } catch (Exception e) {
1648
+ Log.w(TAG, "Failed to set window background to transparent", e);
1649
+ }
1650
+ });
1651
+ }
1652
+
1627
1653
  call.resolve(result);
1628
1654
  bridge.releaseCall(call);
1629
1655
  cameraStartCallbackId = null; // Prevent re-use
@@ -1664,6 +1690,25 @@ public class CameraPreview extends Plugin implements CameraXView.CameraXViewList
1664
1690
  bridge.releaseCall(call);
1665
1691
  cameraStartCallbackId = null;
1666
1692
  }
1693
+
1694
+ // Restore original window background on error to prevent black screen
1695
+ // Use synchronized block to ensure only one thread captures and clears the background.
1696
+ // Even if multiple errors occur, only the first will have a non-null background to restore.
1697
+ synchronized (this) {
1698
+ final Drawable backgroundToRestore = originalWindowBackground;
1699
+ if (backgroundToRestore != null) {
1700
+ originalWindowBackground = null; // Clear immediately so other threads won't restore
1701
+ getBridge()
1702
+ .getActivity()
1703
+ .runOnUiThread(() -> {
1704
+ try {
1705
+ getBridge().getActivity().getWindow().setBackgroundDrawable(backgroundToRestore);
1706
+ } catch (Exception e) {
1707
+ Log.w(TAG, "Failed to restore window background on error", e);
1708
+ }
1709
+ });
1710
+ }
1711
+ }
1667
1712
  }
1668
1713
 
1669
1714
  @PluginMethod
@@ -13,6 +13,10 @@ import android.graphics.Paint;
13
13
  import android.graphics.Rect;
14
14
  import android.graphics.Typeface;
15
15
  import android.graphics.drawable.GradientDrawable;
16
+ import android.hardware.Sensor;
17
+ import android.hardware.SensorEvent;
18
+ import android.hardware.SensorEventListener;
19
+ import android.hardware.SensorManager;
16
20
  import android.hardware.camera2.CameraAccessException;
17
21
  import android.hardware.camera2.CameraCharacteristics;
18
22
  import android.hardware.camera2.CameraManager;
@@ -139,6 +143,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
139
143
  private CameraXViewListener listener;
140
144
  private final Context context;
141
145
  private final WebView webView;
146
+ // WebView's default background is white; we store this to restore on error or cleanup
147
+ // Note: WebView doesn't provide a way to query its current background color, so we assume
148
+ // the default white background. This is consistent across Android versions.
149
+ private int originalWebViewBackground = android.graphics.Color.WHITE;
142
150
  private final LifecycleRegistry lifecycleRegistry;
143
151
  private final Executor mainExecutor;
144
152
  private ExecutorService cameraExecutor;
@@ -157,6 +165,31 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
157
165
  private int activeOperations = 0;
158
166
  private boolean stopPending = false;
159
167
 
168
+ // Sensor Fields
169
+ private SensorManager sensorManager;
170
+ private Sensor accelerometer;
171
+ private final float[] lastAccelerometerValues = new float[3]; // x,y, and z
172
+ private final Object accelerometerLock = new Object();
173
+ private volatile int lastCaptureRotation = -1; // -1 unknown
174
+
175
+ private final SensorEventListener accelerometerListener = new SensorEventListener() {
176
+ @Override
177
+ public void onSensorChanged(SensorEvent event) {
178
+ if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
179
+ synchronized (accelerometerLock) {
180
+ lastAccelerometerValues[0] = event.values[0];
181
+ lastAccelerometerValues[1] = event.values[1];
182
+ lastAccelerometerValues[2] = event.values[2];
183
+ }
184
+ }
185
+ }
186
+
187
+ @Override
188
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
189
+ // Not Needed
190
+ }
191
+ };
192
+
160
193
  private boolean IsOperationRunning(String name) {
161
194
  synchronized (operationLock) {
162
195
  if (stopPending) {
@@ -336,6 +369,23 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
336
369
  public void startSession(CameraSessionConfiguration config) {
337
370
  this.sessionConfig = config;
338
371
  cameraExecutor = Executors.newSingleThreadExecutor();
372
+
373
+ // Reset cached orientation so we don't reuse stale values across sessions
374
+ synchronized (accelerometerLock) {
375
+ lastAccelerometerValues[0] = 0f;
376
+ lastAccelerometerValues[1] = 0f;
377
+ lastAccelerometerValues[2] = 0f;
378
+ }
379
+ lastCaptureRotation = -1;
380
+
381
+ // Start accelerometer for orientation detection regardless of lock
382
+ if (sensorManager == null) {
383
+ sensorManager = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
384
+ accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
385
+ }
386
+ if (accelerometer != null) {
387
+ sensorManager.registerListener(accelerometerListener, accelerometer, SensorManager.SENSOR_DELAY_UI);
388
+ }
339
389
  synchronized (operationLock) {
340
390
  activeOperations = 0;
341
391
  stopPending = false;
@@ -386,6 +436,10 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
386
436
 
387
437
  private void performImmediateStop() {
388
438
  isRunning = false;
439
+ // Stop accelerometer
440
+ if (sensorManager != null && accelerometer != null) {
441
+ sensorManager.unregisterListener(accelerometerListener);
442
+ }
389
443
  // Cancel any ongoing focus operation when stopping session
390
444
  if (currentFocusFuture != null && !currentFocusFuture.isDone()) {
391
445
  currentFocusFuture.cancel(true);
@@ -421,6 +475,22 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
421
475
  });
422
476
  }
423
477
 
478
+ private void restoreWebViewBackground() {
479
+ // Capture sessionConfig reference once to avoid race conditions
480
+ CameraSessionConfiguration config = sessionConfig;
481
+ boolean shouldRestore = config != null && config.isToBack();
482
+ if (shouldRestore) {
483
+ // Capture background color before posting to UI thread
484
+ final int backgroundColorToRestore = originalWebViewBackground;
485
+ webView.post(() -> {
486
+ // Additional safety check in case webView context changed
487
+ if (webView != null) {
488
+ webView.setBackgroundColor(backgroundColorToRestore);
489
+ }
490
+ });
491
+ }
492
+ }
493
+
424
494
  private void setupCamera() {
425
495
  ListenableFuture<ProcessCameraProvider> cameraProviderFuture = ProcessCameraProvider.getInstance(context);
426
496
  cameraProviderFuture.addListener(
@@ -430,6 +500,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
430
500
  setupPreviewView();
431
501
  bindCameraUseCases();
432
502
  } catch (Exception e) {
503
+ // Restore webView background on error
504
+ restoreWebViewBackground();
433
505
  if (listener != null) {
434
506
  listener.onCameraStartError("Error initializing camera: " + e.getMessage());
435
507
  }
@@ -444,7 +516,8 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
444
516
  removePreviewView();
445
517
  }
446
518
  if (sessionConfig.isToBack()) {
447
- webView.setBackgroundColor(android.graphics.Color.TRANSPARENT);
519
+ // Set to black initially to prevent flickering, will be transparent after camera starts
520
+ webView.setBackgroundColor(android.graphics.Color.BLACK);
448
521
  }
449
522
 
450
523
  // Create a container to hold both the preview and grid overlay
@@ -542,6 +615,19 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
542
615
  }
543
616
  }
544
617
 
618
+ /**
619
+ * Compute layout parameters for the camera preview container based on the current session configuration,
620
+ * device screen size, WebView/parent geometry, and optional aspect-ratio centering.
621
+ *
622
+ * The returned FrameLayout.LayoutParams contains width, height, leftMargin (x) and topMargin (y)
623
+ * for placing the preview. When an aspect ratio is specified and sessionConfig is in centered mode,
624
+ * the preview size is scaled to the largest area that fits the aspect ratio within the screen and
625
+ * any axis with a coordinate equal to -1 is auto-centered for that axis; axes explicitly provided
626
+ * in sessionConfig are preserved. Coordinates supplied by sessionConfig are assumed to already
627
+ * include WebView insets.
628
+ *
629
+ * @return a FrameLayout.LayoutParams configured with the computed preview width, height, leftMargin and topMargin
630
+ */
545
631
  private FrameLayout.LayoutParams calculatePreviewLayoutParams() {
546
632
  // sessionConfig already contains pixel-converted coordinates with webview offsets applied
547
633
  int x = sessionConfig.getX();
@@ -663,9 +749,13 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
663
749
  Log.d(TAG, "Width-limited sizing: " + width + "x" + height);
664
750
  }
665
751
 
666
- // Center the preview
667
- x = (screenWidthPx - width) / 2;
668
- y = (screenHeightPx - height) / 2;
752
+ // Center the preview only overwrite what was not explicitly set
753
+ if (sessionConfig.getX() == -1) {
754
+ x = (screenWidthPx - width) / 2;
755
+ }
756
+ if (sessionConfig.getY() == -1) {
757
+ y = (screenHeightPx - height) / 2;
758
+ }
669
759
 
670
760
  Log.d(TAG, "Auto-centered position: x=" + x + ", y=" + y);
671
761
  } catch (NumberFormatException e) {
@@ -714,7 +804,7 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
714
804
  if (focusIndicatorView != null) {
715
805
  focusIndicatorView = null;
716
806
  }
717
- webView.setBackgroundColor(android.graphics.Color.WHITE);
807
+ webView.setBackgroundColor(originalWebViewBackground);
718
808
  }
719
809
 
720
810
  @OptIn(markerClass = ExperimentalCamera2Interop.class)
@@ -784,8 +874,14 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
784
874
  // Bind with or without video capture based on enableVideoMode
785
875
  if (sessionConfig.isVideoModeEnabled() && videoCapture != null) {
786
876
  camera = cameraProvider.bindToLifecycle(this, currentCameraSelector, preview, imageCapture, videoCapture);
877
+ CameraInfo cameraInfo = camera.getCameraInfo();
878
+ currentDeviceId = Camera2CameraInfo.from(cameraInfo).getCameraId();
879
+ Log.d(TAG, "bindCameraUseCases: Camera successfully bound to device ID: " + currentDeviceId);
787
880
  } else {
788
881
  camera = cameraProvider.bindToLifecycle(this, currentCameraSelector, preview, imageCapture);
882
+ CameraInfo cameraInfo = camera.getCameraInfo();
883
+ currentDeviceId = Camera2CameraInfo.from(cameraInfo).getCameraId();
884
+ Log.d(TAG, "bindCameraUseCases: Camera successfully bound to device ID: " + currentDeviceId);
789
885
  }
790
886
 
791
887
  resetExposureCompensationToDefault();
@@ -926,10 +1022,20 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
926
1022
  // Update grid overlay bounds after camera is started
927
1023
  updateGridOverlayBounds();
928
1024
 
1025
+ // Now transition to transparent background after camera is ready
1026
+ // This prevents flickering during camera initialization
1027
+ if (sessionConfig.isToBack()) {
1028
+ webView.post(() -> {
1029
+ webView.setBackgroundColor(android.graphics.Color.TRANSPARENT);
1030
+ });
1031
+ }
1032
+
929
1033
  listener.onCameraStarted(actualWidth, actualHeight, actualX, actualY);
930
1034
  });
931
1035
  }
932
1036
  } catch (Exception e) {
1037
+ // Restore webView background on error
1038
+ restoreWebViewBackground();
933
1039
  if (listener != null) listener.onCameraStartError("Error binding camera: " + e.getMessage());
934
1040
  }
935
1041
  });
@@ -971,6 +1077,50 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
971
1077
  }
972
1078
  }
973
1079
 
1080
+ /**
1081
+ * Get device rotation from accelerometer data.
1082
+ * This works even when portrait lock is enabled.
1083
+ * Falls back to display rotation if accelerometer data is not available.
1084
+ */
1085
+ private int getRotationFromAccelerometer() {
1086
+ float x, y;
1087
+ synchronized (accelerometerLock) {
1088
+ x = lastAccelerometerValues[0];
1089
+ y = lastAccelerometerValues[1];
1090
+ }
1091
+
1092
+ // If no accelerometer data yet, fall back to display rotation
1093
+ final float epsilon = 1.0f;
1094
+ if (Math.abs(x) < epsilon && Math.abs(y) < epsilon) {
1095
+ if (previewView != null && previewView.getDisplay() != null) {
1096
+ return previewView.getDisplay().getRotation();
1097
+ }
1098
+ return android.view.Surface.ROTATION_0;
1099
+ }
1100
+
1101
+ // Android accelerometer: +X is right, +Y is up, +Z is toward user
1102
+ // Determine orientation based on which axis has the strongest gravity component
1103
+ if (Math.abs(x) > Math.abs(y)) {
1104
+ // Landscape orientation
1105
+ if (x > 0) {
1106
+ // Device tilted to the left (top of device points left)
1107
+ return android.view.Surface.ROTATION_90;
1108
+ } else {
1109
+ // Device tilted to the right (top of device points right)
1110
+ return android.view.Surface.ROTATION_270;
1111
+ }
1112
+ } else {
1113
+ // Portrait orientation
1114
+ if (y > 0) {
1115
+ // Normal portrait (top of device points up)
1116
+ return android.view.Surface.ROTATION_0;
1117
+ } else {
1118
+ // Upside down portrait
1119
+ return android.view.Surface.ROTATION_180;
1120
+ }
1121
+ }
1122
+ }
1123
+
974
1124
  public void capturePhoto(
975
1125
  int quality,
976
1126
  final boolean saveToGallery,
@@ -993,6 +1143,12 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
993
1143
  return;
994
1144
  }
995
1145
 
1146
+ // Set rotation from accelerometer for device orientation regardless of lock
1147
+ int rotation = getRotationFromAccelerometer();
1148
+ lastCaptureRotation = rotation;
1149
+ imageCapture.setTargetRotation(rotation);
1150
+ Log.d(TAG, "capturePhoto: Set target rotation to " + rotation + " from accelerometer");
1151
+
996
1152
  Log.d(
997
1153
  TAG,
998
1154
  "capturePhoto: Starting photo capture with: " +
@@ -1744,6 +1900,19 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1744
1900
  Rect bounds = getActualCameraBounds();
1745
1901
  int previewW = Math.max(1, bounds.width());
1746
1902
  int previewH = Math.max(1, bounds.height());
1903
+
1904
+ // Check if device physical orientation differs from UI orientation
1905
+ int rotation = (lastCaptureRotation != -1) ? lastCaptureRotation : getRotationFromAccelerometer();
1906
+ boolean physicalInLandscape = (rotation == android.view.Surface.ROTATION_90 || rotation == android.view.Surface.ROTATION_270);
1907
+ boolean previewIsPortrait = previewH > previewW;
1908
+
1909
+ // If physical orientation doesn't match preview orientation swap ratio
1910
+ if (physicalInLandscape == previewIsPortrait) {
1911
+ int temp = previewW;
1912
+ previewW = previewH;
1913
+ previewH = temp;
1914
+ }
1915
+
1747
1916
  float previewRatio = (float) previewW / (float) previewH;
1748
1917
 
1749
1918
  int imgW = image.getWidth();
@@ -1771,26 +1940,65 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1771
1940
  // not working for xiaomi https://xiaomi.eu/community/threads/mi-11-ultra-unable-to-access-camera-lenses-in-apps-camera2-api.61456/
1772
1941
  @OptIn(markerClass = ExperimentalCamera2Interop.class)
1773
1942
  public static List<app.capgo.capacitor.camera.preview.model.CameraDevice> getAvailableDevicesStatic(Context context) {
1774
- Log.d(TAG, "getAvailableDevicesStatic: Starting CameraX device enumeration with getPhysicalCameraInfos.");
1943
+ Log.d(TAG, "=== Starting Camera Enumeration ===");
1775
1944
  List<app.capgo.capacitor.camera.preview.model.CameraDevice> devices = new ArrayList<>();
1776
1945
  try {
1777
1946
  ListenableFuture<ProcessCameraProvider> cameraProviderFuture = ProcessCameraProvider.getInstance(context);
1778
1947
  ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
1779
1948
  CameraManager cameraManager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
1780
1949
 
1781
- for (CameraInfo cameraInfo : cameraProvider.getAvailableCameraInfos()) {
1950
+ List<CameraInfo> availableCameras = cameraProvider.getAvailableCameraInfos();
1951
+
1952
+ for (CameraInfo cameraInfo : availableCameras) {
1782
1953
  String logicalCameraId = Camera2CameraInfo.from(cameraInfo).getCameraId();
1783
1954
  String position = isBackCamera(cameraInfo) ? "rear" : "front";
1784
1955
 
1785
1956
  // Add logical camera
1786
- float minZoom = Objects.requireNonNull(cameraInfo.getZoomState().getValue()).getMinZoomRatio();
1787
- float maxZoom = cameraInfo.getZoomState().getValue().getMaxZoomRatio();
1957
+ ZoomState zoomState = cameraInfo.getZoomState().getValue();
1958
+ float minZoom = zoomState != null ? zoomState.getMinZoomRatio() : 1.0f;
1959
+ float maxZoom = zoomState != null ? zoomState.getMaxZoomRatio() : 1.0f;
1960
+
1961
+ // Determine device type by analyzing camera characteristics
1962
+ String deviceType = "wideAngle";
1963
+ float focalLength = 4.25f;
1964
+
1965
+ try {
1966
+ CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(logicalCameraId);
1967
+ float[] focalLengths = characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS);
1968
+ android.util.SizeF sensorSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE);
1969
+
1970
+ if (focalLengths != null && focalLengths.length > 0) {
1971
+ focalLength = focalLengths[0];
1972
+
1973
+ // Calculate FOV to determine camera type
1974
+ if (sensorSize != null && sensorSize.getWidth() > 0) {
1975
+ double fov = 2 * Math.toDegrees(Math.atan(sensorSize.getWidth() / (2 * focalLength)));
1976
+ if (fov > 90) {
1977
+ deviceType = "ultraWide";
1978
+ } else if (fov < 40) {
1979
+ deviceType = "telephoto";
1980
+ }
1981
+ } else {
1982
+ // Fallback: classify by focal length alone
1983
+ if (focalLength < 3.0f) {
1984
+ deviceType = "ultraWide";
1985
+ } else if (focalLength > 5.0f) {
1986
+ deviceType = "telephoto";
1987
+ }
1988
+ }
1989
+ }
1990
+ } catch (CameraAccessException e) {
1991
+ Log.e(TAG, "Failed to get characteristics for " + logicalCameraId, e);
1992
+ }
1788
1993
  List<LensInfo> logicalLenses = new ArrayList<>();
1789
- logicalLenses.add(new LensInfo(4.25f, "wideAngle", 1.0f, maxZoom));
1994
+ logicalLenses.add(new LensInfo(focalLength, deviceType, 1.0f, maxZoom));
1995
+
1996
+ String label = "Logical " + deviceType + " (" + position + ")";
1997
+
1790
1998
  devices.add(
1791
1999
  new app.capgo.capacitor.camera.preview.model.CameraDevice(
1792
2000
  logicalCameraId,
1793
- "Logical Camera (" + position + ")",
2001
+ label,
1794
2002
  position,
1795
2003
  logicalLenses,
1796
2004
  minZoom,
@@ -1798,32 +2006,59 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1798
2006
  true
1799
2007
  )
1800
2008
  );
1801
- Log.d(TAG, "Found logical camera: " + logicalCameraId + " (" + position + ") with zoom " + minZoom + "-" + maxZoom);
2009
+ Log.d(TAG, "Added logical camera: " + logicalCameraId + " zoom: " + minZoom + "-" + maxZoom);
1802
2010
 
1803
2011
  // Get and add physical cameras
1804
2012
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
1805
2013
  Set<CameraInfo> physicalCameraInfos = cameraInfo.getPhysicalCameraInfos();
1806
- if (physicalCameraInfos.isEmpty()) continue;
2014
+ Log.d(TAG, "Physical camera count from CameraX: " + physicalCameraInfos.size());
1807
2015
 
1808
- Log.d(TAG, "Logical camera " + logicalCameraId + " has " + physicalCameraInfos.size() + " physical cameras.");
2016
+ if (physicalCameraInfos.isEmpty()) {
2017
+ Log.w(TAG, "No physical cameras exposed through CameraX for " + logicalCameraId);
2018
+
2019
+ // Try to get physical IDs from CameraManager
2020
+ try {
2021
+ CameraCharacteristics chars = cameraManager.getCameraCharacteristics(logicalCameraId);
2022
+ Set<String> physicalIds = chars.getPhysicalCameraIds();
2023
+ Log.d(TAG, "CameraManager reports " + physicalIds.size() + " physical cameras for " + logicalCameraId);
2024
+ for (String pid : physicalIds) {
2025
+ Log.d(TAG, " Physical camera ID: " + pid);
2026
+ }
2027
+ } catch (CameraAccessException e) {
2028
+ Log.e(TAG, "Failed to get characteristics", e);
2029
+ }
2030
+ continue;
2031
+ }
1809
2032
 
1810
2033
  for (CameraInfo physicalCameraInfo : physicalCameraInfos) {
1811
2034
  String physicalId = Camera2CameraInfo.from(physicalCameraInfo).getCameraId();
1812
- if (physicalId.equals(logicalCameraId)) continue; // Already added as logical
2035
+ Log.d(TAG, "Processing physical camera: " + physicalId);
2036
+
2037
+ if (physicalId.equals(logicalCameraId)) {
2038
+ Log.d(TAG, "Skipping - same as logical ID");
2039
+ continue;
2040
+ }
1813
2041
 
1814
2042
  try {
1815
2043
  CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(physicalId);
1816
- String deviceType = "wideAngle";
2044
+ String physicalDeviceType = "wideAngle";
1817
2045
  float[] focalLengths = characteristics.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS);
1818
2046
  android.util.SizeF sensorSize = characteristics.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE);
1819
2047
 
2048
+ Log.d(TAG, " Focal lengths: " + (focalLengths != null ? Arrays.toString(focalLengths) : "null"));
2049
+ Log.d(
2050
+ TAG,
2051
+ " Sensor size: " + (sensorSize != null ? sensorSize.getWidth() + "x" + sensorSize.getHeight() : "null")
2052
+ );
2053
+
1820
2054
  if (focalLengths != null && focalLengths.length > 0 && sensorSize != null && sensorSize.getWidth() > 0) {
1821
2055
  double fov = 2 * Math.toDegrees(Math.atan(sensorSize.getWidth() / (2 * focalLengths[0])));
1822
- if (fov > 90) deviceType = "ultraWide";
1823
- else if (fov < 40) deviceType = "telephoto";
2056
+ Log.d(TAG, " Calculated FOV: " + fov);
2057
+ if (fov > 90) physicalDeviceType = "ultraWide";
2058
+ else if (fov < 40) physicalDeviceType = "telephoto";
1824
2059
  } else if (focalLengths != null && focalLengths.length > 0) {
1825
- if (focalLengths[0] < 3.0f) deviceType = "ultraWide";
1826
- else if (focalLengths[0] > 5.0f) deviceType = "telephoto";
2060
+ if (focalLengths[0] < 3.0f) physicalDeviceType = "ultraWide";
2061
+ else if (focalLengths[0] > 5.0f) physicalDeviceType = "telephoto";
1827
2062
  }
1828
2063
 
1829
2064
  float physicalMinZoom = 1.0f;
@@ -1835,17 +2070,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1835
2070
  physicalMaxZoom = zoomRange.getUpper();
1836
2071
  }
1837
2072
  }
1838
-
1839
- String label = "Physical " + deviceType + " (" + position + ")";
2073
+ float physicalFocalLength = (focalLengths != null && focalLengths.length > 0) ? focalLengths[0] : 4.25f;
2074
+ String physicalLabel = "Physical " + physicalDeviceType + " (" + position + ")";
1840
2075
  List<LensInfo> physicalLenses = new ArrayList<>();
1841
- physicalLenses.add(
1842
- new LensInfo(focalLengths != null ? focalLengths[0] : 4.25f, deviceType, 1.0f, physicalMaxZoom)
1843
- );
2076
+ physicalLenses.add(new LensInfo(physicalFocalLength, physicalDeviceType, 1.0f, physicalMaxZoom));
1844
2077
 
1845
2078
  devices.add(
1846
2079
  new app.capgo.capacitor.camera.preview.model.CameraDevice(
1847
2080
  physicalId,
1848
- label,
2081
+ physicalLabel,
1849
2082
  position,
1850
2083
  physicalLenses,
1851
2084
  physicalMinZoom,
@@ -1853,13 +2086,15 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1853
2086
  false
1854
2087
  )
1855
2088
  );
1856
- Log.d(TAG, "Found physical camera: " + physicalId + " (" + label + ")");
2089
+ Log.d(TAG, "Added physical camera: " + physicalId + " (" + physicalDeviceType + ")");
1857
2090
  } catch (CameraAccessException e) {
1858
2091
  Log.e(TAG, "Failed to access characteristics for physical camera " + physicalId, e);
1859
2092
  }
1860
2093
  }
1861
2094
  }
1862
2095
  }
2096
+
2097
+ Log.d(TAG, "=== Enumeration Complete: " + devices.size() + " cameras ===");
1863
2098
  return devices;
1864
2099
  } catch (Exception e) {
1865
2100
  Log.e(TAG, "getAvailableDevicesStatic: Error getting devices", e);
@@ -1975,18 +2210,21 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
1975
2210
  currentFocusFuture.cancel(true);
1976
2211
  }
1977
2212
 
1978
- // Reset exposure compensation to 0 on tap-to-focus
1979
- try {
1980
- ExposureState state = camera.getCameraInfo().getExposureState();
1981
- Range<Integer> range = state.getExposureCompensationRange();
1982
- int zeroIdx = 0;
1983
- if (!range.contains(0)) {
1984
- // Choose the closest index to 0 if 0 is not available
1985
- zeroIdx = Math.abs(range.getLower()) < Math.abs(range.getUpper()) ? range.getLower() : range.getUpper();
2213
+ //If locked don't auto adjust exposure
2214
+ if (!"LOCK".equals(currentExposureMode)) {
2215
+ // Reset exposure compensation to 0 on tap-to-focus
2216
+ try {
2217
+ ExposureState state = camera.getCameraInfo().getExposureState();
2218
+ Range<Integer> range = state.getExposureCompensationRange();
2219
+ int zeroIdx = 0;
2220
+ if (!range.contains(0)) {
2221
+ // Choose the closest index to 0 if 0 is not available
2222
+ zeroIdx = Math.abs(range.getLower()) < Math.abs(range.getUpper()) ? range.getLower() : range.getUpper();
2223
+ }
2224
+ camera.getCameraControl().setExposureCompensationIndex(zeroIdx);
2225
+ } catch (Exception e) {
2226
+ Log.w(TAG, "setFocus: Failed to reset exposure compensation to 0", e);
1986
2227
  }
1987
- camera.getCameraControl().setExposureCompensationIndex(zeroIdx);
1988
- } catch (Exception e) {
1989
- Log.w(TAG, "setFocus: Failed to reset exposure compensation to 0", e);
1990
2228
  }
1991
2229
 
1992
2230
  int viewWidth = previewView.getWidth();
@@ -2458,40 +2696,60 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
2458
2696
 
2459
2697
  @OptIn(markerClass = ExperimentalCamera2Interop.class)
2460
2698
  public void switchToDevice(String deviceId) {
2699
+ Log.d(TAG, "======================== SWITCH TO DEVICE ========================");
2461
2700
  Log.d(TAG, "switchToDevice: Attempting to switch to device " + deviceId);
2462
2701
 
2463
2702
  mainExecutor.execute(() -> {
2464
2703
  try {
2465
2704
  // Standard physical device selection logic...
2466
2705
  List<CameraInfo> cameraInfos = cameraProvider.getAvailableCameraInfos();
2706
+
2467
2707
  CameraInfo targetCameraInfo = null;
2468
2708
  for (CameraInfo cameraInfo : cameraInfos) {
2469
- if (deviceId.equals(Camera2CameraInfo.from(cameraInfo).getCameraId())) {
2709
+ String id = Camera2CameraInfo.from(cameraInfo).getCameraId();
2710
+ if (deviceId.equals(id)) {
2470
2711
  targetCameraInfo = cameraInfo;
2471
2712
  break;
2472
2713
  }
2473
2714
  }
2474
2715
 
2475
2716
  if (targetCameraInfo != null) {
2476
- Log.d(TAG, "switchToDevice: Found matching CameraInfo for deviceId: " + deviceId);
2477
- final CameraInfo finalTarget = targetCameraInfo;
2478
-
2479
- // This filter will receive a list of all cameras and must return the one we want.
2480
-
2481
- currentCameraSelector = new CameraSelector.Builder()
2482
- .addCameraFilter((cameras) -> {
2483
- // This filter will receive a list of all cameras and must return the one we want.
2484
- return Collections.singletonList(finalTarget);
2485
- })
2486
- .build();
2487
- currentDeviceId = deviceId;
2488
- bindCameraUseCases(); // Rebind with the new, highly specific selector
2717
+ // Determine position from the target camera
2718
+ String position = isBackCamera(targetCameraInfo) ? "rear" : "front";
2719
+ boolean wasCentered = sessionConfig.isCentered();
2720
+
2721
+ // Update sessionConfig with the new device ID
2722
+ sessionConfig = new CameraSessionConfiguration(
2723
+ deviceId,
2724
+ position,
2725
+ sessionConfig.getX(),
2726
+ sessionConfig.getY(),
2727
+ sessionConfig.getWidth(),
2728
+ sessionConfig.getHeight(),
2729
+ sessionConfig.getPaddingBottom(),
2730
+ sessionConfig.getToBack(),
2731
+ sessionConfig.getStoreToFile(),
2732
+ sessionConfig.getEnableOpacity(),
2733
+ sessionConfig.getDisableExifHeaderStripping(),
2734
+ sessionConfig.getDisableAudio(),
2735
+ sessionConfig.getZoomFactor(),
2736
+ sessionConfig.getAspectRatio(),
2737
+ sessionConfig.getGridMode(),
2738
+ sessionConfig.getDisableFocusIndicator(),
2739
+ sessionConfig.isVideoModeEnabled()
2740
+ );
2741
+
2742
+ sessionConfig.setCentered(wasCentered);
2743
+
2744
+ Log.d(TAG, "switchToDevice: Updated sessionConfig with deviceId: " + deviceId);
2745
+ bindCameraUseCases(); // Will now use deviceId from sessionConfig
2489
2746
  } else {
2490
2747
  Log.e(TAG, "switchToDevice: Could not find any CameraInfo matching deviceId: " + deviceId);
2491
2748
  }
2492
2749
  } catch (Exception e) {
2493
2750
  Log.e(TAG, "switchToDevice: Error switching camera", e);
2494
2751
  }
2752
+ Log.d(TAG, "================================================================");
2495
2753
  });
2496
2754
  }
2497
2755
 
@@ -3447,6 +3705,11 @@ public class CameraXView implements LifecycleOwner, LifecycleObserver {
3447
3705
  throw new Exception("Video recording is already in progress");
3448
3706
  }
3449
3707
 
3708
+ // Update video capture rotation from accelerometer for device orientation
3709
+ int rotation = getRotationFromAccelerometer();
3710
+ videoCapture.setTargetRotation(rotation);
3711
+ Log.d(TAG, "startRecordVideo: Using rotation " + rotation + " from accelerometer");
3712
+
3450
3713
  // Create output file
3451
3714
  String fileName = "video_" + System.currentTimeMillis() + ".mp4";
3452
3715
  File outputDir = new File(context.getExternalFilesDir(Environment.DIRECTORY_MOVIES), "CameraPreview");
@@ -2,6 +2,7 @@ import AVFoundation
2
2
  import UIKit
3
3
  import CoreLocation
4
4
  import UniformTypeIdentifiers
5
+ import CoreMotion
5
6
 
6
7
  class CameraController: NSObject {
7
8
  private func getVideoOrientation() -> AVCaptureVideoOrientation {
@@ -37,6 +38,24 @@ class CameraController: NSObject {
37
38
  return orientation
38
39
  }
39
40
 
41
+ // For capture only - uses accelerometer to detect physical orientation to properly position videos/images
42
+ private func getPhysicalOrientation() -> AVCaptureVideoOrientation {
43
+ guard let accelerometerData = motionManager.accelerometerData else {
44
+ return lastCaptureOrientation ?? getVideoOrientation() // Fallback to interface in case of accelerometer fail
45
+ }
46
+
47
+ let x = accelerometerData.acceleration.x
48
+ let y = accelerometerData.acceleration.y
49
+
50
+ if abs(x) > abs(y) {
51
+ // Landscape
52
+ return x > 0 ? .landscapeLeft : .landscapeRight
53
+ } else {
54
+ // Portrait
55
+ return y > 0 ? .portraitUpsideDown : .portrait
56
+ }
57
+ }
58
+
40
59
  var captureSession: AVCaptureSession?
41
60
  var disableFocusIndicator: Bool = false
42
61
 
@@ -74,6 +93,8 @@ class CameraController: NSObject {
74
93
  var zoomFactor: CGFloat = 1.0
75
94
  private var lastZoomUpdateTime: TimeInterval = 0
76
95
  private let zoomUpdateThrottle: TimeInterval = 1.0 / 60.0 // 60 FPS max
96
+ private let motionManager = CMMotionManager()
97
+ private var lastCaptureOrientation: AVCaptureVideoOrientation?
77
98
 
78
99
  var videoFileURL: URL?
79
100
  private let saneMaxZoomFactor: CGFloat = 25.5
@@ -338,6 +359,16 @@ extension CameraController {
338
359
  return
339
360
  }
340
361
 
362
+ // Start accelerometer
363
+ var startedAccelerometer = false
364
+ if self.motionManager.isAccelerometerAvailable {
365
+ self.motionManager.accelerometerUpdateInterval = 1.0 / 60.0
366
+ if !self.motionManager.isAccelerometerActive {
367
+ self.motionManager.startAccelerometerUpdates()
368
+ startedAccelerometer = true
369
+ }
370
+ }
371
+
341
372
  do {
342
373
  // Create session if needed
343
374
  if self.captureSession == nil {
@@ -426,6 +457,9 @@ extension CameraController {
426
457
  completionHandler(nil)
427
458
  }
428
459
  } catch {
460
+ if startedAccelerometer {
461
+ self.motionManager.stopAccelerometerUpdates()
462
+ }
429
463
  DispatchQueue.main.async {
430
464
  completionHandler(error)
431
465
  }
@@ -900,6 +934,12 @@ extension CameraController {
900
934
  return
901
935
  }
902
936
 
937
+ // Make sure capture is getting the physical orientation not interface orientation
938
+ if let connection = photoOutput.connection(with: .video) {
939
+ let captureOrientation = self.getPhysicalOrientation()
940
+ self.lastCaptureOrientation = captureOrientation
941
+ connection.videoOrientation = captureOrientation
942
+ }
903
943
  let settings = AVCapturePhotoSettings()
904
944
  // Configure photo capture settings optimized for speed
905
945
  // Only use high res if explicitly requesting large dimensions
@@ -1285,11 +1325,28 @@ extension CameraController {
1285
1325
  }
1286
1326
 
1287
1327
  func cropImageToAspectRatio(image: UIImage, aspectRatio: String) -> UIImage? {
1288
- guard let ratio = parseAspectRatio(aspectRatio) else {
1328
+ let components = aspectRatio.split(separator: ":").compactMap {Float(String($0))}
1329
+ guard components.count == 2 else {
1289
1330
  print("[CameraPreview] cropImageToAspectRatio - Failed to parse aspect ratio: \(aspectRatio)")
1290
1331
  return image
1291
1332
  }
1292
1333
 
1334
+ // Use physical orientation for capture works with portrait lock
1335
+ let orientation = self.lastCaptureOrientation ?? self.getPhysicalOrientation()
1336
+ let isPortrait = (orientation == .portrait || orientation == .portraitUpsideDown)
1337
+
1338
+ let ratioWidth: CGFloat
1339
+ let ratioHeight: CGFloat
1340
+ if isPortrait {
1341
+ // For portrait 4:3 becomes 3:4, 16:9 becomes 9:16
1342
+ ratioWidth = CGFloat(components[1])
1343
+ ratioHeight = CGFloat(components[0])
1344
+ } else {
1345
+ // For landscape keep original
1346
+ ratioWidth = CGFloat(components[0])
1347
+ ratioHeight = CGFloat(components[1])
1348
+ }
1349
+
1293
1350
  // Only normalize the image orientation if it's not already correct
1294
1351
  let normalizedImage: UIImage
1295
1352
  if image.imageOrientation == .up {
@@ -1302,10 +1359,10 @@ extension CameraController {
1302
1359
 
1303
1360
  let imageSize = normalizedImage.size
1304
1361
  let imageAspectRatio = imageSize.width / imageSize.height
1305
- let targetAspectRatio = ratio.width / ratio.height
1362
+ let targetAspectRatio = ratioWidth / ratioHeight
1306
1363
 
1307
1364
  print("[CameraPreview] cropImageToAspectRatio - Original image: \(imageSize.width)x\(imageSize.height) (ratio: \(imageAspectRatio))")
1308
- print("[CameraPreview] cropImageToAspectRatio - Target ratio: \(ratio.width):\(ratio.height) (ratio: \(targetAspectRatio))")
1365
+ print("[CameraPreview] cropImageToAspectRatio - Target ratio: \(ratioWidth):\(ratioHeight) (ratio: \(targetAspectRatio))")
1309
1366
 
1310
1367
  var cropRect: CGRect
1311
1368
 
@@ -1698,11 +1755,15 @@ extension CameraController {
1698
1755
  // Set the focus point
1699
1756
  device.focusPointOfInterest = point
1700
1757
 
1701
- // Also set exposure point if supported
1702
- if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1703
- device.exposureMode = .autoExpose
1704
- device.setExposureTargetBias(0.0) { _ in }
1705
- device.exposurePointOfInterest = point
1758
+ // Skip exposure point if exposure locked
1759
+ if device.exposureMode != .locked {
1760
+
1761
+ // Also set exposure point if supported
1762
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
1763
+ device.exposureMode = .autoExpose
1764
+ device.setExposureTargetBias(0.0) { _ in }
1765
+ device.exposurePointOfInterest = point
1766
+ }
1706
1767
  }
1707
1768
 
1708
1769
  device.unlockForConfiguration()
@@ -1878,6 +1939,7 @@ extension CameraController {
1878
1939
  captureSession.outputs.forEach { captureSession.removeOutput($0) }
1879
1940
  }
1880
1941
 
1942
+ self.motionManager.stopAccelerometerUpdates()
1881
1943
  self.previewLayer?.removeFromSuperlayer()
1882
1944
  self.previewLayer = nil
1883
1945
 
@@ -2103,18 +2165,8 @@ extension CameraController {
2103
2165
  //
2104
2166
  if let connection = fileVideoOutput.connection(with: .video) {
2105
2167
  if connection.isEnabled == false { connection.isEnabled = true }
2106
- switch UIDevice.current.orientation {
2107
- case .landscapeRight:
2108
- connection.videoOrientation = .landscapeLeft
2109
- case .landscapeLeft:
2110
- connection.videoOrientation = .landscapeRight
2111
- case .portrait:
2112
- connection.videoOrientation = .portrait
2113
- case .portraitUpsideDown:
2114
- connection.videoOrientation = .portraitUpsideDown
2115
- default:
2116
- connection.videoOrientation = .portrait
2117
- }
2168
+ // Goes off accelerometer now
2169
+ connection.videoOrientation = self.getPhysicalOrientation()
2118
2170
  }
2119
2171
 
2120
2172
  let identifier = UUID()
@@ -2176,12 +2228,14 @@ extension CameraController: UIGestureRecognizerDelegate {
2176
2228
  device.focusPointOfInterest = CGPoint(x: CGFloat(devicePoint?.x ?? 0), y: CGFloat(devicePoint?.y ?? 0))
2177
2229
  device.focusMode = focusMode
2178
2230
  }
2179
-
2180
- let exposureMode = AVCaptureDevice.ExposureMode.autoExpose
2181
- if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
2182
- device.exposurePointOfInterest = CGPoint(x: CGFloat(devicePoint?.x ?? 0), y: CGFloat(devicePoint?.y ?? 0))
2183
- device.exposureMode = exposureMode
2184
- device.setExposureTargetBias(0.0) { _ in }
2231
+ // Skip exposure point if locked
2232
+ if device.exposureMode != .locked {
2233
+ let exposureMode = AVCaptureDevice.ExposureMode.autoExpose
2234
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
2235
+ device.exposurePointOfInterest = CGPoint(x: CGFloat(devicePoint?.x ?? 0), y: CGFloat(devicePoint?.y ?? 0))
2236
+ device.exposureMode = exposureMode
2237
+ device.setExposureTargetBias(0.0) { _ in }
2238
+ }
2185
2239
  }
2186
2240
  } catch {
2187
2241
  debugPrint(error)
@@ -34,7 +34,7 @@ extension UIWindow {
34
34
  */
35
35
  @objc(CameraPreview)
36
36
  public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelegate {
37
- private let pluginVersion: String = "8.0.9"
37
+ private let pluginVersion: String = "8.0.11"
38
38
  public let identifier = "CameraPreviewPlugin"
39
39
  public let jsName = "CameraPreview"
40
40
  public let pluginMethods: [CAPPluginMethod] = [
@@ -83,6 +83,7 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
83
83
  private var isInitializing: Bool = false
84
84
  private var isInitialized: Bool = false
85
85
  private var backgroundSession: AVCaptureSession?
86
+ private var isGeneratingDeviceOrientationNotifications: Bool = false
86
87
 
87
88
  var previewView: UIView!
88
89
  var cameraPosition = String()
@@ -679,7 +680,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
679
680
  self.cameraController.stopRequestedAfterCapture = false
680
681
  self.cameraController.cleanup()
681
682
  NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
682
- UIDevice.current.endGeneratingDeviceOrientationNotifications()
683
+ if self.isGeneratingDeviceOrientationNotifications {
684
+ UIDevice.current.endGeneratingDeviceOrientationNotifications()
685
+ self.isGeneratingDeviceOrientationNotifications = false
686
+ }
683
687
  self.isInitialized = false
684
688
  self.isInitializing = false
685
689
  }
@@ -779,11 +783,14 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
779
783
  }
780
784
 
781
785
  DispatchQueue.main.async {
782
- UIDevice.current.beginGeneratingDeviceOrientationNotifications()
783
- NotificationCenter.default.addObserver(self,
784
- selector: #selector(self.handleOrientationChange),
785
- name: UIDevice.orientationDidChangeNotification,
786
- object: nil)
786
+ if self.rotateWhenOrientationChanged == true {
787
+ UIDevice.current.beginGeneratingDeviceOrientationNotifications()
788
+ self.isGeneratingDeviceOrientationNotifications = true
789
+ NotificationCenter.default.addObserver(self,
790
+ selector: #selector(self.handleOrientationChange),
791
+ name: UIDevice.orientationDidChangeNotification,
792
+ object: nil)
793
+ }
787
794
  self.completeStartCamera(call: call)
788
795
  }
789
796
  }
@@ -970,7 +977,10 @@ public class CameraPreview: CAPPlugin, CAPBridgedPlugin, CLLocationManagerDelega
970
977
  // Remove notification observers regardless
971
978
  NotificationCenter.default.removeObserver(self)
972
979
  NotificationCenter.default.removeObserver(self, name: UIDevice.orientationDidChangeNotification, object: nil)
973
- UIDevice.current.endGeneratingDeviceOrientationNotifications()
980
+ if self.isGeneratingDeviceOrientationNotifications {
981
+ UIDevice.current.endGeneratingDeviceOrientationNotifications()
982
+ self.isGeneratingDeviceOrientationNotifications = false
983
+ }
974
984
 
975
985
  if self.cameraController.isCapturingPhoto && !force {
976
986
  // Defer heavy cleanup until capture callback completes (only if not forcing)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/camera-preview",
3
- "version": "8.0.9",
3
+ "version": "8.0.11",
4
4
  "description": "Camera preview",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {