@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.
@@ -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
+ }
@@ -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;