capacitor-camera-view 2.4.0 → 3.0.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.
Files changed (44) hide show
  1. package/CapacitorCameraView.podspec +1 -1
  2. package/Package.swift +1 -1
  3. package/README.md +341 -49
  4. package/android/build.gradle +0 -1
  5. package/android/src/main/AndroidManifest.xml +0 -1
  6. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
  7. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +945 -207
  8. package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
  9. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
  10. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
  11. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
  12. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
  13. package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
  14. package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
  15. package/dist/docs.json +475 -30
  16. package/dist/esm/definitions.d.ts +478 -26
  17. package/dist/esm/definitions.js.map +1 -1
  18. package/dist/esm/utils.d.ts +59 -15
  19. package/dist/esm/utils.js +79 -38
  20. package/dist/esm/utils.js.map +1 -1
  21. package/dist/esm/web.d.ts +191 -13
  22. package/dist/esm/web.js +624 -141
  23. package/dist/esm/web.js.map +1 -1
  24. package/dist/plugin.cjs.js +711 -179
  25. package/dist/plugin.cjs.js.map +1 -1
  26. package/dist/plugin.js +711 -179
  27. package/dist/plugin.js.map +1 -1
  28. package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
  29. package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
  30. package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
  31. package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
  32. package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
  33. package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
  34. package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
  35. package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
  36. package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
  37. package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +200 -0
  38. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
  39. package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
  40. package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
  41. package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
  42. package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +98 -65
  43. package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
  44. package/package.json +25 -9
