172 lines
5.5 KiB
JavaScript
172 lines
5.5 KiB
JavaScript
// Kamerasucher für Strichcodes.
|
||
//
|
||
// Zwei Wege, in dieser Reihenfolge:
|
||
// 1. BarcodeDetector - nativ in Chrome und auf Android, erkennt auch
|
||
// QR- und Datamatrix-Codes, läuft außerhalb des Hauptthreads.
|
||
// 2. Eigener Decoder aus barcode.js - überall sonst, insbesondere
|
||
// Safari auf iPhone und iPad.
|
||
//
|
||
// Wenn beides ausfällt (keine Kamera, Zugriff verweigert), bleibt die
|
||
// Eingabe von Hand. Die Ziffernfolge steht unter jedem Strichcode.
|
||
"use strict";
|
||
|
||
import { decodeImage } from "../barcode.js";
|
||
import { clear, el } from "../dom.js";
|
||
|
||
const SCAN_INTERVAL_MS = 120;
|
||
|
||
async function nativeDetector() {
|
||
if (!("BarcodeDetector" in window)) return null;
|
||
try {
|
||
const formats = await window.BarcodeDetector.getSupportedFormats();
|
||
const wanted = ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "qr_code"]
|
||
.filter((f) => formats.includes(f));
|
||
if (!wanted.length) return null;
|
||
return new window.BarcodeDetector({ formats: wanted });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Öffnet den Sucher als Überlagerung.
|
||
* @returns {Promise<string|null>} erkannte Ziffernfolge oder null bei Abbruch
|
||
*/
|
||
export function scanBarcode() {
|
||
return new Promise((resolve) => {
|
||
let stream = null;
|
||
let timer = null;
|
||
let closed = false;
|
||
|
||
const video = el("video", {
|
||
playsInline: true,
|
||
muted: true,
|
||
autoplay: true,
|
||
className: "scan-video",
|
||
});
|
||
const canvas = document.createElement("canvas");
|
||
const hint = el("p.scan-hint", {}, "Kamera wird geöffnet …");
|
||
|
||
const manualField = el("input", {
|
||
type: "text",
|
||
inputMode: "numeric",
|
||
placeholder: "Ziffern unter dem Strichcode",
|
||
maxLength: 32,
|
||
onkeydown: (ev) => { if (ev.key === "Enter") useManual(); },
|
||
});
|
||
|
||
function useManual() {
|
||
const value = manualField.value.replace(/\D/g, "");
|
||
if (value.length < 6) {
|
||
hint.textContent = "Bitte mindestens sechs Ziffern eingeben.";
|
||
return;
|
||
}
|
||
finish(value);
|
||
}
|
||
|
||
const overlay = el("div.scan-overlay", {},
|
||
el("div.scan-stage", {}, video, el("div.scan-frame", {})),
|
||
hint,
|
||
el("div.scan-manual", {},
|
||
el("label", {}, "Oder von Hand eingeben"),
|
||
el("div.row", {}, manualField,
|
||
el("button.primary", { type: "button", onclick: useManual }, "Übernehmen"))),
|
||
el("button.secondary.scan-close", { type: "button", onclick: () => finish(null) },
|
||
"Abbrechen")
|
||
);
|
||
|
||
function finish(code) {
|
||
if (closed) return;
|
||
closed = true;
|
||
if (timer) clearInterval(timer);
|
||
// Kamera zuverlässig freigeben - sonst leuchtet die Anzeigeleuchte
|
||
// weiter und der Akku leidet.
|
||
if (stream) for (const track of stream.getTracks()) track.stop();
|
||
overlay.remove();
|
||
document.body.classList.remove("scanning");
|
||
window.removeEventListener("keydown", onKey);
|
||
resolve(code);
|
||
}
|
||
|
||
function onKey(ev) {
|
||
if (ev.key === "Escape") finish(null);
|
||
}
|
||
|
||
window.addEventListener("keydown", onKey);
|
||
document.body.classList.add("scanning");
|
||
document.body.append(overlay);
|
||
|
||
(async () => {
|
||
try {
|
||
stream = await navigator.mediaDevices.getUserMedia({
|
||
video: {
|
||
facingMode: { ideal: "environment" },
|
||
width: { ideal: 1280 },
|
||
height: { ideal: 720 },
|
||
},
|
||
audio: false,
|
||
});
|
||
} catch (err) {
|
||
hint.textContent =
|
||
err.name === "NotAllowedError"
|
||
? "Kein Zugriff auf die Kamera. Bitte in den Browsereinstellungen erlauben – oder die Ziffern von Hand eingeben."
|
||
: "Keine Kamera verfügbar. Bitte die Ziffern von Hand eingeben.";
|
||
manualField.focus();
|
||
return;
|
||
}
|
||
|
||
video.srcObject = stream;
|
||
try {
|
||
await video.play();
|
||
} catch {
|
||
// Manche Browser verlangen eine Nutzeraktion; das Bild erscheint
|
||
// dann trotzdem, sobald die Wiedergabe anläuft.
|
||
}
|
||
|
||
const detector = await nativeDetector();
|
||
hint.textContent = detector
|
||
? "Strichcode in den Rahmen halten."
|
||
: "Strichcode in den Rahmen halten. Für eine gute Erkennung waagerecht und gut ausgeleuchtet.";
|
||
|
||
timer = setInterval(async () => {
|
||
if (closed || video.readyState < 2) return;
|
||
|
||
const width = video.videoWidth;
|
||
const height = video.videoHeight;
|
||
if (!width || !height) return;
|
||
|
||
if (detector) {
|
||
try {
|
||
const found = await detector.detect(video);
|
||
if (found.length) {
|
||
const value = String(found[0].rawValue || "").trim();
|
||
if (value) return finish(value);
|
||
}
|
||
} catch {
|
||
// Weiter mit dem eigenen Decoder.
|
||
}
|
||
}
|
||
|
||
// Nur den mittleren Streifen auswerten: Dort liegt der Code,
|
||
// und es spart die meiste Rechenzeit.
|
||
const bandHeight = Math.round(height * 0.35);
|
||
const y = Math.round((height - bandHeight) / 2);
|
||
canvas.width = width;
|
||
canvas.height = bandHeight;
|
||
|
||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||
context.drawImage(video, 0, y, width, bandHeight, 0, 0, width, bandHeight);
|
||
|
||
const code = decodeImage(context.getImageData(0, 0, width, bandHeight));
|
||
if (code) finish(code);
|
||
}, SCAN_INTERVAL_MS);
|
||
})();
|
||
});
|
||
}
|
||
|
||
/** Ob ein Sucher überhaupt sinnvoll ist. Über reines HTTP verweigert der
|
||
* Browser den Kamerazugriff - dann direkt zur Eingabe von Hand. */
|
||
export function cameraAvailable() {
|
||
return Boolean(navigator.mediaDevices?.getUserMedia) && window.isSecureContext;
|
||
}
|