capacitor-camera-view 2.4.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorCameraView.podspec +1 -1
- package/Package.swift +1 -1
- package/README.md +341 -49
- package/android/build.gradle +0 -1
- package/android/src/main/AndroidManifest.xml +0 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraError.kt +42 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraView.kt +945 -207
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/CameraViewPlugin.kt +87 -43
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/BarcodeDetectionResult.kt +28 -2
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraDevice.kt +6 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/CameraSessionConfiguration.kt +15 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/TorchModeState.kt +15 -0
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/model/WebBoundingRect.kt +1 -1
- package/android/src/main/java/com/michaelwolz/capacitorcameraview/utils.kt +73 -19
- package/dist/docs.json +475 -30
- package/dist/esm/definitions.d.ts +478 -26
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/utils.d.ts +59 -15
- package/dist/esm/utils.js +79 -38
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/web.d.ts +191 -13
- package/dist/esm/web.js +624 -141
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +711 -179
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +711 -179
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CameraViewPlugin/CameraError.swift +147 -2
- package/ios/Sources/CameraViewPlugin/CameraEvents.swift +41 -19
- package/ios/Sources/CameraViewPlugin/CameraSessionConfiguration.swift +29 -1
- package/ios/Sources/CameraViewPlugin/CameraViewManager+BarcodeScan.swift +78 -23
- package/ios/Sources/CameraViewPlugin/CameraViewManager+DeferredStart.swift +37 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Focus.swift +131 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Lifecycle.swift +156 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+PhotoCapture.swift +73 -41
- package/ios/Sources/CameraViewPlugin/CameraViewManager+ResolutionSelection.swift +57 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Rotation.swift +200 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoDataOutput.swift +24 -12
- package/ios/Sources/CameraViewPlugin/CameraViewManager+VideoRecording.swift +22 -73
- package/ios/Sources/CameraViewPlugin/CameraViewManager+Zoom.swift +113 -0
- package/ios/Sources/CameraViewPlugin/CameraViewManager.swift +450 -403
- package/ios/Sources/CameraViewPlugin/CameraViewPlugin.swift +98 -65
- package/ios/Sources/CameraViewPlugin/Utils.swift +61 -7
- package/package.json +25 -9
package/dist/plugin.cjs.js
CHANGED
|
@@ -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
|
-
*
|
|
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,
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
canvas.
|
|
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,
|
|
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
|
|
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
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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:
|
|
114
|
-
y:
|
|
115
|
-
width:
|
|
116
|
-
height:
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
562
|
+
throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
|
|
280
563
|
}
|
|
281
564
|
try {
|
|
282
565
|
const canvas = this.getCanvasElement();
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
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
|
|
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
|
|
622
|
+
throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
|
|
322
623
|
}
|
|
323
624
|
if (this.mediaRecorder) {
|
|
324
|
-
throw new
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
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:
|
|
462
|
-
max:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
//
|
|
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
|
|
1087
|
+
const videoReady = await new Promise((resolve) => {
|
|
1088
|
+
const cleanup = () => {
|
|
607
1089
|
videoElement.removeEventListener('loadeddata', loadHandler);
|
|
608
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
-
|
|
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;
|