package/dist/plugin.js CHANGED
@@ -16,7 +16,11 @@ var capacitorCameraView = (function (exports, core) {
16
16
  return dataUrl.split(',')[1];
17
17
  }
18
18
  /**
19
- * Calculates the visible area of the video based on object-fit: cover
19
+ * Calculates the visible area of the video based on object-fit: cover.
20
+ *
21
+ * The `output*` dimensions default to the visible crop's intrinsic (source)
22
+ * pixel size, so a capture keeps the full detail the stream delivers for that
23
+ * region instead of being downscaled to CSS pixels.
20
24
  */
21
25
  function calculateVisibleArea(video) {
22
26
  // Get the displayed dimensions of the video element
@@ -44,6 +48,10 @@ var capacitorCameraView = (function (exports, core) {
44
48
  sourceHeight = videoWidth / displayAspect;
45
49
  sourceY = (videoHeight - sourceHeight) / 2;
46
50
  }
51
+ // Rasterize the capture at the visible crop's native resolution. Rounding
52
+ // keeps the canvas dimensions integral without distorting the aspect ratio.
53
+ const outputWidth = Math.round(sourceWidth);
54
+ const outputHeight = Math.round(sourceHeight);
47
55
  return {
48
56
  sourceX,
49
57
  sourceY,
@@ -51,32 +59,82 @@ var capacitorCameraView = (function (exports, core) {
51
59
  sourceHeight,
52
60
  displayWidth,
53
61
  displayHeight,
62
+ outputWidth,
63
+ outputHeight,
54
64
  };
55
65
  }
56
66
  /**
57
- * Draws the visible area of the video to the canvas
67
+ * The full video frame as a {@link VisibleArea}: no cropping, rasterized at
68
+ * the stream's intrinsic resolution.
69
+ *
70
+ * Used when the session was started with an explicit `aspectRatio`, where the
71
+ * cross-platform contract is that `capture()` returns the full sensor-ratio
72
+ * frame rather than the on-screen cover-cropped region.
73
+ */
74
+ function calculateFullFrameArea(video) {
75
+ const videoRect = video.getBoundingClientRect();
76
+ return {
77
+ sourceX: 0,
78
+ sourceY: 0,
79
+ sourceWidth: video.videoWidth,
80
+ sourceHeight: video.videoHeight,
81
+ displayWidth: videoRect.width,
82
+ displayHeight: videoRect.height,
83
+ outputWidth: video.videoWidth,
84
+ outputHeight: video.videoHeight,
85
+ };
86
+ }
87
+ /**
88
+ * Further crops a {@link VisibleArea} to account for a CSS `transform: scale()`
89
+ * zoom applied to the video element.
90
+ *
91
+ * The CSS-fallback zoom magnifies the preview about its center, but
92
+ * `getBoundingClientRect()` reports the post-transform size and therefore
93
+ * leaves the crop unchanged. Tightening the source rectangle by `scale`, kept
94
+ * centered, makes a capture match what the user actually sees.
95
+ *
96
+ * A `scale <= 1` (no zoom) returns the area unchanged.
97
+ */
98
+ function applyCssZoomCrop(area, scale) {
99
+ if (!(scale > 1)) {
100
+ return area;
101
+ }
102
+ const sourceWidth = area.sourceWidth / scale;
103
+ const sourceHeight = area.sourceHeight / scale;
104
+ return Object.assign(Object.assign({}, area), { sourceX: area.sourceX + (area.sourceWidth - sourceWidth) / 2, sourceY: area.sourceY + (area.sourceHeight - sourceHeight) / 2, sourceWidth,
105
+ sourceHeight, outputWidth: Math.round(sourceWidth), outputHeight: Math.round(sourceHeight) });
106
+ }
107
+ /**
108
+ * Draws the visible area of the video to the canvas at the crop's target
109
+ * output resolution (see {@link VisibleArea.outputWidth}).
58
110
  */
59
111
  function drawVisibleAreaToCanvas(canvas, videoElement, area) {
60
- const { sourceX, sourceY, sourceWidth, sourceHeight, displayWidth, displayHeight } = area;
61
- // Set canvas size to match the displayed dimensions
62
- canvas.width = displayWidth;
63
- canvas.height = displayHeight;
112
+ const { sourceX, sourceY, sourceWidth, sourceHeight, outputWidth, outputHeight } = area;
113
+ // Sized to the crop's intrinsic pixel size rather than the CSS-pixel preview
114
+ // size, so the JPEG keeps the stream's real detail.
115
+ canvas.width = outputWidth;
116
+ canvas.height = outputHeight;
64
117
  const ctx = canvas.getContext('2d', { alpha: false });
65
118
  if (!ctx) {
66
119
  throw new Error('Could not get canvas context');
67
120
  }
68
121
  // Draw only the visible portion of the video to match what the user sees
69
- ctx.drawImage(videoElement, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, displayWidth, displayHeight);
122
+ ctx.drawImage(videoElement, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, outputWidth, outputHeight);
70
123
  }
71
124
  /**
72
- * Transforms barcode coordinates from the video source space to display space
73
- * accounting for object-fit: cover scaling and cropping.
125
+ * Transforms barcode coordinates from the video source space to display space,
126
+ * accounting for how the video element is scaled into its container.
127
+ *
128
+ * Both `object-fit` modes are a single-scale, centered mapping and share one
129
+ * formula, differing only in which axis scale wins: `'cover'` takes the larger
130
+ * (center-cropped), `'fit'` the smaller (letterboxed).
74
131
  *
75
132
  * @param barcodeBoundingBox The original barcode bounding box from the detector
76
133
  * @param videoElement The video element with the camera stream
134
+ * @param scaleMode Whether the preview uses `cover` or `fit` scaling
77
135
  * @returns The transformed bounding box coordinates in display space
78
136
  */
79
- function transformBarcodeBoundingBox(barcodeBoundingBox, videoElement) {
137
+ function transformBarcodeBoundingBox(barcodeBoundingBox, videoElement, scaleMode = 'cover') {
80
138
  // Get the video element's displayed dimensions
81
139
  const videoRect = videoElement.getBoundingClientRect();
82
140
  const displayWidth = videoRect.width;
@@ -84,35 +142,18 @@ var capacitorCameraView = (function (exports, core) {
84
142
  // Get original video dimensions
85
143
  const videoWidth = videoElement.videoWidth;
86
144
  const videoHeight = videoElement.videoHeight;
87
- // Calculate scaling and positioning for object-fit: cover
88
- const videoAspect = videoWidth / videoHeight;
89
- const displayAspect = displayWidth / displayHeight;
90
- let scaledX, scaledY, scaledWidth, scaledHeight;
91
- if (videoAspect > displayAspect) {
92
- // Video is wider than display area - height matches, width is centered and cropped
93
- const scale = displayHeight / videoHeight;
94
- const scaledVideoWidth = videoWidth * scale;
95
- const cropX = (scaledVideoWidth - displayWidth) / 2;
96
- scaledWidth = barcodeBoundingBox.width * scale;
97
- scaledHeight = barcodeBoundingBox.height * scale;
98
- scaledX = barcodeBoundingBox.x * scale - cropX;
99
- scaledY = barcodeBoundingBox.y * scale;
100
- }
101
- else {
102
- // Video is taller than display area - width matches, height is centered and cropped
103
- const scale = displayWidth / videoWidth;
104
- const scaledVideoHeight = videoHeight * scale;
105
- const cropY = (scaledVideoHeight - displayHeight) / 2;
106
- scaledWidth = barcodeBoundingBox.width * scale;
107
- scaledHeight = barcodeBoundingBox.height * scale;
108
- scaledX = barcodeBoundingBox.x * scale;
109
- scaledY = barcodeBoundingBox.y * scale - cropY;
110
- }
145
+ const scaleX = displayWidth / videoWidth;
146
+ const scaleY = displayHeight / videoHeight;
147
+ const scale = scaleMode === 'fit' ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
148
+ // Centering offset of the scaled frame within the container: negative on a
149
+ // cropped axis, positive on a letterboxed one.
150
+ const offsetX = (displayWidth - videoWidth * scale) / 2;
151
+ const offsetY = (displayHeight - videoHeight * scale) / 2;
111
152
  return {
112
- x: scaledX,
113
- y: scaledY,
114
- width: scaledWidth,
115
- height: scaledHeight,
153
+ x: barcodeBoundingBox.x * scale + offsetX,
154
+ y: barcodeBoundingBox.y * scale + offsetY,
155
+ width: barcodeBoundingBox.width * scale,
156
+ height: barcodeBoundingBox.height * scale,
116
157
  };
117
158
  }
118
159
 
@@ -128,12 +169,50 @@ var capacitorCameraView = (function (exports, core) {
128
169
  return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
129
170
  };
130
171
  var _CameraViewWeb_isRunning;
172
+ /**
173
+ * Suppression window in milliseconds during which a repeat of the same barcode
174
+ * (identical value + type) is not re-emitted. A genuinely new code still emits
175
+ * immediately. Kept consistent with the iOS and Android implementations.
176
+ */
177
+ const BARCODE_SUPPRESSION_WINDOW_MS = 500;
178
+ /**
179
+ * Once the per-key dedupe map grows past this size, expired entries are pruned
180
+ * so a long session scanning many different codes stays bounded. Kept
181
+ * consistent with the iOS and Android implementations.
182
+ */
183
+ const BARCODE_DEDUPE_MAP_PRUNE_THRESHOLD = 64;
184
+ /**
185
+ * Backstop for the "wait until the video is ready" step in
186
+ * {@link CameraViewWeb.startBarcodeDetection}. If the video element never
187
+ * fires `loadeddata` (e.g. a stalled stream), the wait settles anyway after
188
+ * this many milliseconds instead of leaving a dangling listener and a
189
+ * permanently pending promise.
190
+ */
191
+ const BARCODE_VIDEO_READY_TIMEOUT_MS = 5000;
192
+ /**
193
+ * Baseline `ideal` capture resolution requested from `getUserMedia` in
194
+ * {@link CameraViewWeb.start}. Without any width/height hint, browsers default
195
+ * to a low-resolution stream (often 640x480), which caps capture quality.
196
+ *
197
+ * These are `ideal` (not `exact`/`min`) so devices that cannot deliver
198
+ * 1080p-class video still start at their best available resolution rather
199
+ * than failing acquisition.
200
+ */
201
+ const DEFAULT_IDEAL_CAPTURE_WIDTH = 1920;
202
+ /**
203
+ * Bounds for the CSS `transform: scale()` zoom simulation used when the browser
204
+ * does not expose a native `zoom` track capability. `getZoom` reports this
205
+ * range and `setZoom` clamps the applied scale to it in fallback mode.
206
+ */
207
+ const SIMULATED_ZOOM_MIN = 1.0;
208
+ const SIMULATED_ZOOM_MAX = 3.0;
131
209
  const BARCODE_TYPE_TO_WEB_FORMAT = {
132
210
  qr: 'qr_code',
133
211
  code128: 'code_128',
134
212
  code39: 'code_39',
135
213
  code39Mod43: null,
136
214
  code93: 'code_93',
215
+ codabar: 'codabar',
137
216
  ean8: 'ean_8',
138
217
  ean13: 'ean_13',
139
218
  interleaved2of5: 'itf',
@@ -141,8 +220,63 @@ var capacitorCameraView = (function (exports, core) {
141
220
  pdf417: 'pdf417',
142
221
  aztec: 'aztec',
143
222
  dataMatrix: 'data_matrix',
223
+ upcA: 'upc_a',
144
224
  upce: 'upc_e',
145
225
  };
226
+ /**
227
+ * Inverse of {@link BARCODE_TYPE_TO_WEB_FORMAT}: maps the web BarcodeDetector
228
+ * format back onto the cross-platform {@link BarcodeType} vocabulary so the
229
+ * `barcodeDetected` event emits the same `type` values as iOS and Android.
230
+ *
231
+ * Note: `interleaved2of5` and `itf14` both map to the web format `itf`, so the
232
+ * inversion has a collision that is resolved by insertion order — `itf14` comes
233
+ * last in {@link BARCODE_TYPE_TO_WEB_FORMAT} and wins, which is the intended
234
+ * result for `itf` detections.
235
+ */
236
+ const WEB_FORMAT_TO_BARCODE_TYPE = Object.entries(BARCODE_TYPE_TO_WEB_FORMAT).reduce((acc, [barcodeType, webFormat]) => {
237
+ if (webFormat) {
238
+ acc[webFormat] = barcodeType;
239
+ }
240
+ return acc;
241
+ }, {});
242
+ /**
243
+ * Error thrown by the web implementation for a rejected plugin call.
244
+ *
245
+ * Carries a stable `code` from the {@link CameraErrorCode} vocabulary, the
246
+ * same public contract iOS and Android provide, so consumers can `switch` on
247
+ * `error.code` instead of matching on the human-readable `message`.
248
+ *
249
+ * Methods that reject via `WebPlugin.unimplemented()` are the one exception:
250
+ * those keep Capacitor's own `UNIMPLEMENTED` convention.
251
+ */
252
+ class CameraViewError extends Error {
253
+ constructor(message, code) {
254
+ super(message);
255
+ this.name = 'CameraViewError';
256
+ this.code = code;
257
+ }
258
+ }
259
+ /**
260
+ * Classifies a `getUserMedia` failure into the closest-fitting
261
+ * {@link CameraErrorCode}, shared by every acquisition site.
262
+ *
263
+ * `kind` selects which media type's dedicated codes apply for `NotFoundError`/
264
+ * `NotReadableError` so a missing/busy camera and a missing/busy microphone
265
+ * aren't conflated. Anything unmappable falls back to `UNKNOWN_ERROR`.
266
+ */
267
+ function classifyGetUserMediaErrorCode(err, kind) {
268
+ if (err instanceof DOMException) {
269
+ switch (err.name) {
270
+ case 'NotAllowedError':
271
+ return 'PERMISSION_DENIED';
272
+ case 'NotFoundError':
273
+ return kind === 'camera' ? 'CAMERA_UNAVAILABLE' : 'AUDIO_DEVICE_UNAVAILABLE';
274
+ case 'NotReadableError':
275
+ return kind === 'camera' ? 'DEVICE_LOCKED' : 'AUDIO_INPUT_ADDITION_FAILED';
276
+ }
277
+ }
278
+ return 'UNKNOWN_ERROR';
279
+ }
146
280
  /**
147
281
  * Web implementation of the CameraViewPlugin.
148
282
  * Optimized for performance and battery efficiency.
@@ -159,10 +293,38 @@ var capacitorCameraView = (function (exports, core) {
159
293
  // Configuration state
160
294
  this.currentCamera = 'environment'; // Default to back camera
161
295
  this.currentZoom = 1.0;
296
+ // Whether the current zoom is applied through the native track `zoom`
297
+ // capability (`applyConstraints`) rather than the CSS `transform: scale()`
298
+ // simulation. Capture only needs to compensate for the transform in the
299
+ // CSS-fallback case.
300
+ this.usingNativeZoom = false;
162
301
  this.currentFlashMode = 'off';
302
+ // The aspect ratio the current session was started with, or null when the
303
+ // option was omitted. Selects the capture contract: with an explicit ratio,
304
+ // capture() returns the full sensor-ratio frame (cross-platform contract);
305
+ // without it, the legacy web behavior of capturing the visible
306
+ // (cover-cropped) preview region is preserved.
307
+ this.sessionAspectRatio = null;
308
+ // How the current session scales the preview into its container. `'fit'`
309
+ // (object-fit: contain) letterboxes the whole frame; `'cover'` (the default)
310
+ // center-crops it. Selects the barcode-transform variant and, together with
311
+ // the aspect ratio, the capture crop.
312
+ this.sessionPreviewScaleMode = 'cover';
313
+ // Resolution/aspect-ratio constraints of the current session, kept so
314
+ // flipCamera() re-acquires the stream with the same resolution contract
315
+ // instead of falling back to the browser default.
316
+ this.sessionResolutionConstraints = {};
163
317
  // Barcode detection support
164
318
  this.barcodeDetectionSupported = false;
165
319
  this.barcodeDetector = null;
320
+ // Scopes the barcode detection loop to a single start()/stop() session so a
321
+ // rapid stop() -> start() can't let the old loop mistake the new session's
322
+ // running flag for its own and keep polling a detached video element.
323
+ this.barcodeDetectionAbortController = null;
324
+ // The most recently scheduled `requestAnimationFrame` id for the barcode
325
+ // detection loop, so `stop()` can cancel a queued-but-not-yet-run frame
326
+ // outright.
327
+ this.barcodeAnimationFrameId = null;
166
328
  // Recording state
167
329
  this.mediaRecorder = null;
168
330
  this.recordedChunks = [];
@@ -175,41 +337,88 @@ var capacitorCameraView = (function (exports, core) {
175
337
  * Start the camera with the given configuration
176
338
  */
177
339
  async start(options) {
340
+ var _a, _b, _c, _d;
341
+ // A session is already running. Per the cross-platform contract, reject
342
+ // instead of silently reconfiguring or no-op'ing: callers who need a
343
+ // different configuration (e.g. a different position or resolution) must
344
+ // call `stop()` first and then `start()` again with the new options.
178
345
  if (__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
179
- return;
180
- }
181
- const permissionStatus = await this.requestPermissions();
182
- if (permissionStatus.camera !== 'granted') {
183
- throw new Error('Camera permission was not granted');
346
+ throw new CameraViewError('Camera session is already running. Call stop() first.', 'SESSION_ALREADY_RUNNING');
184
347
  }
185
348
  try {
186
349
  // Set up video element if it doesn't exist
187
350
  if (!this.videoElement) {
188
351
  await this.setupVideoElement(options === null || options === void 0 ? void 0 : options.containerElementId);
189
352
  }
190
- // Set up video constraints based on options
191
- const videoConstraints = {};
192
- // Prefer deviceId if specified
193
- if (options === null || options === void 0 ? void 0 : options.deviceId) {
194
- videoConstraints.deviceId = { exact: options.deviceId };
195
- // Remember the current camera mode (though we're using a specific device)
196
- this.currentCamera = (options === null || options === void 0 ? void 0 : options.position) === 'front' ? 'user' : 'environment';
197
- }
198
- else {
199
- // Fall back to facing mode
200
- const facingMode = (options === null || options === void 0 ? void 0 : options.position) === 'front' ? 'user' : 'environment';
201
- this.currentCamera = facingMode;
202
- videoConstraints.facingMode = facingMode;
203
- }
204
- const constraints = {
205
- video: videoConstraints,
206
- audio: false,
207
- };
353
+ }
354
+ catch (err) {
355
+ // The only failure path in setupVideoElement() is the target container
356
+ // element not being found, which is the web equivalent of "could not
357
+ // find the view to render the camera preview into".
358
+ throw new CameraViewError(`Failed to start camera: ${this.formatError(err)}`, 'WEBVIEW_UNAVAILABLE');
359
+ }
360
+ // Apply the preview scale mode to the video element. `'fit'` letterboxes
361
+ // the whole frame (object-fit: contain); the empty bars show the container's
362
+ // own background. `'cover'` keeps the long-standing center-cropped preview.
363
+ this.sessionPreviewScaleMode = (_a = options === null || options === void 0 ? void 0 : options.previewScaleMode) !== null && _a !== void 0 ? _a : 'cover';
364
+ if (this.videoElement) {
365
+ this.videoElement.style.objectFit = this.sessionPreviewScaleMode === 'fit' ? 'contain' : 'cover';
366
+ }
367
+ // Set up video constraints based on options
368
+ this.sessionAspectRatio = (_b = options === null || options === void 0 ? void 0 : options.aspectRatio) !== null && _b !== void 0 ? _b : null;
369
+ this.sessionResolutionConstraints = this.buildResolutionConstraints(options);
370
+ const videoConstraints = Object.assign({}, this.sessionResolutionConstraints);
371
+ // Prefer deviceId if specified
372
+ if (options === null || options === void 0 ? void 0 : options.deviceId) {
373
+ videoConstraints.deviceId = { exact: options.deviceId };
374
+ // Remember the current camera mode (though we're using a specific device)
375
+ this.currentCamera = (options === null || options === void 0 ? void 0 : options.position) === 'front' ? 'user' : 'environment';
376
+ }
377
+ else {
378
+ // Fall back to facing mode
379
+ const facingMode = (options === null || options === void 0 ? void 0 : options.position) === 'front' ? 'user' : 'environment';
380
+ this.currentCamera = facingMode;
381
+ videoConstraints.facingMode = facingMode;
382
+ }
383
+ const constraints = {
384
+ video: videoConstraints,
385
+ audio: false,
386
+ };
387
+ // Acquire the camera exactly once, using the real constraints, and derive
388
+ // the permission outcome from this single call. Probing permission with a
389
+ // throwaway acquisition first would double startup latency, flash the
390
+ // camera indicator twice, and risk a spurious NotReadableError on devices
391
+ // where the camera is exclusive.
392
+ try {
208
393
  this.stream = await navigator.mediaDevices.getUserMedia(constraints);
394
+ }
395
+ catch (err) {
396
+ throw this.mapStartAcquisitionError(err);
397
+ }
398
+ try {
209
399
  if (this.videoElement) {
210
400
  this.videoElement.srcObject = this.stream;
211
- this.videoElement.play();
401
+ // Some browsers' autoplay policies can reject `play()` (e.g. lack of a
402
+ // recent user gesture). The element is muted + playsInline, which satisfies
403
+ // autoplay policies in virtually all cases, but a rejection must still be
404
+ // handled here rather than left as an unhandled promise rejection.
405
+ try {
406
+ await this.videoElement.play();
407
+ }
408
+ catch (err) {
409
+ console.warn('[CameraView] Failed to autoplay the video preview', err);
410
+ }
212
411
  __classPrivateFieldSet(this, _CameraViewWeb_isRunning, true, "f");
412
+ // Apply the initial zoom. This also re-establishes zoom on the freshly
413
+ // created video element after a stop()/start() cycle, keeping the
414
+ // applied zoom in sync with `currentZoom` instead of silently resetting
415
+ // to an untransformed element. A zoom failure must not abort start().
416
+ try {
417
+ await this.setZoom({ level: (_c = options === null || options === void 0 ? void 0 : options.zoomFactor) !== null && _c !== void 0 ? _c : 1.0 });
418
+ }
419
+ catch (err) {
420
+ console.warn('[CameraView] Failed to apply initial zoom factor', err);
421
+ }
213
422
  // If barcode detection is enabled and supported, start detection
214
423
  if (options === null || options === void 0 ? void 0 : options.enableBarcodeDetection) {
215
424
  await this.checkBarcodeDetectionSupport();
@@ -221,22 +430,90 @@ var capacitorCameraView = (function (exports, core) {
221
430
  }
222
431
  }
223
432
  catch (err) {
224
- throw new Error(`Failed to start camera: ${this.formatError(err)}`);
433
+ // The stream was already acquired here, so its tracks are still live.
434
+ // Tear down the partially-initialized session state so the thrown error
435
+ // leaves a clean slate for a subsequent start().
436
+ (_d = this.stream) === null || _d === void 0 ? void 0 : _d.getTracks().forEach((track) => track.stop());
437
+ this.stream = null;
438
+ __classPrivateFieldSet(this, _CameraViewWeb_isRunning, false, "f");
439
+ if (this.videoElement) {
440
+ this.videoElement.srcObject = null;
441
+ }
442
+ throw new CameraViewError(`Failed to start camera: ${this.formatError(err)}`, 'UNKNOWN_ERROR');
443
+ }
444
+ }
445
+ /**
446
+ * Builds the resolution/aspect-ratio part of the `getUserMedia` video
447
+ * constraints for a session.
448
+ *
449
+ * `captureMaxDimension` replaces the ideal width (the longer edge in the
450
+ * stream's landscape-oriented coordinate space) and the ideal height is
451
+ * derived from the configured ratio. Everything stays `ideal` so acquisition
452
+ * degrades gracefully on devices that cannot deliver the request.
453
+ */
454
+ buildResolutionConstraints(options) {
455
+ var _a;
456
+ const ratio = (options === null || options === void 0 ? void 0 : options.aspectRatio) === '4:3' ? 4 / 3 : 16 / 9;
457
+ const idealWidth = (_a = options === null || options === void 0 ? void 0 : options.captureMaxDimension) !== null && _a !== void 0 ? _a : DEFAULT_IDEAL_CAPTURE_WIDTH;
458
+ const idealHeight = Math.round(idealWidth / ratio);
459
+ const constraints = {
460
+ width: { ideal: idealWidth },
461
+ height: { ideal: idealHeight },
462
+ };
463
+ if (options === null || options === void 0 ? void 0 : options.aspectRatio) {
464
+ constraints.aspectRatio = { ideal: ratio };
465
+ }
466
+ return constraints;
467
+ }
468
+ /**
469
+ * Map a `getUserMedia` failure from the real-constraints acquisition in
470
+ * `start()` onto the plugin's error contract.
471
+ *
472
+ * `NotAllowedError` maps to `PERMISSION_DENIED`, `NotFoundError` (no
473
+ * matching device) to `CAMERA_UNAVAILABLE`, and `NotReadableError` (device
474
+ * claimed by another process) to `DEVICE_LOCKED`, matching the codes
475
+ * iOS/Android use. Anything else falls back to `UNKNOWN_ERROR`.
476
+ */
477
+ mapStartAcquisitionError(err) {
478
+ if (err instanceof DOMException) {
479
+ switch (err.name) {
480
+ case 'NotAllowedError':
481
+ return new CameraViewError('Camera permission was not granted', 'PERMISSION_DENIED');
482
+ case 'NotFoundError':
483
+ return new CameraViewError('Failed to start camera: No camera matching the requested configuration was found.', 'CAMERA_UNAVAILABLE');
484
+ case 'NotReadableError':
485
+ return new CameraViewError('Failed to start camera: The camera could not be started, possibly because it is already in use by another application.', 'DEVICE_LOCKED');
486
+ }
225
487
  }
488
+ return new CameraViewError(`Failed to start camera: ${this.formatError(err)}`, 'UNKNOWN_ERROR');
226
489
  }
227
490
  /**
228
491
  * Stop the camera and release resources
229
492
  */
230
493
  async stop() {
231
- var _a;
494
+ var _a, _b, _c;
232
495
  if (!__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
233
496
  return;
234
497
  }
235
498
  try {
499
+ // Tear down the barcode detection session tied to this camera session
500
+ // first: abort settles any pending video-ready wait in
501
+ // startBarcodeDetection, and cancelling the animation frame stops a
502
+ // queued detectFrame call from ever running. Together these ensure no
503
+ // stale loop can survive into a subsequent start().
504
+ (_a = this.barcodeDetectionAbortController) === null || _a === void 0 ? void 0 : _a.abort();
505
+ this.barcodeDetectionAbortController = null;
506
+ if (this.barcodeAnimationFrameId !== null) {
507
+ cancelAnimationFrame(this.barcodeAnimationFrameId);
508
+ this.barcodeAnimationFrameId = null;
509
+ }
236
510
  // Stop any active recording
237
511
  if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
238
- // Reject any pending stopRecording promise since we're force-stopping
239
- (_a = this.recordingReject) === null || _a === void 0 ? void 0 : _a.call(this, new Error('Camera session stopped while recording'));
512
+ // Reject any pending stopRecording promise since we're force-stopping.
513
+ // There is no dedicated code for this case, so it falls back to
514
+ // UNKNOWN_ERROR per the plugin's documented "anything unmappable"
515
+ // contract.
516
+ (_b = this.recordingReject) === null || _b === void 0 ? void 0 : _b.call(this, new CameraViewError('Camera session stopped while recording', 'UNKNOWN_ERROR'));
240
517
  this.recordingResolve = null;
241
518
  this.recordingReject = null;
242
519
  this.mediaRecorder.stop();
@@ -252,14 +529,19 @@ var capacitorCameraView = (function (exports, core) {
252
529
  this.stream.getTracks().forEach((track) => track.stop());
253
530
  this.stream = null;
254
531
  }
255
- // Clear video source
532
+ // Detach the stream and remove the video element from the DOM
256
533
  if (this.videoElement) {
534
+ this.videoElement.pause();
535
+ this.videoElement.srcObject = null;
536
+ (_c = this.videoElement.parentNode) === null || _c === void 0 ? void 0 : _c.removeChild(this.videoElement);
257
537
  this.videoElement = null;
258
538
  }
259
539
  __classPrivateFieldSet(this, _CameraViewWeb_isRunning, false, "f");
260
540
  }
261
541
  catch (err) {
262
- throw new Error(`Failed to stop camera: ${this.formatError(err)}`);
542
+ // No dedicated code for a teardown failure; falls back to UNKNOWN_ERROR
543
+ // per the plugin's documented "anything unmappable" contract.
544
+ throw new CameraViewError(`Failed to stop camera: ${this.formatError(err)}`, 'UNKNOWN_ERROR');
263
545
  }
264
546
  }
265
547
  /**
@@ -273,22 +555,41 @@ var capacitorCameraView = (function (exports, core) {
273
555
  * Preserves what the user actually sees in the UI, including cropping from object-fit: cover.
274
556
  */
275
557
  async capture(options) {
558
+ var _a;
276
559
  const videoElement = this.videoElement;
277
560
  if (!__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f") || !videoElement) {
278
- throw new Error('Camera is not running');
561
+ throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
279
562
  }
280
563
  try {
281
564
  const canvas = this.getCanvasElement();
282
- const visibleArea = calculateVisibleArea(videoElement);
565
+ // `fit` mode letterboxes the whole frame and an explicit `aspectRatio`
566
+ // contractually returns the full sensor-ratio frame, so both capture
567
+ // uncropped. Otherwise capture the visible (cover-cropped) region so the
568
+ // output matches what the user sees.
569
+ const captureArea = this.sessionPreviewScaleMode === 'fit' || this.sessionAspectRatio
570
+ ? calculateFullFrameArea(videoElement)
571
+ : calculateVisibleArea(videoElement);
572
+ // In CSS-fallback zoom mode the preview is magnified about its center via
573
+ // `transform: scale()`, so tighten the source crop to match the zoomed
574
+ // viewport (this preserves the frame's aspect ratio). Native-zoom mode
575
+ // needs no compensation: the track already delivers the zoomed frame.
576
+ const visibleArea = applyCssZoomCrop(captureArea, this.getCssZoomScale());
283
577
  drawVisibleAreaToCanvas(canvas, videoElement, visibleArea);
284
- const quality = Math.min(1.0, Math.max(0.1, options.quality / 100));
285
- if (options.saveToFile) {
578
+ // Mirror the native platforms' default: `quality` is optional and defaults
579
+ // to 90 when omitted. Without this, `options?.quality / 100` would be
580
+ // `NaN` for an undefined quality, which is passed silently to `toBlob`/
581
+ // `toDataURL` (both treat an invalid quality as "use the default"), so a
582
+ // caller relying on the documented default would get a different result
583
+ // on web than on iOS/Android.
584
+ const requestedQuality = (_a = options === null || options === void 0 ? void 0 : options.quality) !== null && _a !== void 0 ? _a : 90;
585
+ const quality = Math.min(1.0, Math.max(0.1, requestedQuality / 100));
586
+ if (options === null || options === void 0 ? void 0 : options.saveToFile) {
286
587
  // Create a blob from canvas and return a blob URL.
287
588
  // `path` is native-only (no filesystem path on web), so it is omitted here.
288
589
  return new Promise((resolve, reject) => {
289
590
  canvas.toBlob((blob) => {
290
591
  if (!blob) {
291
- reject(new Error('Failed to create blob from canvas'));
592
+ reject(new CameraViewError('Failed to create blob from canvas', 'IMAGE_COMPRESSION_FAILED'));
292
593
  return;
293
594
  }
294
595
  const url = URL.createObjectURL(blob);
@@ -303,7 +604,7 @@ var capacitorCameraView = (function (exports, core) {
303
604
  }
304
605
  }
305
606
  catch (err) {
306
- throw new Error(`Failed to capture photo: ${this.formatError(err)}`);
607
+ throw new CameraViewError(`Failed to capture photo: ${this.formatError(err)}`, 'FRAME_CAPTURE_ERROR');
307
608
  }
308
609
  }
309
610
  /**
@@ -317,10 +618,10 @@ var capacitorCameraView = (function (exports, core) {
317
618
  */
318
619
  async startRecording(options) {
319
620
  if (!__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f") || !this.videoElement) {
320
- throw new Error('Camera is not running');
621
+ throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
321
622
  }
322
623
  if (this.mediaRecorder) {
323
- throw new Error('Recording is already in progress');
624
+ throw new CameraViewError('Recording is already in progress', 'RECORDING_ALREADY_IN_PROGRESS');
324
625
  }
325
626
  try {
326
627
  let stream = this.stream;
@@ -370,7 +671,10 @@ var capacitorCameraView = (function (exports, core) {
370
671
  this.mediaRecorder = null;
371
672
  this.recordedChunks = [];
372
673
  const errorMessage = (_b = (_a = event.error) === null || _a === void 0 ? void 0 : _a.message) !== null && _b !== void 0 ? _b : 'Unknown recording error';
373
- (_c = this.recordingReject) === null || _c === void 0 ? void 0 : _c.call(this, new Error('Recording error: ' + errorMessage));
674
+ // A generic MediaRecorder runtime failure has no dedicated code, so
675
+ // it falls back to UNKNOWN_ERROR per the plugin's documented
676
+ // "anything unmappable" contract.
677
+ (_c = this.recordingReject) === null || _c === void 0 ? void 0 : _c.call(this, new CameraViewError('Recording error: ' + errorMessage, 'UNKNOWN_ERROR'));
374
678
  this.recordingResolve = null;
375
679
  this.recordingReject = null;
376
680
  };
@@ -383,15 +687,43 @@ var capacitorCameraView = (function (exports, core) {
383
687
  }
384
688
  this.mediaRecorder = null;
385
689
  this.recordedChunks = [];
386
- throw new Error(`Failed to start recording: ${this.formatError(err)}`);
690
+ throw new CameraViewError(`Failed to start recording: ${this.formatError(err)}`, this.mapRecordingStartErrorCode(err));
691
+ }
692
+ }
693
+ /**
694
+ * Maps a failure caught by `startRecording()`'s try/catch onto the closest-
695
+ * fitting `CameraErrorCode`, without altering the existing wrapped message.
696
+ *
697
+ * A `DOMException` here can only come from the microphone `getUserMedia`
698
+ * call, so it is classified with `kind: 'microphone'`. The two other
699
+ * distinguishable failures are plain `Error`s matched by message text.
700
+ */
701
+ mapRecordingStartErrorCode(err) {
702
+ if (err instanceof DOMException) {
703
+ return classifyGetUserMediaErrorCode(err, 'microphone');
704
+ }
705
+ if (err instanceof Error) {
706
+ if (err.message === 'No camera stream available') {
707
+ return 'CAMERA_UNAVAILABLE';
708
+ }
709
+ if (err.message === 'No supported video recording format found') {
710
+ return 'CONFIGURATION_FAILED';
711
+ }
387
712
  }
713
+ return 'UNKNOWN_ERROR';
388
714
  }
389
715
  /**
390
716
  * Stop the current video recording
391
717
  */
392
718
  async stopRecording() {
393
719
  if (!this.mediaRecorder) {
394
- throw new Error('No recording is in progress');
720
+ throw new CameraViewError('No recording is in progress', 'NO_RECORDING_IN_PROGRESS');
721
+ }
722
+ // A stop is already pending. Reject this second call instead of
723
+ // overwriting the pending callbacks, which would orphan the first caller's
724
+ // promise forever.
725
+ if (this.recordingResolve || this.recordingReject) {
726
+ throw new CameraViewError('stopRecording() is already pending', 'UNKNOWN_ERROR');
395
727
  }
396
728
  return new Promise((resolve, reject) => {
397
729
  var _a;
@@ -404,45 +736,95 @@ var capacitorCameraView = (function (exports, core) {
404
736
  * Flip between front and back camera
405
737
  */
406
738
  async flipCamera() {
739
+ var _a;
407
740
  if (!__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
408
- throw new Error('Camera is not running');
741
+ throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
409
742
  }
743
+ // Flipping restarts the stream and stops the tracks the MediaRecorder is
744
+ // consuming, which would silently freeze an in-progress recording. Reject
745
+ // instead so the caller can stop recording first; the recording stays
746
+ // intact.
747
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
748
+ throw new CameraViewError('Cannot flip camera while a recording is in progress', 'RECORDING_ALREADY_IN_PROGRESS');
749
+ }
750
+ // The candidate facing mode, kept local until the new stream is actually
751
+ // live: `currentCamera` (and the previous stream) must not be touched
752
+ // before that point, or a failed re-acquisition below would leave the
753
+ // session state pointing at a camera that isn't running while the
754
+ // previous camera's tracks have already been stopped.
755
+ const nextCamera = this.currentCamera === 'user' ? 'environment' : 'user';
756
+ // Acquire the new-facing stream with the new facing mode, keeping the
757
+ // session's resolution/aspect-ratio constraints so the flipped stream
758
+ // honors the same contract as the one it replaces.
759
+ const constraints = {
760
+ video: Object.assign(Object.assign({}, this.sessionResolutionConstraints), { facingMode: nextCamera }),
761
+ audio: false,
762
+ };
763
+ let newStream;
410
764
  try {
411
- // Switch current camera
412
- this.currentCamera = this.currentCamera === 'user' ? 'environment' : 'user';
413
- // Stop current stream
414
- if (this.stream) {
415
- this.stream.getTracks().forEach((track) => track.stop());
416
- }
417
- // Restart with new facing mode
418
- const constraints = {
419
- video: {
420
- facingMode: this.currentCamera,
421
- },
422
- audio: false,
423
- };
424
- this.stream = await navigator.mediaDevices.getUserMedia(constraints);
425
- if (this.videoElement) {
426
- this.videoElement.srcObject = this.stream;
427
- }
765
+ newStream = await navigator.mediaDevices.getUserMedia(constraints);
428
766
  }
429
767
  catch (err) {
430
- throw new Error(`Failed to flip camera: ${this.formatError(err)}`);
768
+ // Acquisition failed: `stream`/`currentCamera` haven't been touched, so
769
+ // the previous camera is still attached and running. Just surface the
770
+ // error. The only `getUserMedia` call in this method re-acquires the
771
+ // camera (never the microphone), so classify with kind: 'camera'.
772
+ throw new CameraViewError(`Failed to flip camera: ${this.formatError(err)}`, classifyGetUserMediaErrorCode(err, 'camera'));
773
+ }
774
+ // The new stream is live: safe to stop the previous stream's tracks and
775
+ // commit the flipped state.
776
+ (_a = this.stream) === null || _a === void 0 ? void 0 : _a.getTracks().forEach((track) => track.stop());
777
+ this.stream = newStream;
778
+ this.currentCamera = nextCamera;
779
+ if (this.videoElement) {
780
+ this.videoElement.srcObject = newStream;
781
+ }
782
+ // Re-apply the session's zoom level to the new stream through the normal
783
+ // setZoom() path - a flip otherwise silently drops native-zoom's applied
784
+ // constraint (reset on the fresh track) or leaves CSS-fallback's
785
+ // transform stale against the fresh element state. Best-effort: a zoom
786
+ // failure must not fail the flip itself.
787
+ try {
788
+ await this.setZoom({ level: this.currentZoom });
789
+ }
790
+ catch (err) {
791
+ console.warn('[CameraView] Failed to re-apply zoom after flipping camera', err);
431
792
  }
432
793
  }
433
794
  /**
434
- * Get available camera devices
795
+ * Get available camera devices.
796
+ *
797
+ * Position detection prefers the `facingMode` capability of the active video
798
+ * track, since it is a standardized signal rather than a locale-dependent
799
+ * string. Every other device falls back to matching the English word "front"
800
+ * in `device.label` — which is empty for all devices until camera permission
801
+ * has been granted once, so the fallback resolves to `'back'` in that case.
435
802
  */
436
803
  async getAvailableDevices() {
804
+ var _a;
437
805
  try {
438
806
  const devices = await navigator.mediaDevices.enumerateDevices();
439
807
  const videoDevices = devices.filter((device) => device.kind === 'videoinput');
808
+ const activeTrack = this.getVideoTrack();
809
+ const activeDeviceId = activeTrack === null || activeTrack === void 0 ? void 0 : activeTrack.getSettings().deviceId;
810
+ const activeFacingMode = activeTrack && typeof activeTrack.getCapabilities === 'function'
811
+ ? (_a = activeTrack.getCapabilities().facingMode) === null || _a === void 0 ? void 0 : _a[0]
812
+ : undefined;
440
813
  return {
441
- devices: videoDevices.map((device) => ({
442
- id: device.deviceId,
443
- name: device.label || `Camera ${device.deviceId.substring(0, 5)}`,
444
- position: device.label.toLowerCase().includes('front') ? 'front' : 'back',
445
- })),
814
+ devices: videoDevices.map((device) => {
815
+ const position = device.deviceId === activeDeviceId && activeFacingMode
816
+ ? activeFacingMode === 'user'
817
+ ? 'front'
818
+ : 'back'
819
+ : device.label.toLowerCase().includes('front')
820
+ ? 'front'
821
+ : 'back';
822
+ return {
823
+ id: device.deviceId,
824
+ name: device.label || `Camera ${device.deviceId.substring(0, 5)}`,
825
+ position,
826
+ };
827
+ }),
446
828
  };
447
829
  }
448
830
  catch (err) {
@@ -451,31 +833,106 @@ var capacitorCameraView = (function (exports, core) {
451
833
  }
452
834
  }
453
835
  /**
454
- * Get current zoom information (web has limited zoom support)
836
+ * Get current zoom information.
837
+ *
838
+ * When the active video track exposes a native `zoom` capability (Chromium
839
+ * on capable cameras), the real min/max/current are reported. Otherwise the
840
+ * simulated CSS-scale range is returned.
455
841
  */
456
842
  async getZoom() {
457
- // Web has limited zoom capabilities in most browsers,
458
- // we fake zoomin by scaling the video element
843
+ const track = this.getVideoTrack();
844
+ const capability = this.getNativeZoomCapability(track);
845
+ if (track && capability) {
846
+ const settings = track.getSettings();
847
+ return {
848
+ min: capability.min,
849
+ max: capability.max,
850
+ current: typeof settings.zoom === 'number' ? settings.zoom : this.currentZoom,
851
+ };
852
+ }
853
+ // No native zoom: report the simulated CSS-scale range.
459
854
  return {
460
- min: 1.0,
461
- max: 3.0,
855
+ min: SIMULATED_ZOOM_MIN,
856
+ max: SIMULATED_ZOOM_MAX,
462
857
  current: this.currentZoom,
463
858
  };
464
859
  }
465
860
  /**
466
- * Set zoom level (limited support in web)
861
+ * Set the zoom level.
862
+ *
863
+ * Prefers real zoom via `track.applyConstraints({ advanced: [{ zoom }] })`
864
+ * when the browser exposes the native `zoom` capability, clamping to the
865
+ * reported range. Falls back to a CSS `transform: scale()` simulation
866
+ * otherwise.
467
867
  */
468
868
  async setZoom(options) {
469
- // Store the requested zoom level
869
+ const track = this.getVideoTrack();
870
+ const capability = this.getNativeZoomCapability(track);
871
+ if (track && capability) {
872
+ const clamped = Math.max(capability.min, Math.min(options.level, capability.max));
873
+ await track.applyConstraints({ advanced: [{ zoom: clamped }] });
874
+ this.currentZoom = clamped;
875
+ this.usingNativeZoom = true;
876
+ // Clear any CSS transform left over from a previous fallback so the two
877
+ // zoom mechanisms can't stack.
878
+ if (this.videoElement) {
879
+ this.videoElement.style.transform = '';
880
+ }
881
+ return;
882
+ }
883
+ // CSS-transform fallback.
884
+ this.usingNativeZoom = false;
470
885
  this.currentZoom = options.level;
471
- // Apply visual zoom using CSS transform when native zoom isn't supported
472
886
  if (this.videoElement) {
473
887
  this.videoElement.style.transition = options.ramp ? 'transform 0.2s ease-in-out' : 'none';
474
- const scale = Math.max(1.0, Math.min(options.level, 3.0)); // Limit scale to reasonable bounds
475
- this.videoElement.style.transform = `scale(${scale})`;
888
+ this.videoElement.style.transform = `scale(${this.getCssZoomScale()})`;
476
889
  this.videoElement.style.transformOrigin = 'center';
477
890
  }
478
891
  }
892
+ /**
893
+ * The video track backing the active stream, or `null`.
894
+ */
895
+ getVideoTrack() {
896
+ var _a, _b;
897
+ return (_b = (_a = this.stream) === null || _a === void 0 ? void 0 : _a.getVideoTracks()[0]) !== null && _b !== void 0 ? _b : null;
898
+ }
899
+ /**
900
+ * Reads the native `zoom` capability off a track, if the browser both
901
+ * supports `getCapabilities()` and exposes a usable `zoom` range on this
902
+ * device. Returns `null` when native zoom is unavailable (CSS fallback).
903
+ */
904
+ getNativeZoomCapability(track) {
905
+ if (!track || typeof track.getCapabilities !== 'function') {
906
+ return null;
907
+ }
908
+ const zoom = track.getCapabilities().zoom;
909
+ if (zoom && typeof zoom.min === 'number' && typeof zoom.max === 'number' && zoom.max > zoom.min) {
910
+ return zoom;
911
+ }
912
+ return null;
913
+ }
914
+ /**
915
+ * The effective CSS `transform: scale()` factor currently applied to the
916
+ * preview: `1` when native zoom is in use (no transform), otherwise
917
+ * `currentZoom` clamped to the simulated range. Used both to set the
918
+ * transform and to compensate captures for it.
919
+ */
920
+ getCssZoomScale() {
921
+ if (this.usingNativeZoom) {
922
+ return 1;
923
+ }
924
+ return Math.max(SIMULATED_ZOOM_MIN, Math.min(this.currentZoom, SIMULATED_ZOOM_MAX));
925
+ }
926
+ /**
927
+ * Set the focus/metering point (not supported in web).
928
+ *
929
+ * The `pointsOfInterest` media-track constraint has effectively no browser
930
+ * support, so this rejects with `unimplemented` rather than silently doing
931
+ * nothing. Left as a hook for a future implementation.
932
+ */
933
+ async setFocusPoint() {
934
+ throw this.unimplemented('Focus point control is not supported in the web implementation.');
935
+ }
479
936
  /**
480
937
  * Get current flash mode
481
938
  */
@@ -490,11 +947,16 @@ var capacitorCameraView = (function (exports, core) {
490
947
  return { flashModes: ['off'] };
491
948
  }
492
949
  /**
493
- * Set flash mode (limited support in web)
950
+ * Set flash mode (limited support in web).
951
+ *
952
+ * Only `'off'` is supported on web; any other mode rejects rather than
953
+ * silently accepting a mode it cannot apply.
494
954
  */
495
955
  async setFlashMode(options) {
496
- this.currentFlashMode = options.mode;
497
- console.warn('Flash mode control is not fully supported in the web implementation');
956
+ if (options.mode !== 'off') {
957
+ throw this.unimplemented('Flash mode control is not supported in the web implementation.');
958
+ }
959
+ this.currentFlashMode = 'off';
498
960
  }
499
961
  /**
500
962
  * Check if torch is available (not supported in web)
@@ -504,11 +966,14 @@ var capacitorCameraView = (function (exports, core) {
504
966
  return { available: false };
505
967
  }
506
968
  /**
507
- * Get torch mode (not supported in web)
969
+ * Get torch mode (not supported in web).
970
+ *
971
+ * Follows the documented contract for `getTorchMode()`: callers must check
972
+ * `isTorchAvailable()` first, which always reports `false` on web, so this throws
973
+ * rather than returning a fabricated "off" state.
508
974
  */
509
975
  async getTorchMode() {
510
- // Torch is not supported in web implementation
511
- return { enabled: false, level: 0.0 };
976
+ throw this.unimplemented('Torch control is not supported in web implementation.');
512
977
  }
513
978
  /**
514
979
  * Set torch mode (not supported in web)
@@ -521,35 +986,38 @@ var capacitorCameraView = (function (exports, core) {
521
986
  * Check camera and microphone permission without requesting
522
987
  */
523
988
  async checkPermissions() {
524
- try {
525
- // Use Permissions API if available
526
- if (navigator.permissions) {
527
- const [cameraResult, microphoneResult] = await Promise.all([
528
- navigator.permissions.query({ name: 'camera' }),
529
- navigator.permissions.query({ name: 'microphone' }),
530
- ]);
531
- return {
532
- camera: cameraResult.state === 'granted' ? 'granted' : cameraResult.state === 'denied' ? 'denied' : 'prompt',
533
- microphone: microphoneResult.state === 'granted'
534
- ? 'granted'
535
- : microphoneResult.state === 'denied'
536
- ? 'denied'
537
- : 'prompt',
538
- };
989
+ const [camera, microphone] = await Promise.all([
990
+ this.checkSinglePermission('camera'),
991
+ this.checkSinglePermission('microphone'),
992
+ ]);
993
+ return { camera, microphone };
994
+ }
995
+ /**
996
+ * Resolves the current state of a single permission.
997
+ *
998
+ * Queried independently per permission name so one unsupported query (e.g.
999
+ * Firefox does not support querying `'microphone'`) rejects on its own
1000
+ * instead of collapsing *both* permissions to `'prompt'`.
1001
+ */
1002
+ async checkSinglePermission(name) {
1003
+ if (navigator.permissions) {
1004
+ try {
1005
+ const result = await navigator.permissions.query({ name: name });
1006
+ return result.state === 'granted' ? 'granted' : result.state === 'denied' ? 'denied' : 'prompt';
1007
+ }
1008
+ catch (_a) {
1009
+ // This permission name is not supported by the Permissions API in this
1010
+ // browser; fall through to the best-effort fallback below instead of
1011
+ // failing the other permission's check too.
539
1012
  }
540
- // If Permissions API is not available, fall back to checking the active stream
541
- return {
542
- camera: this.stream ? 'granted' : 'prompt',
543
- microphone: 'prompt',
544
- };
545
1013
  }
546
- catch (err) {
547
- // If permissions API is not supported or fails
548
- return {
549
- camera: 'prompt',
550
- microphone: 'prompt',
551
- };
1014
+ // If the Permissions API is unavailable/unsupported for this name, fall back to
1015
+ // checking the active stream for camera; there is no equivalent signal for
1016
+ // microphone without an active audio track, so it stays 'prompt'.
1017
+ if (name === 'camera') {
1018
+ return this.stream ? 'granted' : 'prompt';
552
1019
  }
1020
+ return 'prompt';
553
1021
  }
554
1022
  /**
555
1023
  * Request camera and/or microphone permissions from the user.
@@ -594,70 +1062,125 @@ var capacitorCameraView = (function (exports, core) {
594
1062
  * Start barcode detection if supported
595
1063
  */
596
1064
  async startBarcodeDetection() {
1065
+ var _a;
597
1066
  const barcodeDetector = this.barcodeDetector;
598
1067
  const videoElement = this.videoElement;
599
1068
  if (!this.barcodeDetectionSupported || !barcodeDetector || !videoElement) {
600
1069
  return;
601
1070
  }
602
- // Make sure video is fully loaded before starting detection
1071
+ // Scope this loop to its own session. Aborting the previous controller
1072
+ // (defensive - start() only calls in here once per session) and handing
1073
+ // out a fresh signal means a stale detectFrame closure from an earlier
1074
+ // session can never mistake a later session's #isRunning === true for
1075
+ // its own "keep going" signal.
1076
+ (_a = this.barcodeDetectionAbortController) === null || _a === void 0 ? void 0 : _a.abort();
1077
+ const abortController = new AbortController();
1078
+ this.barcodeDetectionAbortController = abortController;
1079
+ const { signal } = abortController;
1080
+ // Make sure video is fully loaded before starting detection. The wait
1081
+ // settles - without starting detection - as soon as the session is
1082
+ // stopped (`signal` aborts), and a timeout backstops the case where the
1083
+ // video never fires `loadeddata` at all, so neither path leaves a
1084
+ // dangling listener or a permanently pending promise.
603
1085
  if (videoElement.readyState < 2) {
604
- await new Promise((resolve) => {
605
- const loadHandler = () => {
1086
+ const videoReady = await new Promise((resolve) => {
1087
+ const cleanup = () => {
606
1088
  videoElement.removeEventListener('loadeddata', loadHandler);
607
- resolve();
1089
+ signal.removeEventListener('abort', abortHandler);
1090
+ clearTimeout(timeoutId);
608
1091
  };
1092
+ const loadHandler = () => {
1093
+ cleanup();
1094
+ resolve(true);
1095
+ };
1096
+ const abortHandler = () => {
1097
+ cleanup();
1098
+ resolve(false);
1099
+ };
1100
+ const timeoutId = setTimeout(() => {
1101
+ cleanup();
1102
+ resolve(false);
1103
+ }, BARCODE_VIDEO_READY_TIMEOUT_MS);
609
1104
  videoElement.addEventListener('loadeddata', loadHandler);
1105
+ signal.addEventListener('abort', abortHandler);
610
1106
  });
1107
+ if (!videoReady || signal.aborted) {
1108
+ return;
1109
+ }
1110
+ }
1111
+ if (signal.aborted) {
1112
+ return;
611
1113
  }
612
1114
  // Add throttling to reduce CPU usage
613
1115
  let lastDetectionTime = 0;
614
1116
  const minTimeBetweenDetections = 100; // ms
1117
+ // Dedupe state: timestamps of recently emitted barcodes keyed by value +
1118
+ // type. A per-key map (rather than a single "last" slot) is required so
1119
+ // multiple codes in frame can't alternate and defeat the suppression
1120
+ // window. Closure-local, so it resets on every start().
1121
+ const recentBarcodeEmitTimes = new Map();
615
1122
  // Set up periodic frame analysis for barcode detection
616
1123
  const detectFrame = async () => {
617
- if (!__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f") || !videoElement || !barcodeDetector) {
1124
+ var _a;
1125
+ // `signal.aborted` is this loop's own session check: it stays true for
1126
+ // this closure even if a rapid stop() -> start() flips #isRunning back
1127
+ // to true for a *new* session before this frame runs. `#isRunning` is
1128
+ // kept as a defensive secondary check.
1129
+ if (signal.aborted || !__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f") || !videoElement || !barcodeDetector) {
618
1130
  return;
619
1131
  }
620
- const now = Date.now();
1132
+ // Monotonic clock: a backward wall-clock jump must not extend the
1133
+ // throttle or suppression windows arbitrarily.
1134
+ const now = performance.now();
621
1135
  if (now - lastDetectionTime >= minTimeBetweenDetections) {
622
1136
  try {
623
1137
  const barcodes = await barcodeDetector.detect(videoElement);
624
1138
  lastDetectionTime = now;
625
1139
  if (barcodes.length > 0) {
626
1140
  const barcode = barcodes[0];
627
- // Transform barcode coordinates using the utility function
628
- const boundingRect = transformBarcodeBoundingBox(barcode.boundingBox, videoElement);
629
- this.notifyListeners('barcodeDetected', {
630
- value: barcode.rawValue,
631
- type: barcode.format.toLowerCase(),
632
- boundingRect,
633
- });
1141
+ // Normalize the web BarcodeDetector format onto the shared BarcodeType
1142
+ // vocabulary. Fall back to the raw format for the few detector formats
1143
+ // that have no BarcodeType equivalent (e.g. 'unknown'); the emitted
1144
+ // `type` is therefore `BarcodeType | string`.
1145
+ const type = (_a = WEB_FORMAT_TO_BARCODE_TYPE[barcode.format]) !== null && _a !== void 0 ? _a : barcode.format;
1146
+ // Rate control: suppress re-emission of the same code (value + type)
1147
+ // within the suppression window. A genuinely new code has a different
1148
+ // key and emits immediately. Timestamps are tracked per key so
1149
+ // multiple codes in frame can't alternate and defeat the window.
1150
+ const barcodeKey = `${type}\u0000${barcode.rawValue}`;
1151
+ const lastEmit = recentBarcodeEmitTimes.get(barcodeKey);
1152
+ if (lastEmit === undefined || now - lastEmit >= BARCODE_SUPPRESSION_WINDOW_MS) {
1153
+ // Prune expired entries once the map grows, keeping it bounded
1154
+ // during long sessions that scan many different codes.
1155
+ if (recentBarcodeEmitTimes.size > BARCODE_DEDUPE_MAP_PRUNE_THRESHOLD) {
1156
+ for (const [key, emitTime] of recentBarcodeEmitTimes) {
1157
+ if (now - emitTime >= BARCODE_SUPPRESSION_WINDOW_MS) {
1158
+ recentBarcodeEmitTimes.delete(key);
1159
+ }
1160
+ }
1161
+ }
1162
+ recentBarcodeEmitTimes.set(barcodeKey, now);
1163
+ // Transform barcode coordinates using the utility function,
1164
+ // accounting for the session's preview scale mode (cover crops,
1165
+ // fit letterboxes) so the rect lands over the on-screen barcode.
1166
+ const boundingRect = transformBarcodeBoundingBox(barcode.boundingBox, videoElement, this.sessionPreviewScaleMode);
1167
+ this.notifyListeners('barcodeDetected', {
1168
+ value: barcode.rawValue,
1169
+ type,
1170
+ boundingRect,
1171
+ });
1172
+ }
634
1173
  }
635
1174
  }
636
1175
  catch (err) {
637
1176
  console.error('Barcode detection error', err);
638
1177
  }
639
1178
  }
640
- if (__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
641
- requestAnimationFrame(detectFrame);
1179
+ if (!signal.aborted && __classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
1180
+ this.barcodeAnimationFrameId = requestAnimationFrame(detectFrame);
642
1181
  }
643
1182
  };
644
- requestAnimationFrame(detectFrame);
645
- }
646
- /**
647
- * Clean up resources when the plugin is disposed
648
- */
649
- async handleOnDestroy() {
650
- var _a;
651
- await this.stop();
652
- // Remove elements from DOM
653
- if ((_a = this.videoElement) === null || _a === void 0 ? void 0 : _a.parentNode) {
654
- this.videoElement.parentNode.removeChild(this.videoElement);
655
- this.videoElement = null;
656
- }
657
- if (this.canvasElement) {
658
- this.canvasElement = null;
659
- }
660
- this.barcodeDetector = null;
1183
+ this.barcodeAnimationFrameId = requestAnimationFrame(detectFrame);
661
1184
  }
662
1185
  /**
663
1186
  * Check if barcode detection is supported in this browser
@@ -764,8 +1287,17 @@ var capacitorCameraView = (function (exports, core) {
764
1287
 
765
1288
  var web = /*#__PURE__*/Object.freeze({
766
1289
  __proto__: null,
1290
+ BARCODE_DEDUPE_MAP_PRUNE_THRESHOLD: BARCODE_DEDUPE_MAP_PRUNE_THRESHOLD,
1291
+ BARCODE_SUPPRESSION_WINDOW_MS: BARCODE_SUPPRESSION_WINDOW_MS,
767
1292
  BARCODE_TYPE_TO_WEB_FORMAT: BARCODE_TYPE_TO_WEB_FORMAT,
768
- CameraViewWeb: CameraViewWeb
1293
+ BARCODE_VIDEO_READY_TIMEOUT_MS: BARCODE_VIDEO_READY_TIMEOUT_MS,
1294
+ CameraViewError: CameraViewError,
1295
+ CameraViewWeb: CameraViewWeb,
1296
+ DEFAULT_IDEAL_CAPTURE_WIDTH: DEFAULT_IDEAL_CAPTURE_WIDTH,
1297
+ SIMULATED_ZOOM_MAX: SIMULATED_ZOOM_MAX,
1298
+ SIMULATED_ZOOM_MIN: SIMULATED_ZOOM_MIN,
1299
+ WEB_FORMAT_TO_BARCODE_TYPE: WEB_FORMAT_TO_BARCODE_TYPE,
1300
+ classifyGetUserMediaErrorCode: classifyGetUserMediaErrorCode
769
1301
  });
770
1302
 
771
1303
  exports.CameraView = CameraView;