import Quagga from '@ericblade/quagga2' export const useBarcodeScanner = (onScan, targetElementGetter) => { const useScannerMode = ref(false) const showScanner = ref(false) const scannerError = ref('') const isProcessingScan = ref(false) const isInitialized = ref(false) // --- Re-scan tuning (camera mode) --------------------------------------- // Quagga fires onDetected on every analysed frame (~10-30/s). A still // barcode left in front of the camera therefore re-fires continuously, and // because each frame is decoded independently it also yields occasional // misreads ("scans again and again with errors"). To process exactly one // barcode per presentation we combine three guards: // 1. A confidence filter that drops low-quality (likely-misread) decodes. // 2. A short cooldown during which the Quagga processing loop is PAUSED // (Quagga.pause() halts decoding but keeps the camera stream alive, so // resuming is instant — no camera flash), then resumed. // 3. A duplicate-code window that ignores the SAME code re-detected too // soon, while still allowing a deliberate re-scan after the window. const SCAN_COOLDOWN_MS = 1200 // detector paused for this long after a hit const DUPLICATE_WINDOW_MS = 2500 // ignore the identical code within this window // Lenient on purpose: the pause + duplicate-window are the primary fix, so this // only drops obviously-bad reads (garbage frames) and won't reject clean scans. const MAX_AVG_DECODE_ERROR = 0.25 // reject decodes whose avg segment error is worse let lastScannedCode = '' let lastScannedAt = 0 let cooldownTimer = null // Keep-alive lifecycle: the root caches every mobile // page, so onBeforeUnmount does NOT fire on navigation. The camera must be // released in onDeactivated and re-armed in onActivated — otherwise the // stream stays live on the hidden page (camera LED on, battery drain) and // the next camera page fails with NotReadableError ("Kamera wird bereits // von einer anderen App verwendet"). let pageVisible = true let startTimer = null // Deduped delayed start: onMounted AND onActivated both fire on the first // render of a kept-alive page — only one start may run. const scheduleStart = () => { if (!useScannerMode.value || startTimer) return startTimer = setTimeout(() => { startTimer = null if (pageVisible && useScannerMode.value && !isInitialized.value) { startScanner() } }, 200) } // Load scanner preference from localStorage onMounted(() => { if (typeof window !== 'undefined') { const savedPreference = localStorage.getItem('useBarcodeScanner') useScannerMode.value = savedPreference === 'true' scheduleStart() } }) onActivated(() => { pageVisible = true scheduleStart() }) onDeactivated(() => { pageVisible = false if (startTimer) { clearTimeout(startTimer) startTimer = null } stopScanner() }) // Cleanup on unmount onBeforeUnmount(() => { pageVisible = false if (startTimer) { clearTimeout(startTimer) startTimer = null } stopScanner() }) // Map a getUserMedia / Quagga error to an actionable German message. const cameraErrorMessage = (err) => { const name = err?.name || '' if (name === 'NotAllowedError' || name === 'PermissionDeniedError') { return 'Kamera-Berechtigung verweigert. Bitte in den Android-Einstellungen für LogShip die Kamera erlauben – oder die App über „Update“ aktualisieren.' } if (name === 'NotFoundError' || name === 'DevicesNotFoundError') { return 'Keine Kamera gefunden.' } if (name === 'NotReadableError' || name === 'TrackStartError') { return 'Kamera wird bereits von einer anderen App verwendet.' } return 'Kamera konnte nicht gestartet werden: ' + (err?.message || name || 'Unbekannter Fehler') } const startScanner = async () => { if (!useScannerMode.value) return showScanner.value = true scannerError.value = '' // Secure-context / WebView guard: without HTTPS (or in an outdated native shell) // navigator.mediaDevices is undefined and getUserMedia would throw an opaque error. if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) { scannerError.value = 'Kamera nicht verfügbar. Bitte die App aktualisieren (Kamera-Berechtigung fehlt in der installierten Version) oder einen Handscanner verwenden.' return } try { // Wait for component to mount await nextTick() // Get the target element from the DOM let targetElement = null let retries = 0 while (!targetElement && retries < 20) { if (targetElementGetter) { targetElement = targetElementGetter() } else { // Fallback: find by class name targetElement = document.querySelector('.scanner-video-container-inline') } if (!targetElement) { await new Promise(resolve => setTimeout(resolve, 100)) retries++ } } if (!targetElement) { scannerError.value = 'Video element not found' return } // Initialize Quagga with back camera preference Quagga.init({ inputStream: { type: 'LiveStream', target: targetElement, constraints: { facingMode: 'environment', // Always prefer back camera width: { min: 640, ideal: 1280, max: 1920 }, height: { min: 480, ideal: 720, max: 1080 } } }, locator: { patchSize: 'medium', halfSample: true }, numOfWorkers: navigator.hardwareConcurrency || 4, decoder: { readers: [ 'code_128_reader', 'ean_reader', 'ean_8_reader', 'code_39_reader', 'code_39_vin_reader', 'codabar_reader', 'upc_reader', 'upc_e_reader', 'i2of5_reader' ], multiple: false }, locate: true }, (err) => { if (err) { console.error('Quagga initialization error:', err) scannerError.value = cameraErrorMessage(err) return } isInitialized.value = true Quagga.start() // Add detection handler Quagga.onDetected((result) => { // Ignore everything while a scan is being processed / cooling down. // (Late events that were already queued in the workers also land // here and are correctly dropped.) if (!result || !result.codeResult || isProcessingScan.value) return const scannedCode = result.codeResult.code if (!scannedCode) return // 1) Confidence filter: reject likely misreads. Quagga reports a // per-segment decode `error` (0 = perfect). A high average means // the frame was decoded unreliably — skip it and wait for a // cleaner frame instead of emitting a bogus code. const decoded = Array.isArray(result.codeResult.decodedCodes) ? result.codeResult.decodedCodes : [] const errors = decoded .map(d => d?.error) .filter(e => typeof e === 'number') if (errors.length) { const avgError = errors.reduce((a, b) => a + b, 0) / errors.length if (avgError > MAX_AVG_DECODE_ERROR) return } // 2) Duplicate-code window: a barcode held in frame would otherwise // re-trigger every cooldown cycle. Suppress the identical code // unless enough time has passed for a deliberate re-scan. const now = Date.now() if (scannedCode === lastScannedCode && (now - lastScannedAt) < DUPLICATE_WINDOW_MS) { return } // --- Accept the scan --- isProcessingScan.value = true lastScannedCode = scannedCode lastScannedAt = now // Actually pause the decode loop so the still barcode in frame can't // re-fire during the cooldown. pause() keeps the camera stream open, // so the resume below is instant (no re-init / no camera flash). try { Quagga.pause() } catch (e) { /* not fatal */ } if (onScan) { onScan(scannedCode) } // Resume detection after the cooldown. if (cooldownTimer) clearTimeout(cooldownTimer) cooldownTimer = setTimeout(() => { cooldownTimer = null isProcessingScan.value = false // Only resume if we're still actively scanning. if (isInitialized.value && useScannerMode.value) { try { Quagga.start() } catch (e) { /* not fatal */ } } }, SCAN_COOLDOWN_MS) }) }) } catch (err) { console.error('Scanner error:', err) scannerError.value = cameraErrorMessage(err) } } const stopScanner = () => { // Cancel any pending cooldown resume so it can't fire Quagga.start() // after teardown, and clear the processing/duplicate guards. if (cooldownTimer) { clearTimeout(cooldownTimer) cooldownTimer = null } isProcessingScan.value = false lastScannedCode = '' lastScannedAt = 0 if (isInitialized.value) { Quagga.stop() Quagga.offDetected() isInitialized.value = false } showScanner.value = false scannerError.value = '' } // Restart scanner - useful when DOM changes might have disrupted the video stream const restartScanner = async () => { stopScanner() await nextTick() // Small delay to ensure DOM is settled await new Promise(resolve => setTimeout(resolve, 100)) await startScanner() } return { useScannerMode, showScanner, scannerError, isProcessingScan, isInitialized, startScanner, stopScanner, restartScanner } }