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