@marianmeres/scanner 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +144 -0
- package/API.md +696 -0
- package/CLAUDE.md +3 -0
- package/LICENSE +21 -0
- package/README.md +272 -0
- package/dist/camera-adapter.d.ts +31 -0
- package/dist/camera-adapter.js +96 -0
- package/dist/detector.d.ts +25 -0
- package/dist/detector.js +36 -0
- package/dist/mod.d.ts +5 -0
- package/dist/mod.js +5 -0
- package/dist/scan-image.d.ts +24 -0
- package/dist/scan-image.js +35 -0
- package/dist/scanner.d.ts +17 -0
- package/dist/scanner.js +594 -0
- package/dist/stage.d.ts +59 -0
- package/dist/stage.js +334 -0
- package/dist/types.d.ts +262 -0
- package/dist/types.js +28 -0
- package/package.json +40 -0
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// deno-lint-ignore-file no-explicit-any
|
|
2
|
+
import { createClog } from "@marianmeres/clog";
|
|
3
|
+
import { createStore } from "@marianmeres/store";
|
|
4
|
+
import { createDefaultCameraAdapter } from "./camera-adapter.js";
|
|
5
|
+
import { createDefaultDetector, toScanResult } from "./detector.js";
|
|
6
|
+
import { ScannerErrorCode } from "./types.js";
|
|
7
|
+
const _g = globalThis;
|
|
8
|
+
const DEFAULT_SCAN_INTERVAL_MS = 100;
|
|
9
|
+
const DEFAULT_DEDUPE_MS = 1500;
|
|
10
|
+
/** Stop scanning after this many consecutive detector failures. */
|
|
11
|
+
const MAX_DETECT_FAILURES = 5;
|
|
12
|
+
/** Classify a getUserMedia (or adapter) rejection into a {@linkcode ScannerError}. */
|
|
13
|
+
export function classifyAcquireError(e) {
|
|
14
|
+
const name = e?.name ?? "";
|
|
15
|
+
const message = e?.message || String(e);
|
|
16
|
+
switch (name) {
|
|
17
|
+
case "NotFoundError":
|
|
18
|
+
case "DevicesNotFoundError":
|
|
19
|
+
return { code: ScannerErrorCode.NoDevice, message };
|
|
20
|
+
case "SecurityError":
|
|
21
|
+
return { code: ScannerErrorCode.InsecureContext, message };
|
|
22
|
+
case "NotReadableError":
|
|
23
|
+
case "TrackStartError":
|
|
24
|
+
return { code: ScannerErrorCode.DeviceBusy, message };
|
|
25
|
+
case "NotSupportedError":
|
|
26
|
+
return { code: ScannerErrorCode.NotSupported, message };
|
|
27
|
+
default:
|
|
28
|
+
return { code: ScannerErrorCode.RequestFailed, message };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isPermissionDenied(e) {
|
|
32
|
+
const name = e?.name ?? "";
|
|
33
|
+
return name === "NotAllowedError" || name === "PermissionDeniedError";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Create a headless (no UI) camera barcode scanner.
|
|
37
|
+
*
|
|
38
|
+
* Reactive state is exposed via the svelte-store-compatible
|
|
39
|
+
* `subscribe`/`get`. Methods never throw — errors land in `state.error`
|
|
40
|
+
* (see {@linkcode ScannerErrorCode}).
|
|
41
|
+
*
|
|
42
|
+
* ```ts
|
|
43
|
+
* const scanner = createScanner({ onScan: (r) => console.log(r.value) });
|
|
44
|
+
* const result = await scanner.start(); // single-shot: auto-stops on first hit
|
|
45
|
+
* scanner.destroy();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function createScanner(config = {}) {
|
|
49
|
+
const logger = config.logger ?? createClog("scanner");
|
|
50
|
+
const ownsAdapter = !config.adapter;
|
|
51
|
+
const adapter = config.adapter ?? createDefaultCameraAdapter();
|
|
52
|
+
const mode = config.mode ?? "single";
|
|
53
|
+
const scanIntervalMs = Math.max(0, config.scanIntervalMs ?? DEFAULT_SCAN_INTERVAL_MS);
|
|
54
|
+
const dedupeMs = Math.max(0, config.dedupeMs ?? DEFAULT_DEDUPE_MS);
|
|
55
|
+
const store = createStore({
|
|
56
|
+
status: "idle",
|
|
57
|
+
error: null,
|
|
58
|
+
permission: "unknown",
|
|
59
|
+
torch: { supported: false, on: false },
|
|
60
|
+
cameras: [],
|
|
61
|
+
activeCameraId: null,
|
|
62
|
+
lastResult: null,
|
|
63
|
+
});
|
|
64
|
+
let detector = config.detector ?? null;
|
|
65
|
+
let video = config.video ?? null;
|
|
66
|
+
let stream = null;
|
|
67
|
+
let destroyed = false;
|
|
68
|
+
/** Detection loop is running. */
|
|
69
|
+
let active = false;
|
|
70
|
+
/** Temporarily suspend detection (e.g. during live camera switch). */
|
|
71
|
+
let paused = false;
|
|
72
|
+
/** Invalidates in-flight async init when start/stop interleave. */
|
|
73
|
+
let generation = 0;
|
|
74
|
+
let startPromise = null;
|
|
75
|
+
let resolveStart = null;
|
|
76
|
+
let frameCancel = null;
|
|
77
|
+
let lastDetectAt = 0;
|
|
78
|
+
let consecutiveFailures = 0;
|
|
79
|
+
/** Continuous mode dedupe: `format|value` -> last emit timestamp. */
|
|
80
|
+
const seen = new Map();
|
|
81
|
+
/** Explicit camera override (set via setCamera). */
|
|
82
|
+
let overrideDeviceId = null;
|
|
83
|
+
/** Serializes live camera switches (concurrent setCamera calls chain). */
|
|
84
|
+
let switchChain = Promise.resolve();
|
|
85
|
+
/** Delayed torch-capability re-probe (Android Chrome quirk). */
|
|
86
|
+
let torchProbeTimer = null;
|
|
87
|
+
/** Detaches the current track "ended" watcher. */
|
|
88
|
+
let trackEndedCleanup = null;
|
|
89
|
+
const cleanups = [];
|
|
90
|
+
// -----------------------------------------------------------------------
|
|
91
|
+
// State helpers
|
|
92
|
+
// -----------------------------------------------------------------------
|
|
93
|
+
function update(patch) {
|
|
94
|
+
store.update((s) => ({ ...s, ...patch }));
|
|
95
|
+
}
|
|
96
|
+
function setError(error) {
|
|
97
|
+
logger.warn(`[${error.code}]`, error.message);
|
|
98
|
+
update({ error });
|
|
99
|
+
try {
|
|
100
|
+
config.onError?.(error);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
logger.error("onError callback threw", e);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Classify + report a camera acquisition failure (start or live switch). */
|
|
107
|
+
function reportAcquireError(e) {
|
|
108
|
+
if (isPermissionDenied(e)) {
|
|
109
|
+
update({ permission: "denied" });
|
|
110
|
+
setError({
|
|
111
|
+
code: ScannerErrorCode.PermissionDenied,
|
|
112
|
+
message: e?.message || "Camera permission denied",
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
setError(classifyAcquireError(e));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function safeOnScan(result) {
|
|
120
|
+
try {
|
|
121
|
+
config.onScan?.(result);
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
logger.error("onScan callback threw", e);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// -----------------------------------------------------------------------
|
|
128
|
+
// Video element
|
|
129
|
+
// -----------------------------------------------------------------------
|
|
130
|
+
function prepVideoEl(v) {
|
|
131
|
+
try {
|
|
132
|
+
v.muted = true;
|
|
133
|
+
v.autoplay = true;
|
|
134
|
+
v.playsInline = true;
|
|
135
|
+
v.setAttribute?.("playsinline", "");
|
|
136
|
+
v.setAttribute?.("muted", "");
|
|
137
|
+
v.setAttribute?.("autoplay", "");
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
logger.debug("prepVideoEl", e);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function getVideo() {
|
|
144
|
+
if (video)
|
|
145
|
+
return video;
|
|
146
|
+
if (!_g?.document?.createElement)
|
|
147
|
+
return null;
|
|
148
|
+
video = _g.document.createElement("video");
|
|
149
|
+
prepVideoEl(video);
|
|
150
|
+
return video;
|
|
151
|
+
}
|
|
152
|
+
function videoReady(v) {
|
|
153
|
+
// HAVE_CURRENT_DATA (2)+ and a real frame size
|
|
154
|
+
return (v.readyState ?? 0) >= 2 &&
|
|
155
|
+
(v.videoWidth ?? 0) > 0;
|
|
156
|
+
}
|
|
157
|
+
// -----------------------------------------------------------------------
|
|
158
|
+
// Detection loop
|
|
159
|
+
// -----------------------------------------------------------------------
|
|
160
|
+
function scheduleFrame(v, fn) {
|
|
161
|
+
const anyV = v;
|
|
162
|
+
if (typeof anyV.requestVideoFrameCallback === "function") {
|
|
163
|
+
const id = anyV.requestVideoFrameCallback(() => fn());
|
|
164
|
+
return () => anyV.cancelVideoFrameCallback?.(id);
|
|
165
|
+
}
|
|
166
|
+
if (typeof _g.requestAnimationFrame === "function") {
|
|
167
|
+
const id = _g.requestAnimationFrame(() => fn());
|
|
168
|
+
return () => _g.cancelAnimationFrame?.(id);
|
|
169
|
+
}
|
|
170
|
+
const id = setTimeout(fn, Math.max(scanIntervalMs, 16));
|
|
171
|
+
return () => clearTimeout(id);
|
|
172
|
+
}
|
|
173
|
+
function loop() {
|
|
174
|
+
if (!active || destroyed || !video)
|
|
175
|
+
return;
|
|
176
|
+
const gen = generation;
|
|
177
|
+
frameCancel = scheduleFrame(video, () => void onFrame(gen));
|
|
178
|
+
}
|
|
179
|
+
async function onFrame(gen) {
|
|
180
|
+
if (gen !== generation || !active || destroyed || !video)
|
|
181
|
+
return;
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
const hidden = _g?.document?.visibilityState === "hidden";
|
|
184
|
+
if (!hidden && !paused && now - lastDetectAt >= scanIntervalMs &&
|
|
185
|
+
videoReady(video)) {
|
|
186
|
+
lastDetectAt = now;
|
|
187
|
+
try {
|
|
188
|
+
const detections = await detector.detect(video);
|
|
189
|
+
// the session may have been stopped/replaced while decoding —
|
|
190
|
+
// a stale frame must not touch the new session (results,
|
|
191
|
+
// counters, nor fork a second frame chain via the trailing loop)
|
|
192
|
+
if (gen !== generation || !active || destroyed)
|
|
193
|
+
return;
|
|
194
|
+
consecutiveFailures = 0;
|
|
195
|
+
if (detections?.length) {
|
|
196
|
+
handleDetections(detections.map((d) => toScanResult(d)));
|
|
197
|
+
if (!active)
|
|
198
|
+
return; // single-shot finished
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
if (gen !== generation || destroyed)
|
|
203
|
+
return;
|
|
204
|
+
consecutiveFailures++;
|
|
205
|
+
logger.debug("detect failed", e);
|
|
206
|
+
if (consecutiveFailures >= MAX_DETECT_FAILURES) {
|
|
207
|
+
setError({
|
|
208
|
+
code: ScannerErrorCode.DetectorFailed,
|
|
209
|
+
message: e?.message || String(e),
|
|
210
|
+
});
|
|
211
|
+
stopWith(null, "stopped");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
loop();
|
|
217
|
+
}
|
|
218
|
+
function handleDetections(results) {
|
|
219
|
+
if (mode === "single") {
|
|
220
|
+
const result = results[0];
|
|
221
|
+
update({ lastResult: result });
|
|
222
|
+
safeOnScan(result);
|
|
223
|
+
stopWith(result, "stopped");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// continuous
|
|
227
|
+
for (const result of results) {
|
|
228
|
+
const key = `${result.format}|${result.value}`;
|
|
229
|
+
const last = seen.get(key) ?? 0;
|
|
230
|
+
if (result.timestamp - last < dedupeMs)
|
|
231
|
+
continue;
|
|
232
|
+
seen.set(key, result.timestamp);
|
|
233
|
+
update({ lastResult: result });
|
|
234
|
+
safeOnScan(result);
|
|
235
|
+
}
|
|
236
|
+
// opportunistic prune — expired entries can never suppress again, and a
|
|
237
|
+
// long-lived session over unique codes would otherwise grow unbounded
|
|
238
|
+
if (seen.size > 64) {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
for (const [key, ts] of seen) {
|
|
241
|
+
if (now - ts >= dedupeMs)
|
|
242
|
+
seen.delete(key);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// -----------------------------------------------------------------------
|
|
247
|
+
// Start / stop
|
|
248
|
+
// -----------------------------------------------------------------------
|
|
249
|
+
function buildConstraints() {
|
|
250
|
+
const cam = overrideDeviceId ?? config.preferredCamera ?? "environment";
|
|
251
|
+
const videoC = cam === "environment" || cam === "user"
|
|
252
|
+
? { facingMode: { ideal: cam } }
|
|
253
|
+
: { deviceId: { exact: cam } };
|
|
254
|
+
return { video: videoC, audio: false };
|
|
255
|
+
}
|
|
256
|
+
function adoptStream(s) {
|
|
257
|
+
stream = s;
|
|
258
|
+
const track = s.getVideoTracks?.()[0];
|
|
259
|
+
const settings = track?.getSettings?.();
|
|
260
|
+
const caps = track?.getCapabilities?.();
|
|
261
|
+
update({
|
|
262
|
+
activeCameraId: settings?.deviceId ?? null,
|
|
263
|
+
torch: { supported: !!caps?.torch, on: false },
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Android Chrome reports track capabilities (notably `torch`) only after
|
|
268
|
+
* the camera actually streams — re-probe shortly after adoption so
|
|
269
|
+
* `state.torch.supported` does not stay a stale `false`.
|
|
270
|
+
*/
|
|
271
|
+
function scheduleTorchReprobe(track, gen) {
|
|
272
|
+
if (typeof track?.getCapabilities !== "function")
|
|
273
|
+
return;
|
|
274
|
+
if (torchProbeTimer)
|
|
275
|
+
clearTimeout(torchProbeTimer);
|
|
276
|
+
torchProbeTimer = setTimeout(() => {
|
|
277
|
+
torchProbeTimer = null;
|
|
278
|
+
if (gen !== generation || destroyed)
|
|
279
|
+
return;
|
|
280
|
+
try {
|
|
281
|
+
const caps = track.getCapabilities?.();
|
|
282
|
+
if (caps?.torch && !store.get().torch.supported) {
|
|
283
|
+
store.update((s) => ({
|
|
284
|
+
...s,
|
|
285
|
+
torch: { ...s.torch, supported: true },
|
|
286
|
+
}));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
catch (_e) {
|
|
290
|
+
/* noop */
|
|
291
|
+
}
|
|
292
|
+
}, 500);
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The OS/browser can kill the camera track outside our control (permission
|
|
296
|
+
* revoked mid-scan, USB camera unplugged, native app preempting the
|
|
297
|
+
* camera). Without this the scanner would hang in "scanning" forever.
|
|
298
|
+
*/
|
|
299
|
+
function watchTrackEnded(track, gen) {
|
|
300
|
+
trackEndedCleanup?.();
|
|
301
|
+
trackEndedCleanup = null;
|
|
302
|
+
if (typeof track?.addEventListener !== "function")
|
|
303
|
+
return;
|
|
304
|
+
const onEnded = () => {
|
|
305
|
+
if (gen !== generation || destroyed)
|
|
306
|
+
return;
|
|
307
|
+
setError({
|
|
308
|
+
code: ScannerErrorCode.RequestFailed,
|
|
309
|
+
message: "Camera track ended unexpectedly " +
|
|
310
|
+
"(permission revoked, device unplugged or preempted)",
|
|
311
|
+
});
|
|
312
|
+
stopWith(null, "stopped");
|
|
313
|
+
};
|
|
314
|
+
track.addEventListener("ended", onEnded);
|
|
315
|
+
trackEndedCleanup = () => track.removeEventListener?.("ended", onEnded);
|
|
316
|
+
}
|
|
317
|
+
async function playStream(s) {
|
|
318
|
+
if (!video)
|
|
319
|
+
return;
|
|
320
|
+
try {
|
|
321
|
+
video.srcObject = s;
|
|
322
|
+
await video.play?.();
|
|
323
|
+
}
|
|
324
|
+
catch (e) {
|
|
325
|
+
// autoplay/gesture quirks — the stream is still attached; log only
|
|
326
|
+
logger.debug("video.play()", e);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function refreshCameras() {
|
|
330
|
+
if (destroyed)
|
|
331
|
+
return [];
|
|
332
|
+
try {
|
|
333
|
+
const cameras = await adapter.enumerateVideoDevices();
|
|
334
|
+
if (!destroyed)
|
|
335
|
+
update({ cameras });
|
|
336
|
+
return cameras;
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
logger.debug("enumerateVideoDevices failed", e);
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async function init(gen) {
|
|
344
|
+
update({ status: "initializing", error: null, lastResult: null });
|
|
345
|
+
// lazy detector (wasm cost only when actually used)
|
|
346
|
+
if (!detector) {
|
|
347
|
+
try {
|
|
348
|
+
detector = createDefaultDetector({
|
|
349
|
+
formats: config.formats,
|
|
350
|
+
wasmOverrides: config.wasmOverrides,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
setError({
|
|
355
|
+
code: ScannerErrorCode.DetectorFailed,
|
|
356
|
+
message: e?.message || String(e),
|
|
357
|
+
});
|
|
358
|
+
finishStart(null, "idle");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (!getVideo()) {
|
|
363
|
+
setError({
|
|
364
|
+
code: ScannerErrorCode.NotSupported,
|
|
365
|
+
message: "No DOM available to create a video element " +
|
|
366
|
+
"(pass config.video explicitly)",
|
|
367
|
+
});
|
|
368
|
+
finishStart(null, "idle");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
let s;
|
|
372
|
+
try {
|
|
373
|
+
s = await adapter.getStream(buildConstraints());
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
// stopped/destroyed while waiting — the cancelling stopWith()/destroy()
|
|
377
|
+
// already resolved the (old) promise and set the final status; this
|
|
378
|
+
// rejection belongs to a request nobody is waiting for anymore
|
|
379
|
+
if (gen !== generation || destroyed)
|
|
380
|
+
return;
|
|
381
|
+
reportAcquireError(e);
|
|
382
|
+
finishStart(null, "idle");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (gen !== generation || destroyed) {
|
|
386
|
+
// stopped/destroyed while waiting for the stream
|
|
387
|
+
s.getTracks?.().forEach((t) => t.stop());
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
update({ permission: "granted" });
|
|
391
|
+
adoptStream(s);
|
|
392
|
+
await playStream(s);
|
|
393
|
+
if (gen !== generation || destroyed)
|
|
394
|
+
return;
|
|
395
|
+
const track = s.getVideoTracks?.()[0];
|
|
396
|
+
watchTrackEnded(track, gen);
|
|
397
|
+
scheduleTorchReprobe(track, gen);
|
|
398
|
+
lastDetectAt = 0;
|
|
399
|
+
consecutiveFailures = 0;
|
|
400
|
+
seen.clear();
|
|
401
|
+
active = true;
|
|
402
|
+
update({ status: "scanning" });
|
|
403
|
+
void refreshCameras(); // labels are available now — fire and forget
|
|
404
|
+
loop();
|
|
405
|
+
}
|
|
406
|
+
/** Resolve the pending start() promise and set a final status (no stream teardown). */
|
|
407
|
+
function finishStart(result, status) {
|
|
408
|
+
update({ status });
|
|
409
|
+
const resolve = resolveStart;
|
|
410
|
+
resolveStart = null;
|
|
411
|
+
startPromise = null;
|
|
412
|
+
resolve?.(result);
|
|
413
|
+
}
|
|
414
|
+
/** Full teardown: loop, tracks, video binding + resolve pending start(). */
|
|
415
|
+
function stopWith(result, status) {
|
|
416
|
+
generation++;
|
|
417
|
+
active = false;
|
|
418
|
+
paused = false;
|
|
419
|
+
frameCancel?.();
|
|
420
|
+
frameCancel = null;
|
|
421
|
+
trackEndedCleanup?.();
|
|
422
|
+
trackEndedCleanup = null;
|
|
423
|
+
if (torchProbeTimer) {
|
|
424
|
+
clearTimeout(torchProbeTimer);
|
|
425
|
+
torchProbeTimer = null;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
stream?.getTracks?.().forEach((t) => t.stop());
|
|
429
|
+
}
|
|
430
|
+
catch (e) {
|
|
431
|
+
logger.debug("track stop", e);
|
|
432
|
+
}
|
|
433
|
+
stream = null;
|
|
434
|
+
if (video) {
|
|
435
|
+
try {
|
|
436
|
+
video.srcObject = null;
|
|
437
|
+
}
|
|
438
|
+
catch (_e) {
|
|
439
|
+
/* noop */
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
store.update((s) => ({ ...s, torch: { ...s.torch, on: false } }));
|
|
443
|
+
finishStart(result, status);
|
|
444
|
+
}
|
|
445
|
+
// -----------------------------------------------------------------------
|
|
446
|
+
// Public api
|
|
447
|
+
// -----------------------------------------------------------------------
|
|
448
|
+
function start() {
|
|
449
|
+
if (destroyed) {
|
|
450
|
+
logger.warn("start() called on destroyed scanner");
|
|
451
|
+
return Promise.resolve(null);
|
|
452
|
+
}
|
|
453
|
+
if (startPromise)
|
|
454
|
+
return startPromise;
|
|
455
|
+
startPromise = new Promise((resolve) => {
|
|
456
|
+
resolveStart = resolve;
|
|
457
|
+
});
|
|
458
|
+
const p = startPromise;
|
|
459
|
+
void init(++generation);
|
|
460
|
+
return p;
|
|
461
|
+
}
|
|
462
|
+
function stop() {
|
|
463
|
+
if (destroyed)
|
|
464
|
+
return;
|
|
465
|
+
if (!startPromise && !active && !stream)
|
|
466
|
+
return;
|
|
467
|
+
stopWith(null, "stopped");
|
|
468
|
+
}
|
|
469
|
+
function setCamera(deviceId) {
|
|
470
|
+
if (destroyed)
|
|
471
|
+
return Promise.resolve();
|
|
472
|
+
overrideDeviceId = deviceId;
|
|
473
|
+
// serialize live switches — a second call issued mid-switch would
|
|
474
|
+
// otherwise see `stream === null` and silently no-op on the wrong camera
|
|
475
|
+
switchChain = switchChain.then(() => doSwitchCamera(deviceId));
|
|
476
|
+
return switchChain;
|
|
477
|
+
}
|
|
478
|
+
async function doSwitchCamera(deviceId) {
|
|
479
|
+
if (destroyed || !stream)
|
|
480
|
+
return; // not scanning: applied on next start()
|
|
481
|
+
if (store.get().activeCameraId === deviceId)
|
|
482
|
+
return; // already live
|
|
483
|
+
const gen = generation;
|
|
484
|
+
paused = true;
|
|
485
|
+
try {
|
|
486
|
+
// most mobile browsers cannot open two cameras at once — release first
|
|
487
|
+
stream.getTracks?.().forEach((t) => t.stop());
|
|
488
|
+
stream = null;
|
|
489
|
+
const s = await adapter.getStream({
|
|
490
|
+
video: { deviceId: { exact: deviceId } },
|
|
491
|
+
audio: false,
|
|
492
|
+
});
|
|
493
|
+
if (gen !== generation || destroyed || !active) {
|
|
494
|
+
// session stopped/replaced while switching — not ours to adopt
|
|
495
|
+
s.getTracks?.().forEach((t) => t.stop());
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
adoptStream(s);
|
|
499
|
+
await playStream(s);
|
|
500
|
+
const track = s.getVideoTracks?.()[0];
|
|
501
|
+
watchTrackEnded(track, gen);
|
|
502
|
+
scheduleTorchReprobe(track, gen);
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
// a stale failure must not kill the session that replaced us
|
|
506
|
+
if (gen !== generation || destroyed)
|
|
507
|
+
return;
|
|
508
|
+
reportAcquireError(e);
|
|
509
|
+
stopWith(null, "stopped");
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
// only un-pause the session this switch belongs to
|
|
513
|
+
if (gen === generation)
|
|
514
|
+
paused = false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
async function setTorch(on) {
|
|
518
|
+
if (destroyed)
|
|
519
|
+
return false;
|
|
520
|
+
const gen = generation;
|
|
521
|
+
const track = stream?.getVideoTracks?.()[0];
|
|
522
|
+
const caps = track?.getCapabilities?.();
|
|
523
|
+
if (!caps?.torch)
|
|
524
|
+
return false;
|
|
525
|
+
try {
|
|
526
|
+
await track.applyConstraints({ advanced: [{ torch: on }] });
|
|
527
|
+
// the session may have been stopped while applying — the store
|
|
528
|
+
// already reflects torch.off for the (now released) track
|
|
529
|
+
if (gen !== generation || destroyed)
|
|
530
|
+
return false;
|
|
531
|
+
store.update((s) => ({ ...s, torch: { ...s.torch, on } }));
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
catch (e) {
|
|
535
|
+
logger.warn("setTorch failed", e);
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function destroy() {
|
|
540
|
+
if (destroyed)
|
|
541
|
+
return;
|
|
542
|
+
stopWith(null, "stopped");
|
|
543
|
+
cleanups.forEach((fn) => {
|
|
544
|
+
try {
|
|
545
|
+
fn();
|
|
546
|
+
}
|
|
547
|
+
catch (_e) {
|
|
548
|
+
/* noop */
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
cleanups.length = 0;
|
|
552
|
+
if (ownsAdapter) {
|
|
553
|
+
try {
|
|
554
|
+
adapter.destroy?.();
|
|
555
|
+
}
|
|
556
|
+
catch (e) {
|
|
557
|
+
logger.debug("adapter destroy", e);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
destroyed = true;
|
|
561
|
+
}
|
|
562
|
+
// a consumer-provided video element gets the same treatment as an
|
|
563
|
+
// internally created one (playsinline/muted/autoplay — iOS quirks)
|
|
564
|
+
if (video)
|
|
565
|
+
prepVideoEl(video);
|
|
566
|
+
// -----------------------------------------------------------------------
|
|
567
|
+
// Permission tracking (best effort, fire-and-forget)
|
|
568
|
+
// -----------------------------------------------------------------------
|
|
569
|
+
adapter.queryPermission?.()
|
|
570
|
+
.then((status) => {
|
|
571
|
+
if (status && !destroyed)
|
|
572
|
+
update({ permission: status });
|
|
573
|
+
})
|
|
574
|
+
.catch(() => {
|
|
575
|
+
/* noop */
|
|
576
|
+
});
|
|
577
|
+
const unsubPerm = adapter.onPermissionChange?.((status) => {
|
|
578
|
+
if (!destroyed)
|
|
579
|
+
update({ permission: status });
|
|
580
|
+
});
|
|
581
|
+
if (unsubPerm)
|
|
582
|
+
cleanups.push(unsubPerm);
|
|
583
|
+
return {
|
|
584
|
+
subscribe: store.subscribe.bind(store),
|
|
585
|
+
get: store.get.bind(store),
|
|
586
|
+
start,
|
|
587
|
+
stop,
|
|
588
|
+
listCameras: refreshCameras,
|
|
589
|
+
setCamera,
|
|
590
|
+
setTorch,
|
|
591
|
+
getVideo,
|
|
592
|
+
destroy,
|
|
593
|
+
};
|
|
594
|
+
}
|
package/dist/stage.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic DOM "stage" for the scanner — a mountable camera
|
|
3
|
+
* preview with the typical scanning UX: dimmed overlay with a viewfinder
|
|
4
|
+
* cutout, corner guides, optional scan-line animation and opt-in control
|
|
5
|
+
* buttons (cancel / torch / camera switch).
|
|
6
|
+
*
|
|
7
|
+
* The stage is a pure consumer of the headless {@linkcode Scanner} — it
|
|
8
|
+
* renders its video element and reflects its reactive state. Destroying the
|
|
9
|
+
* stage does NOT stop or destroy the scanner (and vice versa).
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import type { Scanner } from "./types.js";
|
|
14
|
+
/** Stage color theme. `"auto"` follows `prefers-color-scheme`. */
|
|
15
|
+
export type ScannerStageTheme = "auto" | "light" | "dark";
|
|
16
|
+
/** Options for {@linkcode createScannerStage}. */
|
|
17
|
+
export interface ScannerStageOptions {
|
|
18
|
+
/** Element to mount the stage into (its position will be set to relative). */
|
|
19
|
+
container: HTMLElement;
|
|
20
|
+
/**
|
|
21
|
+
* Which built-in control buttons to render. Buttons for unsupported
|
|
22
|
+
* features hide automatically (torch without capability, camera switch
|
|
23
|
+
* with a single camera). Default: `{ cancel: true }`.
|
|
24
|
+
*/
|
|
25
|
+
controls?: {
|
|
26
|
+
cancel?: boolean;
|
|
27
|
+
torch?: boolean;
|
|
28
|
+
cameraSwitch?: boolean;
|
|
29
|
+
};
|
|
30
|
+
/** Render the animated scan line inside the viewfinder. Default `true`. */
|
|
31
|
+
scanLine?: boolean;
|
|
32
|
+
/** Color theme. Default `"auto"`. */
|
|
33
|
+
theme?: ScannerStageTheme;
|
|
34
|
+
/** Accent color (CSS color value) — sets `--mms-accent`. */
|
|
35
|
+
accent?: string;
|
|
36
|
+
/** Called after the built-in cancel button stopped the scanner. */
|
|
37
|
+
onCancel?: () => void;
|
|
38
|
+
}
|
|
39
|
+
/** Handle returned by {@linkcode createScannerStage}. */
|
|
40
|
+
export interface ScannerStage {
|
|
41
|
+
/** The stage root element (already mounted into the container). */
|
|
42
|
+
el: HTMLElement;
|
|
43
|
+
/** Unmount and release stage resources. Does NOT touch the scanner. */
|
|
44
|
+
destroy(): void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Mount the scanning stage UI into `options.container`.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* const scanner = createScanner();
|
|
51
|
+
* const stage = createScannerStage(scanner, { container: el });
|
|
52
|
+
* const result = await scanner.start();
|
|
53
|
+
* stage.destroy();
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Styling is themable via scoped CSS custom properties (`--mms-*`), e.g.
|
|
57
|
+
* `--mms-accent`, `--mms-dim`, `--mms-frame-size`, `--mms-radius`.
|
|
58
|
+
*/
|
|
59
|
+
export declare function createScannerStage(scanner: Scanner, options: ScannerStageOptions): ScannerStage;
|