152 lines
4.0 KiB
JavaScript
152 lines
4.0 KiB
JavaScript
import {
|
|
BarcodeFormat,
|
|
BrowserMultiFormatReader,
|
|
} from '@zxing/browser';
|
|
import { DecodeHintType } from '@zxing/library';
|
|
|
|
const KITCHEN_ITEM_PREFIX = 'kitchen:item::';
|
|
const UUID_B64_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
|
const UUID_B64_WITH_PADDING_PATTERN = /^[A-Za-z0-9_-]{22}={0,2}$/;
|
|
|
|
const SCANNER_FORMATS = [
|
|
BarcodeFormat.DATA_MATRIX,
|
|
BarcodeFormat.EAN_13,
|
|
BarcodeFormat.EAN_8,
|
|
BarcodeFormat.UPC_A,
|
|
BarcodeFormat.UPC_E,
|
|
BarcodeFormat.CODE_128,
|
|
BarcodeFormat.CODE_39,
|
|
BarcodeFormat.QR_CODE,
|
|
];
|
|
|
|
export function normalizeIdentifierCode(value) {
|
|
return String(value || '').replace(/\s+/g, '').trim();
|
|
}
|
|
|
|
export function parseKitchenScanPayload(rawValue) {
|
|
const value = normalizeIdentifierCode(rawValue);
|
|
if (!value) {
|
|
return { type: 'empty', raw: '' };
|
|
}
|
|
|
|
if (value.toLowerCase().startsWith(KITCHEN_ITEM_PREFIX)) {
|
|
const uuidB64 = value.slice(KITCHEN_ITEM_PREFIX.length).trim();
|
|
return uuidB64
|
|
? { type: 'item', uuidB64, raw: value }
|
|
: { type: 'unknown', raw: value };
|
|
}
|
|
|
|
// Backward compatibility: some labels/scanners provide the raw base64 UUID only.
|
|
if (UUID_B64_PATTERN.test(value) || UUID_B64_WITH_PADDING_PATTERN.test(value)) {
|
|
return {
|
|
type: 'item',
|
|
uuidB64: value.replace(/=+$/g, ''),
|
|
raw: value,
|
|
};
|
|
}
|
|
|
|
return { type: 'identifier', identifierCode: value, raw: value };
|
|
}
|
|
|
|
export function canUseCameraScanner() {
|
|
return Boolean(
|
|
typeof navigator !== 'undefined'
|
|
&& navigator.mediaDevices
|
|
&& typeof navigator.mediaDevices.getUserMedia === 'function',
|
|
);
|
|
}
|
|
|
|
export function createScannerReader() {
|
|
const hints = new Map();
|
|
hints.set(DecodeHintType.POSSIBLE_FORMATS, SCANNER_FORMATS);
|
|
return new BrowserMultiFormatReader(hints);
|
|
}
|
|
|
|
export function normalizeScannerError(error) {
|
|
const message = String(error?.message || '');
|
|
const normalized = message.toLowerCase();
|
|
|
|
if (error?.name === 'NotAllowedError' || normalized.includes('permission')) {
|
|
return 'Camera access was denied. Allow access to scan, or enter the code manually.';
|
|
}
|
|
|
|
if (error?.name === 'NotFoundError' || normalized.includes('requested device not found')) {
|
|
return 'No camera was found on this device. Enter the code manually.';
|
|
}
|
|
|
|
if (error?.name === 'NotReadableError' || normalized.includes('could not start video source')) {
|
|
return 'Camera is busy in another app. Close it there and try scanning again.';
|
|
}
|
|
|
|
return 'Could not start scanning. Enter the code manually.';
|
|
}
|
|
|
|
export async function startCameraScanner({
|
|
reader,
|
|
videoElement,
|
|
onDetected,
|
|
onDecodeError,
|
|
}) {
|
|
const activeReader = reader || createScannerReader();
|
|
const shouldLogDecodeErrors = import.meta.env.DEV;
|
|
let lastDecodeErrorName = '';
|
|
let lastDecodeErrorAt = 0;
|
|
|
|
const controls = await activeReader.decodeFromConstraints(
|
|
{
|
|
audio: false,
|
|
video: {
|
|
facingMode: { ideal: 'environment' },
|
|
},
|
|
},
|
|
videoElement,
|
|
(result, error) => {
|
|
if (result) {
|
|
onDetected?.(result.getText?.() || '');
|
|
return;
|
|
}
|
|
|
|
if (!error) {
|
|
return;
|
|
}
|
|
|
|
onDecodeError?.(error);
|
|
if (!shouldLogDecodeErrors) {
|
|
return;
|
|
}
|
|
|
|
const errorName = String(error?.name || 'UnknownError');
|
|
const now = Date.now();
|
|
if (errorName !== lastDecodeErrorName || now - lastDecodeErrorAt > 2000) {
|
|
console.debug('[scanner] Ignoring frame decode error while scanning:', errorName, error?.message || '');
|
|
lastDecodeErrorName = errorName;
|
|
lastDecodeErrorAt = now;
|
|
}
|
|
},
|
|
);
|
|
|
|
return { reader: activeReader, controls };
|
|
}
|
|
|
|
export function stopCameraScanner({ reader, controls, videoElement }) {
|
|
try {
|
|
controls?.stop?.();
|
|
} catch {
|
|
// Ignore cleanup errors when scanner is already stopped.
|
|
}
|
|
|
|
try {
|
|
reader?.reset?.();
|
|
} catch {
|
|
// Ignore cleanup errors from stale reader state.
|
|
}
|
|
|
|
const stream = videoElement?.srcObject;
|
|
if (stream && typeof stream.getTracks === 'function') {
|
|
stream.getTracks().forEach((track) => track.stop());
|
|
}
|
|
if (videoElement) {
|
|
videoElement.srcObject = null;
|
|
}
|
|
}
|