capacitor-camera-view 2.4.0 → 3.0.0

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