fix(shopfloor): use ZXing's actual API (decodeFromVideoElementContinuously)

The previous patch called reader.decodeFromCanvas which doesn't exist
in @zxing/library 0.21.3. Real methods (verified by grep on the
vendored bundle) are:
  decodeFromVideoElement(el)               -- one-shot
  decodeFromVideoElementContinuously(el, cb) -- continuous loop

Switched to the continuous variant. ZXing manages its own per-frame
timing internally — we just register the (result, err) callback and
React to result.getText() on hits. NotFoundException = no QR this
frame, which we silently ignore.

Also fixed the related video-play race: ZXing internally registers a
'playing' event listener on the video and then calls play() itself.
If we await v.play() ourselves first, the 'playing' event fires
BEFORE ZXing attaches its listener and ZXing then waits forever for
an event that already happened.

Fix: for the zxing path we set attributes + srcObject but do NOT
call play(). ZXing's playVideoOnLoadAsync handles play -> playing ->
decode in the right order. The native and jsQR paths still pre-play
because their loops poll the video themselves.

Cleanup: _stopCamera now calls reader.reset() to tear down ZXing's
internal state cleanly when the modal closes.

Version: shopfloor 19.0.22 -> 19.0.23.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-04-25 13:56:47 -04:00
parent 43397b1854
commit 256ce21522
2 changed files with 107 additions and 82 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Plating — Shop Floor',
'version': '19.0.22.0.0',
'version': '19.0.23.0.0',
'category': 'Manufacturing/Plating',
'summary': 'Shop-floor tablet stations, QR scanning, bake window enforcer, '
'first-piece inspection gates.',

View File

