feat: Implement hybrid barcode scanner with real-time camera support and image upload fallback

This commit is contained in:
2026-06-01 11:55:28 +02:00
parent 20e5d3bb9a
commit 997c7b42d5
7 changed files with 626 additions and 144 deletions
+161 -12
View File
@@ -67,8 +67,22 @@
<!-- Scanner Panel -->
<div id="qrContainer" class="qr-container" style="display: none;">
<div id="qr-reader" style="width: auto; height: 300px;"></div>
<span id="qr-result" style="display: none;"></span>
<div class="scanner-header">
<h3>Barcode/QR-Code Scanner</h3>
<div class="scanner-controls">
<button type="button" id="cameraToggle" class="scanner-button" title="Kamera wechseln">📷 Kamera</button>
<label for="uploadScanImage" class="scanner-button" title="Bild hochladen zum Dekodieren">📤 Upload</label>
<input type="file" id="uploadScanImage" accept="image/*" style="display: none;">
</div>
</div>
<div class="scanner-video-wrapper">
<video id="qr-reader" playsinline style="width: 100%; max-width: 500px; border-radius: 8px;"></video>
<canvas id="scanner-canvas" style="display: none;"></canvas>
</div>
<div id="scanMessage" class="scanner-message" style="margin-top: 10px; text-align: center; font-weight: bold; color: #666;"></div>
<p style="font-size: 0.9em; color: #999; margin-top: 10px; text-align: center;">
💡 Hinweis: Scan-Ergebnis wird automatisch in das Feld oben übernommen. USB-Scanner werden als Tastatureingabe erkannt.
</p>
</div>
<!-- Table Header (only in table mode) -->
@@ -206,7 +220,8 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>
<script src="/static/js/scanner.js"></script>
<script>
// View mode persistence
const LIBRARY_VIEW_MODE_KEY = 'inventarLibraryViewMode';
@@ -631,8 +646,9 @@ document.getElementById('favoriteToggle').addEventListener('click', function() {
// TODO: Implement favorites filtering
});
// Scanner toggle
// Scanner instance (hybrid: real-time + upload + keyboard)
let scanner = null;
document.getElementById('scannerBtn').addEventListener('click', function(e) {
e.stopPropagation();
const container = document.getElementById('qrContainer');
@@ -640,7 +656,10 @@ document.getElementById('scannerBtn').addEventListener('click', function(e) {
if (isOpen) {
container.style.display = 'none';
if (scanner) scanner.clear();
if (scanner) {
scanner.stop();
scanner = null;
}
this.classList.remove('open');
this.setAttribute('aria-expanded', 'false');
} else {
@@ -649,17 +668,66 @@ document.getElementById('scannerBtn').addEventListener('click', function(e) {
this.setAttribute('aria-expanded', 'true');
if (!scanner) {
scanner = new Html5Qrcode('qr-reader');
scanner.start(
{ facingMode: "environment" },
{ fps: 10, qrbox: 250 },
onScanSuccess,
onScanError
);
initScanner();
}
}
});
function initScanner() {
scanner = new HybridScanner({
videoId: 'qr-reader',
canvasId: 'scanner-canvas',
formats: ['QR_CODE', 'CODE_128', 'EAN_13', 'ISBN', 'CODE_39', 'UPC_A', 'EAN_8'],
fps: 10,
facingMode: 'environment',
onSuccess: function(decodedText) {
const studentId = decodedText.trim();
document.getElementById('studentIdInput').value = studentId;
document.getElementById('scanMessage').textContent = '✓ Erkannt: ' + studentId;
document.getElementById('scanMessage').style.color = '#4CAF50';
},
onError: function(error) {
document.getElementById('scanMessage').textContent = '✗ ' + error;
document.getElementById('scanMessage').style.color = '#f44336';
}
});
scanner.start();
}
// Camera toggle
document.getElementById('cameraToggle')?.addEventListener('click', function() {
if (scanner) {
scanner.toggleCamera();
this.textContent = scanner.options.facingMode === 'user' ? '📷 Rückkamera' : '🤳 Frontkamera';
}
});
// Image upload fallback
document.getElementById('uploadScanImage')?.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const messageEl = document.getElementById('scanMessage');
messageEl.textContent = 'Wird dekodiert...';
messageEl.style.color = '#2196F3';
HybridScanner.uploadImageForDecoding(
file,
(decodedText) => {
document.getElementById('studentIdInput').value = decodedText;
messageEl.textContent = '✓ Erkannt (Upload): ' + decodedText;
messageEl.style.color = '#4CAF50';
e.target.value = ''; // Reset file input
},
(error) => {
messageEl.textContent = '✗ ' + error;
messageEl.style.color = '#f44336';
e.target.value = '';
}
);
}
});
function onScanSuccess(decodedText, decodedResult) {
const studentId = decodedText.trim();
document.getElementById('studentIdInput').value = studentId;
@@ -737,5 +805,86 @@ function onScanError(error) {
display: block; /* keep rows stacked but allow horizontal scroll */
}
}
/* Hybrid Scanner Styles */
.qr-container {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.scanner-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
gap: 12px;
}
.scanner-header h3 {
margin: 0;
color: #333;
font-size: 1.1em;
}
.scanner-controls {
display: flex;
gap: 8px;
}
.scanner-button {
background: #2196F3;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
transition: background-color 0.2s;
}
.scanner-button:hover {
background: #1976D2;
}
.scanner-button:active {
background: #1565C0;
}
.scanner-video-wrapper {
display: flex;
justify-content: center;
margin: 12px 0;
}
#qr-reader {
border: 2px solid #2196F3;
border-radius: 8px;
}
.scanner-message {
min-height: 20px;
font-size: 0.95em;
text-align: center;
}
@media (max-width: 600px) {
.scanner-header {
flex-direction: column;
align-items: stretch;
}
.scanner-controls {
width: 100%;
justify-content: space-between;
}
.scanner-button {
flex: 1;
}
}
</style>
{% endblock %}
+18 -80
View File
@@ -459,7 +459,10 @@
Hinweis: Im Schnellmodus zuerst den Schülerausweis scannen, danach den Buch-/Mediencode.
</div>
<div id="scanReaderWrap" class="library-scan-reader-wrap">
<div id="libraryQrReader" class="library-scan-reader"></div>
<div id="libraryQrReader" class="library-scan-reader">
<video id="libraryQrReaderVideo" playsinline style="width: 100%; border-radius: 8px; display: none;"></video>
<canvas id="libraryQrReaderCanvas" style="display: none;"></canvas>
</div>
</div>
</div>
<div id="filterPanel" class="library-filter-panel">
@@ -547,7 +550,8 @@
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js"></script>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>
<script src="/static/js/scanner.js"></script>
<script>
// State
let libraryItems = [];
@@ -1002,54 +1006,8 @@
}
async function ensureScannerLibraryLoaded() {
if (typeof Html5QrcodeScanner !== 'undefined') {
return true;
}
const sources = [
'https://cdn.jsdelivr.net/npm/html5-qrcode/minified/html5-qrcode.min.js'
];
for (const src of sources) {
try {
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-scanner-src="${src}"]`);
if (existing) {
const onLoad = () => resolve();
const onError = () => reject(new Error('Script load failed'));
existing.addEventListener('load', onLoad, { once: true });
existing.addEventListener('error', onError, { once: true });
setTimeout(() => {
existing.removeEventListener('load', onLoad);
existing.removeEventListener('error', onError);
if (typeof Html5QrcodeScanner !== 'undefined') {
resolve();
} else {
reject(new Error('Script not available'));
}
}, 1200);
return;
}
const script = document.createElement('script');
script.src = src;
script.async = true;
script.defer = true;
script.dataset.scannerSrc = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Script load failed'));
document.head.appendChild(script);
});
if (typeof Html5QrcodeScanner !== 'undefined') {
return true;
}
} catch (err) {
console.warn('Scanner library load failed from', src, err);
}
}
return false;
// HybridScanner is loaded globally via script tag
return typeof HybridScanner !== 'undefined';
}
async function startScanner() {
@@ -1066,37 +1024,17 @@
readerWrap.style.display = 'block';
try {
const formats = [];
if (typeof Html5QrcodeSupportedFormats !== 'undefined') {
formats.push(
Html5QrcodeSupportedFormats.QR_CODE,
Html5QrcodeSupportedFormats.EAN_13,
Html5QrcodeSupportedFormats.EAN_8,
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.CODE_39,
Html5QrcodeSupportedFormats.UPC_A,
Html5QrcodeSupportedFormats.UPC_E,
Html5QrcodeSupportedFormats.ITF,
Html5QrcodeSupportedFormats.CODABAR
);
}
const scannerConfig = {
scannerInstance = scannerInstance || new HybridScanner({
videoId: 'libraryQrReaderVideo',
canvasId: 'libraryQrReaderCanvas',
formats: ['QR_CODE', 'EAN_13', 'EAN_8', 'CODE_128', 'CODE_39', 'UPC_A', 'UPC_E', 'ITF', 'CODABAR'],
fps: 10,
rememberLastUsedCamera: true,
aspectRatio: 1.333334
};
if (formats.length > 0) {
scannerConfig.formatsToSupport = formats;
}
facingMode: 'environment',
onSuccess: handleScanSuccess,
onError: handleScanError
});
scannerInstance = scannerInstance || new Html5QrcodeScanner(
'libraryQrReader',
scannerConfig,
false
);
scannerInstance.render(handleScanSuccess, handleScanError);
scannerInstance.start();
scannerRunning = true;
toggleBtn.textContent = 'Scanner stoppen';
setScanStatus('Scanner aktiv. Jetzt Code scannen.', 'warn');
@@ -1113,7 +1051,7 @@
const readerWrap = document.getElementById('scanReaderWrap');
const toggleBtn = document.getElementById('toggleScannerBtn');
try {
await scannerInstance.clear();
scannerInstance.stop();
} catch (err) {
console.error('Scanner stop failed:', err);
}
+32 -20
View File
@@ -413,7 +413,10 @@
</div>
<div class="qr-container">
<button id="scanButton" class="scan-button" aria-controls="qr-reader" aria-expanded="false">Barcode scannen</button>
<div id="qr-reader"></div>
<div id="qr-reader" style="display: none;">
<video id="qr-reader-video" playsinline style="width: 100%; max-width: 500px; border-radius: 8px; display: none;"></video>
<canvas id="qr-reader-canvas" style="display: none;"></canvas>
</div>
</div>
<div id="table-view-header" class="table-view-header" aria-hidden="true">
<span>Name</span>
@@ -526,7 +529,8 @@
window.isDebug = false; // Set to true only for development environment
</script>
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>
<script src="/static/js/scanner.js"></script>
<script>
// Global state
const highlightItemId = (window.serverVars && window.serverVars.highlightItemId && window.serverVars.highlightItemId !== 'null')
@@ -539,7 +543,7 @@
document.addEventListener('DOMContentLoaded', function() {
const scanButton = document.getElementById('scanButton');
const qrReader = document.getElementById('qr-reader');
let html5QrcodeScanner = null;
let hybridScanner = null;
if (!scanButton || !qrReader) return;
@@ -553,28 +557,36 @@
if (qrReader.style.display !== 'block') {
qrReader.style.display = 'block';
html5QrcodeScanner = new Html5QrcodeScanner(
'qr-reader', { fps: 10, qrbox: 250 }
);
html5QrcodeScanner.render((decodedText) => {
html5QrcodeScanner.clear();
qrReader.style.display = 'none';
// Put scanned code into the search box and trigger search
const searchInput = document.getElementById('code-search');
if (searchInput) {
searchInput.value = decodedText;
searchByCode();
}
hybridScanner = new HybridScanner({
videoId: 'qr-reader-video',
canvasId: 'qr-reader-canvas',
formats: ['QR_CODE', 'CODE_128', 'EAN_13', 'EAN_8', 'CODE_39'],
fps: 10,
facingMode: 'environment',
onSuccess: function(decodedText) {
hybridScanner.stop();
qrReader.style.display = 'none';
// Put scanned code into the search box and trigger search
const searchInput = document.getElementById('code-search');
if (searchInput) {
searchInput.value = decodedText;
searchByCode();
}
setScannerUi(false);
setScannerUi(false);
},
onError: function(error) {
console.error('Scanner error:', error);
}
});
hybridScanner.start();
setScannerUi(true);
} else {
if (html5QrcodeScanner) {
html5QrcodeScanner.clear();
if (hybridScanner) {
hybridScanner.stop();
hybridScanner = null;
}
qrReader.style.display = 'none';
setScannerUi(false);
+62 -32
View File
@@ -876,7 +876,10 @@
<button type="button" id="scan-code4-btn" class="fetch-isbn-button">Barcode scannen</button>
</div>
<small style="display:block; color:#666;">Einzelcode manuell setzen oder per Scanner erfassen.</small>
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
<div id="code4-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;">
<video id="code4-scanner-video" playsinline style="width: 100%; border-radius: 8px; display: none;"></video>
<canvas id="code4-scanner-canvas" style="display: none;"></canvas>
</div>
<small id="code4-scan-status" style="display:block; color:#666; margin-top:6px;"></small>
</div>
<div class="form-group">
@@ -907,7 +910,10 @@
<button type="button" id="scan-isbn-btn" class="fetch-isbn-button">Barcode scannen</button>
<button type="button" class="fetch-isbn-button" onclick="fetchBookInfo('upload')">Bild abrufen</button>
</div>
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;"></div>
<div id="isbn-scanner" style="width:100%; max-width:520px; display:none; margin-top:10px;">
<video id="isbn-scanner-video" playsinline style="width: 100%; border-radius: 8px; display: none;"></video>
<canvas id="isbn-scanner-canvas" style="display: none;"></canvas>
</div>
<small id="isbn-scan-status" style="display:block; color:#666; margin-top:6px;">Scannen oder manuell eingeben. Gültige ISBNs helfen beim Abruf von Buchdaten, andere Codes werden trotzdem akzeptiert.</small>
<div id="book-info-container" class="book-info-container"></div>
</div>
@@ -971,7 +977,8 @@
}
</script>
<script src="https://unpkg.com/html5-qrcode@2.0.9/dist/html5-qrcode.min.js"></script>
<script src="https://unpkg.com/@zxing/library@latest/umd/index.min.js"></script>
<script src="/static/js/scanner.js"></script>
<script>
const libraryModuleEnabled = {{ 'true' if library_module_enabled else 'false' }};
@@ -1050,16 +1057,19 @@
// Stop ISBN scanner if currently running to avoid camera conflicts.
if (isbnScannerRunning && isbnScannerInstance) {
isbnScannerInstance.clear().catch(() => {});
isbnScannerInstance.stop();
isbnScannerInstance = null;
const isbnScannerBox = document.getElementById('isbn-scanner');
const isbnScanButton = document.getElementById('scan-isbn-btn');
if (isbnScannerBox) isbnScannerBox.style.display = 'none';
if (isbnScanButton) isbnScanButton.textContent = 'ISBN scannen';
isbnScannerRunning = false;
setIsbnScanStatus('Scanner gestoppt.');
}
if (code4ScannerRunning && code4ScannerInstance) {
code4ScannerInstance.clear().catch(() => {});
code4ScannerInstance.stop();
code4ScannerInstance = null;
scannerBox.style.display = 'none';
scanButton.textContent = 'Barcode scannen';
code4ScannerRunning = false;
@@ -1069,29 +1079,36 @@
scannerBox.style.display = 'block';
scanButton.textContent = 'Scanner stoppen';
code4ScannerInstance = new Html5QrcodeScanner('code4-scanner', {
fps: 10,
qrbox: 250,
rememberLastUsedCamera: true
});
code4ScannerRunning = true;
setCode4ScanStatus('Scanner läuft. Der Scan wird im Basis-Codefeld übernommen.');
code4ScannerInstance.render((decodedText) => {
const scannedCode = String(decodedText || '').trim();
if (!scannedCode) return;
code4ScannerInstance = new HybridScanner({
videoId: 'code4-scanner-video',
canvasId: 'code4-scanner-canvas',
formats: ['CODE_128', 'CODE_39', 'QR_CODE'],
fps: 10,
facingMode: 'environment',
onSuccess: function(decodedText) {
const scannedCode = String(decodedText || '').trim();
if (!scannedCode) return;
const now = Date.now();
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) {
return;
const now = Date.now();
if (scannedCode === code4LastScanned && (now - code4LastScannedAt) < 1500) {
return;
}
code4LastScanned = scannedCode;
code4LastScannedAt = now;
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
},
onError: function(error) {
setCode4ScanStatus(`Fehler: ${error}`, true);
}
code4LastScanned = scannedCode;
code4LastScannedAt = now;
});
codeField.value = scannedCode;
validateCodeField(codeField);
setCode4ScanStatus(`Code_4 gesetzt: ${scannedCode}`);
}, () => {});
code4ScannerInstance.start();
}
function startIsbnScanner() {
@@ -1106,7 +1123,8 @@
// Stop Code_4 scanner if currently running to avoid camera conflicts.
if (code4ScannerRunning && code4ScannerInstance) {
code4ScannerInstance.clear().catch(() => {});
code4ScannerInstance.stop();
code4ScannerInstance = null;
const codeScannerBox = document.getElementById('code4-scanner');
const codeScanButton = document.getElementById('scan-code4-btn');
if (codeScannerBox) codeScannerBox.style.display = 'none';
@@ -1116,7 +1134,8 @@
}
if (isbnScannerRunning && isbnScannerInstance) {
isbnScannerInstance.clear().catch(() => {});
isbnScannerInstance.stop();
isbnScannerInstance = null;
scannerBox.style.display = 'none';
scanButton.textContent = 'ISBN scannen';
isbnScannerRunning = false;
@@ -1126,15 +1145,16 @@
scannerBox.style.display = 'block';
scanButton.textContent = 'Scanner stoppen';
isbnScannerInstance = new Html5QrcodeScanner('isbn-scanner', {
fps: 10,
qrbox: 250,
rememberLastUsedCamera: true
});
isbnScannerRunning = true;
setIsbnScanStatus('Scanner läuft. Bitte ISBN-Code erfassen.');
isbnScannerInstance.render((decodedText) => {
isbnScannerInstance = new HybridScanner({
videoId: 'isbn-scanner-video',
canvasId: 'isbn-scanner-canvas',
formats: ['ISBN', 'EAN_13', 'EAN_8', 'QR_CODE'],
fps: 10,
facingMode: 'environment',
onSuccess: function(decodedText) {
const scannedCode = String(decodedText || '').trim();
if (!scannedCode) return;
@@ -1147,7 +1167,11 @@
setIsbnScanStatus('Kein gültiges ISBN-Format erkannt, der gescannte Wert bleibt aber im Feld.', true);
}
isbnScannerInstance.clear().catch(() => {});
// Stop scanner after successful scan
if (isbnScannerInstance) {
isbnScannerInstance.stop();
isbnScannerInstance = null;
}
scannerBox.style.display = 'none';
scanButton.textContent = 'ISBN scannen';
isbnScannerRunning = false;
@@ -1156,7 +1180,13 @@
// Automatically load book metadata after a valid ISBN scan.
fetchBookInfo('upload');
}
}, () => {});
},
onError: function(error) {
setIsbnScanStatus(`Fehler: ${error}`, true);
}
});
isbnScannerInstance.start();
}
// Load predefined filter values for dropdowns