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.
- 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 +195 -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/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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
405
|
+
throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
|
|
163
406
|
}
|
|
164
407
|
try {
|
|
165
408
|
const canvas = this.getCanvasElement();
|
|
166
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
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
|
|
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
|
|
465
|
+
throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
|
|
205
466
|
}
|
|
206
467
|
if (this.mediaRecorder) {
|
|
207
|
-
throw new
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
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:
|
|
345
|
-
max:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
//
|
|
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
|
|
930
|
+
const videoReady = await new Promise((resolve) => {
|
|
931
|
+
const cleanup = () => {
|
|
490
932
|
videoElement.removeEventListener('loadeddata', loadHandler);
|
|
491
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|