@@ -144,21 +144,38 @@ export class QrScanner extends Component {
// Wait one paint tick so the t-ref resolves to the <video>
await new Promise((r) => requestAnimationFrame(r));
const v = this.videoRef.el;
if (v) {
v.srcObject = this.stream;
// iOS Safari requires both attributes AND the explicit
// play() call; without them the stream stays black.
v.setAttribute("playsinline", "true");
v.muted = true;
await v.play();
if (!v) {
this.state.error = "Video element not mounted";
return;
}
// iOS Safari requires playsinline + muted on the element
// BEFORE it has a source, otherwise the stream stays black
// and play() rejects with "user interaction required".
v.setAttribute("playsinline", "true");
v.setAttribute("muted", "true");
v.muted = true;
v.srcObject = this.stream;
if (this.state.decoder === "zxing") {
// CRITICAL: do NOT call v.play() here. ZXing's
// decodeFromVideoElementContinuously registers a
// "playing" event listener and then calls play()
// itself; if play() has already happened, the
// "playing" event fired before the listener attached
// and ZXing waits forever. Leaving the video paused
// here lets ZXing drive the play -> playing -> decode
// sequence cleanly.
this._zxingDecodeLoop();
} else if (this.state.decoder === "native") {
} else {
// Native BarcodeDetector / jsQR loops both poll the
// video themselves, so they need it actively playing.
await v.play();
if (this.state.decoder === "native") {
this._nativeDecodeLoop();
} else if (this.state.decoder === "jsqr") {
this._jsQRDecodeLoop();
}
}
} catch (e) {
this.state.error = "Couldn't access camera: " + (e.message || e);
}
@@ -166,6 +183,16 @@ export class QrScanner extends Component {
_stopCamera() {
this.decodeLoopActive = false;
// Stop ZXing's internal decode loop if it's running. reset()
// is the documented teardown for BrowserMultiFormatReader.
if (this._zxingReader) {
try {
this._zxingReader.reset();
} catch (e) {
// Some versions throw on double-reset; safe to ignore.
}
this._zxingReader = null;
}
if (this.stream) {
this.stream.getTracks().forEach((t) => t.stop());
this.stream = null;
@@ -203,17 +230,24 @@ export class QrScanner extends Component {
}
/**
* Decode loop using ZXing-js. ZXing is the gold standard for
* pure-JS QR decoding — it's the same algorithm family the iOS
* Camera app uses internally, with HybridBinarizer + perspective
* transform that recover from skew, glare, and motion blur that
* jsQR rejects.
* Continuous QR decode using ZXing-js. We feed our existing
* <video> element (already wired to the getUserMedia stream)
* straight into ZXing's continuous reader, which manages its
* own per-frame timing and decode pipeline (HybridBinarizer +
* perspective transform) — the same algorithm family the iOS
* Camera app uses internally.
*
* We use the lower-level decodeFromCanvas API rather than ZXing's
* built-in continuous video reader so we keep ownership of the
* <video> element, the getUserMedia stream, and the modal UI.
* Status line tracks attempts and last result so any stalls are
* immediately visible.
* The vendored bundle exposes these instance methods on
* ZXing.BrowserMultiFormatReader:
* - decodeFromVideoElement(el) -> one-shot
* - decodeFromVideoElementContinuously(el, cb) -> loop
* The continuous form callbacks `(result, err)` per frame:
* `result` truthy on hit, `err` is usually a NotFoundException
* (no code in this frame) which we ignore.
*
* Cleanup: `reader.reset()` stops the loop and releases internal
* state. We call it from _stopCamera() so closing the modal is
* clean.
*/
_zxingDecodeLoop() {
this.decodeLoopActive = true;
@@ -222,79 +256,70 @@ export class QrScanner extends Component {
this.state.statusLine = "Decoder: zxing — video element missing";
return;
}
if (!this._canvas) {
this._canvas = document.createElement("canvas");
this._ctx = this._canvas.getContext("2d", { willReadFrequently: true });
}
// Constrain ZXing to QR codes only — skips all the other
// 1D / 2D format probes (Code 128, EAN, PDF417, etc.) that
// double the per-frame cost without helping us.
const reader = new window.ZXing.BrowserMultiFormatReader();
const Z = window.ZXing;
const reader = new Z.BrowserMultiFormatReader();
this._zxingReader = reader;
// Restrict to QR codes — skips Code128 / EAN / PDF417 probes
// we don't need, halves per-frame work.
try {
const hints = new Map();
// DecodeHintType.POSSIBLE_FORMATS = 2 in zxing-js
hints.set(2, [window.ZXing.BarcodeFormat.QR_CODE]);
// DecodeHintType.POSSIBLE_FORMATS == 2 in the zxing-js enum.
const formatsKey = (Z.DecodeHintType && Z.DecodeHintType.POSSIBLE_FORMATS) || 2;
const qrFormat = (Z.BarcodeFormat && Z.BarcodeFormat.QR_CODE);
if (qrFormat !== undefined) {
hints.set(formatsKey, [qrFormat]);
reader.hints = hints;
} catch (e) {
// Older ZXing versions don't expose hints this way; fine,
// just decode all formats.
}
let frames = 0;
let attempts = 0;
let lastDecode = 0;
} catch (e) {
// Hint API differs across versions — fine, decode all formats.
}
// Live status — ZXing manages its own timing internally so we
// count callbacks instead of rAF ticks. Hits is what matters.
let callbacks = 0;
let lastStatus = 0;
let lastResult = "—";
const MIN_INTERVAL_MS = 100;
const STATUS_INTERVAL_MS = 500;
const tick = (now) => {
if (!this.decodeLoopActive || !this.state.open) return;
frames++;
if (
v.readyState >= 2 &&
v.videoWidth && v.videoHeight &&
(now - lastDecode) >= MIN_INTERVAL_MS
) {
lastDecode = now;
attempts++;
try {
const w = v.videoWidth;
const h = v.videoHeight;
if (this._canvas.width !== w) this._canvas.width = w;
if (this._canvas.height !== h) this._canvas.height = h;
this._ctx.drawImage(v, 0, 0, w, h);
// ZXing's decodeFromCanvas returns a Result on
// success and throws NotFoundException on miss.
const result = reader.decodeFromCanvas(this._canvas);
const text = result && (result.getText ? result.getText() : result.text);
if (text) {
lastResult = "found";
this._handleCode(text);
return;
}
lastResult = "empty";
} catch (e) {
// NotFoundException = no QR in frame — common,
// not an error. Anything else we surface.
const name = e && (e.name || (e.constructor && e.constructor.name));
if (name === "NotFoundException" || name === "NotFoundException2") {
lastResult = "no_code";
} else {
lastResult = "err: " + ((e && e.message) || String(e)).slice(0, 50);
}
}
}
if (now - lastStatus > STATUS_INTERVAL_MS) {
const refreshStatus = () => {
const now = performance.now();
if (now - lastStatus > 400) {
lastStatus = now;
this.state.statusLine =
"zxing · f" + frames +
" a" + attempts +
"zxing · cb" + callbacks +
" " + (v.videoWidth || 0) + "x" + (v.videoHeight || 0) +
" rs" + v.readyState +
" r:" + lastResult;
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
try {
reader.decodeFromVideoElementContinuously(v, (result, err) => {
callbacks++;
if (result) {
lastResult = "found";
refreshStatus();
const text = result.getText ? result.getText() : result.text;
this._handleCode(text);
return;
}
if (err) {
const name = err.name || (err.constructor && err.constructor.name) || "";
if (name.indexOf("NotFound") >= 0) {
lastResult = "no_code";
} else if (name.indexOf("Checksum") >= 0 || name.indexOf("Format") >= 0) {
// Found something QR-shaped but couldn't read it
// (blurry / damaged) — keep trying next frame.
lastResult = "partial";
} else {
lastResult = "err:" + (err.message || name).slice(0, 40);
}
}
refreshStatus();
});
} catch (e) {
this.state.statusLine = "zxing init failed: " +
((e && e.message) || String(e)).slice(0, 80);
}
}
/**