capacitor-camera-view 2.3.1 → 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 -48
- 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 +946 -205
- 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 +487 -30
- package/dist/esm/definitions.d.ts +494 -27
- 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 +626 -142
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +713 -180
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +713 -180
- 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 +111 -69
- 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,21 +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
|
-
|
|
170
|
-
|
|
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) {
|
|
431
|
+
// Create a blob from canvas and return a blob URL.
|
|
432
|
+
// `path` is native-only (no filesystem path on web), so it is omitted here.
|
|
171
433
|
return new Promise((resolve, reject) => {
|
|
172
434
|
canvas.toBlob((blob) => {
|
|
173
435
|
if (!blob) {
|
|
174
|
-
reject(new
|
|
436
|
+
reject(new CameraViewError('Failed to create blob from canvas', 'IMAGE_COMPRESSION_FAILED'));
|
|
175
437
|
return;
|
|
176
438
|
}
|
|
177
439
|
const url = URL.createObjectURL(blob);
|
|
@@ -186,7 +448,7 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
186
448
|
}
|
|
187
449
|
}
|
|
188
450
|
catch (err) {
|
|
189
|
-
throw new
|
|
451
|
+
throw new CameraViewError(`Failed to capture photo: ${this.formatError(err)}`, 'FRAME_CAPTURE_ERROR');
|
|
190
452
|
}
|
|
191
453
|
}
|
|
192
454
|
/**
|
|
@@ -200,10 +462,10 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
200
462
|
*/
|
|
201
463
|
async startRecording(options) {
|
|
202
464
|
if (!__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f") || !this.videoElement) {
|
|
203
|
-
throw new
|
|
465
|
+
throw new CameraViewError('Camera is not running', 'SESSION_NOT_RUNNING');
|
|
204
466
|
}
|
|
205
467
|
if (this.mediaRecorder) {
|
|
206
|
-
throw new
|
|
468
|
+
throw new CameraViewError('Recording is already in progress', 'RECORDING_ALREADY_IN_PROGRESS');
|
|
207
469
|
}
|
|
208
470
|
try {
|
|
209
471
|
let stream = this.stream;
|
|
@@ -253,7 +515,10 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
253
515
|
this.mediaRecorder = null;
|
|
254
516
|
this.recordedChunks = [];
|
|
255
517
|
const errorMessage = (_b = (_a = event.error) === null || _a === void 0 ? void 0 : _a.message) !== null && _b !== void 0 ? _b : 'Unknown recording error';
|
|
256
|
-
|
|
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'));
|
|
257
522
|
this.recordingResolve = null;
|
|
258
523
|
this.recordingReject = null;
|
|
259
524
|
};
|
|
@@ -266,15 +531,43 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
266
531
|
}
|
|
267
532
|
this.mediaRecorder = null;
|
|
268
533
|
this.recordedChunks = [];
|
|
269
|
-
throw new
|
|
534
|
+
throw new CameraViewError(`Failed to start recording: ${this.formatError(err)}`, this.mapRecordingStartErrorCode(err));
|
|
270
535
|
}
|
|
271
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
|
+
}
|
|
272
559
|
/**
|
|
273
560
|
* Stop the current video recording
|
|
274
561
|
*/
|
|
275
562
|
async stopRecording() {
|
|
276
563
|
if (!this.mediaRecorder) {
|
|
277
|
-
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');
|
|
278
571
|
}
|
|
279
572
|
return new Promise((resolve, reject) => {
|
|
280
573
|
var _a;
|
|
@@ -287,45 +580,95 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
287
580
|
* Flip between front and back camera
|
|
288
581
|
*/
|
|
289
582
|
async flipCamera() {
|
|
583
|
+
var _a;
|
|
290
584
|
if (!__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
|
|
291
|
-
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');
|
|
292
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;
|
|
293
608
|
try {
|
|
294
|
-
|
|
295
|
-
this.currentCamera = this.currentCamera === 'user' ? 'environment' : 'user';
|
|
296
|
-
// Stop current stream
|
|
297
|
-
if (this.stream) {
|
|
298
|
-
this.stream.getTracks().forEach((track) => track.stop());
|
|
299
|
-
}
|
|
300
|
-
// Restart with new facing mode
|
|
301
|
-
const constraints = {
|
|
302
|
-
video: {
|
|
303
|
-
facingMode: this.currentCamera,
|
|
304
|
-
},
|
|
305
|
-
audio: false,
|
|
306
|
-
};
|
|
307
|
-
this.stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
308
|
-
if (this.videoElement) {
|
|
309
|
-
this.videoElement.srcObject = this.stream;
|
|
310
|
-
}
|
|
609
|
+
newStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
311
610
|
}
|
|
312
611
|
catch (err) {
|
|
313
|
-
|
|
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);
|
|
314
636
|
}
|
|
315
637
|
}
|
|
316
638
|
/**
|
|
317
|
-
* 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.
|
|
318
646
|
*/
|
|
319
647
|
async getAvailableDevices() {
|
|
648
|
+
var _a;
|
|
320
649
|
try {
|
|
321
650
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
322
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;
|
|
323
657
|
return {
|
|
324
|
-
devices: videoDevices.map((device) =>
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
}),
|
|
329
672
|
};
|
|
330
673
|
}
|
|
331
674
|
catch (err) {
|
|
@@ -334,31 +677,106 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
334
677
|
}
|
|
335
678
|
}
|
|
336
679
|
/**
|
|
337
|
-
* 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.
|
|
338
685
|
*/
|
|
339
686
|
async getZoom() {
|
|
340
|
-
|
|
341
|
-
|
|
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.
|
|
342
698
|
return {
|
|
343
|
-
min:
|
|
344
|
-
max:
|
|
699
|
+
min: SIMULATED_ZOOM_MIN,
|
|
700
|
+
max: SIMULATED_ZOOM_MAX,
|
|
345
701
|
current: this.currentZoom,
|
|
346
702
|
};
|
|
347
703
|
}
|
|
348
704
|
/**
|
|
349
|
-
* 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.
|
|
350
711
|
*/
|
|
351
712
|
async setZoom(options) {
|
|
352
|
-
|
|
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;
|
|
353
729
|
this.currentZoom = options.level;
|
|
354
|
-
// Apply visual zoom using CSS transform when native zoom isn't supported
|
|
355
730
|
if (this.videoElement) {
|
|
356
731
|
this.videoElement.style.transition = options.ramp ? 'transform 0.2s ease-in-out' : 'none';
|
|
357
|
-
|
|
358
|
-
this.videoElement.style.transform = `scale(${scale})`;
|
|
732
|
+
this.videoElement.style.transform = `scale(${this.getCssZoomScale()})`;
|
|
359
733
|
this.videoElement.style.transformOrigin = 'center';
|
|
360
734
|
}
|
|
361
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
|
+
}
|
|
362
780
|
/**
|
|
363
781
|
* Get current flash mode
|
|
364
782
|
*/
|
|
@@ -373,11 +791,16 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
373
791
|
return { flashModes: ['off'] };
|
|
374
792
|
}
|
|
375
793
|
/**
|
|
376
|
-
* 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.
|
|
377
798
|
*/
|
|
378
799
|
async setFlashMode(options) {
|
|
379
|
-
|
|
380
|
-
|
|
800
|
+
if (options.mode !== 'off') {
|
|
801
|
+
throw this.unimplemented('Flash mode control is not supported in the web implementation.');
|
|
802
|
+
}
|
|
803
|
+
this.currentFlashMode = 'off';
|
|
381
804
|
}
|
|
382
805
|
/**
|
|
383
806
|
* Check if torch is available (not supported in web)
|
|
@@ -387,11 +810,14 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
387
810
|
return { available: false };
|
|
388
811
|
}
|
|
389
812
|
/**
|
|
390
|
-
* 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.
|
|
391
818
|
*/
|
|
392
819
|
async getTorchMode() {
|
|
393
|
-
|
|
394
|
-
return { enabled: false, level: 0.0 };
|
|
820
|
+
throw this.unimplemented('Torch control is not supported in web implementation.');
|
|
395
821
|
}
|
|
396
822
|
/**
|
|
397
823
|
* Set torch mode (not supported in web)
|
|
@@ -404,35 +830,38 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
404
830
|
* Check camera and microphone permission without requesting
|
|
405
831
|
*/
|
|
406
832
|
async checkPermissions() {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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.
|
|
422
856
|
}
|
|
423
|
-
// If Permissions API is not available, fall back to checking the active stream
|
|
424
|
-
return {
|
|
425
|
-
camera: this.stream ? 'granted' : 'prompt',
|
|
426
|
-
microphone: 'prompt',
|
|
427
|
-
};
|
|
428
857
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
};
|
|
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';
|
|
435
863
|
}
|
|
864
|
+
return 'prompt';
|
|
436
865
|
}
|
|
437
866
|
/**
|
|
438
867
|
* Request camera and/or microphone permissions from the user.
|
|
@@ -477,70 +906,125 @@ export class CameraViewWeb extends WebPlugin {
|
|
|
477
906
|
* Start barcode detection if supported
|
|
478
907
|
*/
|
|
479
908
|
async startBarcodeDetection() {
|
|
909
|
+
var _a;
|
|
480
910
|
const barcodeDetector = this.barcodeDetector;
|
|
481
911
|
const videoElement = this.videoElement;
|
|
482
912
|
if (!this.barcodeDetectionSupported || !barcodeDetector || !videoElement) {
|
|
483
913
|
return;
|
|
484
914
|
}
|
|
485
|
-
//
|
|
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.
|
|
486
929
|
if (videoElement.readyState < 2) {
|
|
487
|
-
await new Promise((resolve) => {
|
|
488
|
-
const
|
|
930
|
+
const videoReady = await new Promise((resolve) => {
|
|
931
|
+
const cleanup = () => {
|
|
489
932
|
videoElement.removeEventListener('loadeddata', loadHandler);
|
|
490
|
-
|
|
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);
|
|
491
943
|
};
|
|
944
|
+
const timeoutId = setTimeout(() => {
|
|
945
|
+
cleanup();
|
|
946
|
+
resolve(false);
|
|
947
|
+
}, BARCODE_VIDEO_READY_TIMEOUT_MS);
|
|
492
948
|
videoElement.addEventListener('loadeddata', loadHandler);
|
|
949
|
+
signal.addEventListener('abort', abortHandler);
|
|
493
950
|
});
|
|
951
|
+
if (!videoReady || signal.aborted) {
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
if (signal.aborted) {
|
|
956
|
+
return;
|
|
494
957
|
}
|
|
495
958
|
// Add throttling to reduce CPU usage
|
|
496
959
|
let lastDetectionTime = 0;
|
|
497
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();
|
|
498
966
|
// Set up periodic frame analysis for barcode detection
|
|
499
967
|
const detectFrame = async () => {
|
|
500
|
-
|
|
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) {
|
|
501
974
|
return;
|
|
502
975
|
}
|
|
503
|
-
|
|
976
|
+
// Monotonic clock: a backward wall-clock jump must not extend the
|
|
977
|
+
// throttle or suppression windows arbitrarily.
|
|
978
|
+
const now = performance.now();
|
|
504
979
|
if (now - lastDetectionTime >= minTimeBetweenDetections) {
|
|
505
980
|
try {
|
|
506
981
|
const barcodes = await barcodeDetector.detect(videoElement);
|
|
507
982
|
lastDetectionTime = now;
|
|
508
983
|
if (barcodes.length > 0) {
|
|
509
984
|
const barcode = barcodes[0];
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
+
}
|
|
517
1017
|
}
|
|
518
1018
|
}
|
|
519
1019
|
catch (err) {
|
|
520
1020
|
console.error('Barcode detection error', err);
|
|
521
1021
|
}
|
|
522
1022
|
}
|
|
523
|
-
if (__classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
|
|
524
|
-
requestAnimationFrame(detectFrame);
|
|
1023
|
+
if (!signal.aborted && __classPrivateFieldGet(this, _CameraViewWeb_isRunning, "f")) {
|
|
1024
|
+
this.barcodeAnimationFrameId = requestAnimationFrame(detectFrame);
|
|
525
1025
|
}
|
|
526
1026
|
};
|
|
527
|
-
requestAnimationFrame(detectFrame);
|
|
528
|
-
}
|
|
529
|
-
/**
|
|
530
|
-
* Clean up resources when the plugin is disposed
|
|
531
|
-
*/
|
|
532
|
-
async handleOnDestroy() {
|
|
533
|
-
var _a;
|
|
534
|
-
await this.stop();
|
|
535
|
-
// Remove elements from DOM
|
|
536
|
-
if ((_a = this.videoElement) === null || _a === void 0 ? void 0 : _a.parentNode) {
|
|
537
|
-
this.videoElement.parentNode.removeChild(this.videoElement);
|
|
538
|
-
this.videoElement = null;
|
|
539
|
-
}
|
|
540
|
-
if (this.canvasElement) {
|
|
541
|
-
this.canvasElement = null;
|
|
542
|
-
}
|
|
543
|
-
this.barcodeDetector = null;
|
|
1027
|
+
this.barcodeAnimationFrameId = requestAnimationFrame(detectFrame);
|
|
544
1028
|
}
|
|
545
1029
|
/**
|
|
546
1030
|
* Check if barcode detection is supported in this browser
|