diff --git a/src/main.js b/src/main.js index 315bbec..026d468 100644 --- a/src/main.js +++ b/src/main.js @@ -1,6 +1,6 @@ // Vollstaendige Verdrahtung der App: Kamera, Barcode/OCR-Erkennung, Sitzung // und Oberflaeche. Ersetzt das fruehere Sichtpruefungs-Geruest vollstaendig. -import { startCamera, grabFrame, imageDataFromFile } from './camera.js'; +import { startCamera, grabFrame, grabFrameRegion, imageDataFromFile } from './camera.js'; import { decodeBarcodes } from './barcode.js'; import { runOcr, isOcrAvailable } from './ocr.js'; import { recognize } from './pipeline.js'; @@ -162,6 +162,134 @@ async function processCapture(getFrame) { } } +// --- Dauersuche nach Barcodes ----------------------------------------- +// +// Solange die Kamera laeuft, sucht die App fortwaehrend im Zielrahmen nach +// Barcodes, statt auf den Knopfdruck zu warten - ein Modul in Freihand vor +// die Kamera zu halten und zu treffen, gelingt selten beim ersten Bild. + +// Etwa fuenf Versuche pro Sekunde: haeufig genug, dass ein kurzes Hinhalten +// genuegt, aber selten genug, dass ein Handy dabei nicht dauerhaft ausgelastet +// ist. Die tatsaechliche Dauer eines Versuchs wird unten gemessen und von +// dieser Zielspanne abgezogen - dauert ein Versuch laenger (z. B. weil er +// eine Buchung samt kurzer Rueckmeldung ausgeloest hat), startet der naechste +// sofort, ohne zusaetzlich zu warten. +const SEARCH_INTERVAL_MS = 200; + +// Sperrzeit gegen Doppelerfassung: haelt man ein Modul mehrere Sekunden vor +// die Kamera, darf es nicht mehrfach gebucht werden. Zwei Sekunden reichen, +// um eine kurze Verdeckung oder ein Wackeln zu ueberbruecken, waehrend +// derselbe Barcode noch im Bild ist - ein zuegiger Wechsel zu einem anderen +// Modul wird davon nicht ausgebremst, denn ein *anderer* Code hebt die Sperre +// sofort auf (siehe runSearchAttempt). Bewusst kurz gehalten, damit ein +// tatsaechlich zweites Exemplar desselben Teils (z. B. beim Sortieren vieler +// gleicher Module) nach dem Wegnehmen ohne spuerbare Wartezeit erneut gezaehlt +// werden kann, sobald kein Code mehr im Bild ist. +const REBOOK_COOLDOWN_MS = 2000; + +// Zuletzt automatisch (per Dauersuche) gebuchte Codemenge, waehrend die Suche +// wegen der Sperrzeit ruht - null, solange keine Sperre aktiv ist. +let restingCodes = null; +// Zeitpunkt (performance.now()), ab dem die Sperrzeit abgelaufen ist. +let restUntil = 0; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Dieselben Bedingungen, unter denen heute schon die Bedienelemente gesperrt + * sind: eine Erkennung laeuft (busy) oder ein Dialog/die Sitzungsliste liegt + * als Vollbild-Overlay ueber der Ansicht. + */ +function searchPaused() { + return busy || app.querySelector('.overlay') !== null; +} + +/** + * Ein einzelner Durchlauf der Dauersuche: Ausschnitt in nativer Aufloesung + * holen, auf Barcodes pruefen, Rahmen entsprechend hervorheben. Wird ein noch + * nicht gesperrter Code gefunden, laeuft ab hier derselbe Weg wie beim + * Antippen von "Modul scannen" (siehe processCapture) - der Nutzer muss dafuer + * nichts tun. + */ +async function runSearchAttempt() { + if (searchPaused()) { + view.setFrameDetected(false); + return; + } + + let region; + try { + region = grabFrameRegion(view.video); + } catch { + // Kamera liefert noch keine brauchbare Bildgroesse - naechster Durchlauf + // versucht es erneut, kein Grund die Dauersuche abzubrechen. + view.setFrameDetected(false); + return; + } + + let codes; + try { + codes = await decodeBarcodes(region); + } catch { + view.setFrameDetected(false); + return; + } + + view.setFrameDetected(codes.length > 0); + if (codes.length === 0) { + // Kein Code mehr im Bild: Die Sperrzeit darf jetzt ablaufen (siehe + // REBOOK_COOLDOWN_MS oben), aber nur wenn sie tatsaechlich verstrichen ist. + if (restingCodes !== null && performance.now() >= restUntil) { + restingCodes = null; + } + return; + } + + const normalizedCodes = codes.map((code) => normalizeToken(code)); + + if (restingCodes !== null) { + const stillSameModule = normalizedCodes.some((code) => restingCodes.has(code)); + if (stillSameModule) { + // Dasselbe Modul haengt noch im Bild - nicht erneut buchen, egal ob die + // Sperrzeit schon verstrichen ist. + return; + } + // Ein anderer Code ist erschienen: Modulwechsel, die Sperre faellt sofort. + restingCodes = null; + } + + // Waehrend decodeBarcodes lief, koennte sich der Zustand veraendert haben + // (z. B. Knopfdruck des Nutzers) - unmittelbar vor dem Ausloesen erneut pruefen. + if (searchPaused()) return; + + restingCodes = new Set(normalizedCodes); + restUntil = performance.now() + REBOOK_COOLDOWN_MS; + // Derselbe Ausschnitt, der den Code gerade geliefert hat, geht direkt in + // denselben Ablauf wie beim Knopfdruck - kein zweiter, zeitversetzter Griff + // zur Kamera noetig. + await processCapture(() => Promise.resolve(region)); +} + +/** + * Laeuft, solange die Kamera aktiv ist. Ein neuer Versuch beginnt erst, wenn + * der vorherige vollstaendig fertig ist - nie ueberlappend. + */ +async function continuousSearchLoop() { + for (;;) { + const started = performance.now(); + try { + await runSearchAttempt(); + } catch { + // Ein einzelner fehlgeschlagener Versuch darf die Dauersuche nicht + // dauerhaft abbrechen. + } + const elapsed = performance.now() - started; + await sleep(Math.max(0, SEARCH_INTERVAL_MS - elapsed)); + } +} + /** Entfernt alle offenen Vollbild-Overlays innerhalb der App. */ function closeOverlays() { app.querySelectorAll('.overlay').forEach((element) => element.remove()); @@ -199,14 +327,20 @@ function openSessionList() { }); } -startCamera(view.video).catch((error) => { - // Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy- - // Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg - // ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar. - cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`; - view.setStatus(cameraUnavailableMessage, true); - view.setFilePickerVisible(true); -}); +startCamera(view.video) + .then(() => { + // Erst ab hier liefert die Kamera brauchbare Bilder - die Dauersuche + // laeuft fortan im Hintergrund, ohne dass der Nutzer etwas tun muss. + continuousSearchLoop(); + }) + .catch((error) => { + // Kein automatischer Aufruf der Dateiauswahl ohne Nutzergeste - Handy- + // Browser blockieren das regelmaessig. Stattdessen bleibt der Ersatzweg + // ueber eine sichtbare, dauerhaft eingeblendete Schaltflaeche erreichbar. + cameraUnavailableMessage = `Kamera nicht verfuegbar (${error.message}) — Bild auswaehlen`; + view.setStatus(cameraUnavailableMessage, true); + view.setFilePickerVisible(true); + }); /** * Bietet eine beim Start gefundene, gesicherte Sitzung zur Fortsetzung an,