Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

View File

@@ -0,0 +1,171 @@
// 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;
}