MIF_E31221407_FE/composables/barcodeScanner.ts

367 lines
12 KiB
TypeScript

import { ref, onMounted, onBeforeUnmount, type Ref, watch } from 'vue'
import { BrowserMultiFormatReader, type IScannerControls, BarcodeFormat } from '@zxing/browser'
export interface BarcodeScannerOptions {
formats?: BarcodeFormat[];
tryHarder?: boolean;
autostart?: boolean;
preferBackCamera?: boolean;
resetAfterScan?: boolean;
resetDelay?: number;
}
export function useBarcodeScanner(
videoElement: Ref<HTMLVideoElement | null>,
options: BarcodeScannerOptions = {}
) {
// Default options
const {
formats = [BarcodeFormat.QR_CODE, BarcodeFormat.EAN_13, BarcodeFormat.CODE_128],
tryHarder = true,
autostart = true,
preferBackCamera = true,
resetAfterScan = true,
resetDelay = 1500
} = options;
// Reactive state
const barcode = ref<string | null>(null);
const isSupported = ref<boolean>(false);
const error = ref<string | null>(null);
const status = ref<'idle' | 'changing' | 'error' | 'scanning' | 'scanned'>('idle');
const permissionStatus = ref<'loading' | 'allowed' | 'denied'>('loading');
const currentDeviceIndex = ref(0);
const devices = ref<MediaDeviceInfo[]>([]);
// Internal state
let codeReader: BrowserMultiFormatReader | null = null;
let controls: IScannerControls | null = null;
let resetTimer: number | null = null;
/**
* Check if the camera is supported by the browser
*/
const checkCameraSupport = (): boolean => {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
};
/**
* Request camera permission from the user
*/
const requestCameraPermission = async (): Promise<boolean> => {
try {
permissionStatus.value = 'loading';
await navigator.mediaDevices.getUserMedia({ video: true });
permissionStatus.value = 'allowed';
return true;
} catch (err) {
error.value = 'Camera permission denied or failed.';
permissionStatus.value = 'denied';
console.error('[BarcodeScanner] Permission error:', err);
return false;
}
};
/**
* Get available video devices
*/
const getVideoDevices = async (): Promise<MediaDeviceInfo[]> => {
try {
const availableDevices = await BrowserMultiFormatReader.listVideoInputDevices();
devices.value = availableDevices;
return availableDevices;
} catch (err) {
console.error('[BarcodeScanner] Failed to list devices:', err);
error.value = 'Failed to get camera devices.';
return [];
}
};
/**
* Find the best camera device to use based on options
*/
const findBestDevice = (deviceList: MediaDeviceInfo[]): MediaDeviceInfo | null => {
if (!deviceList.length) return null;
// Try to find back camera if preferred
if (preferBackCamera) {
const backCamera = deviceList.find(device =>
/back|rear|environment/i.test(device.label)
);
if (backCamera) {
currentDeviceIndex.value = deviceList.findIndex(d => d.deviceId === backCamera.deviceId);
return backCamera;
}
}
// Fallback to first camera
currentDeviceIndex.value = 0;
return deviceList[0];
};
/**
* Initialize the barcode reader with hints
*/
const initializeReader = async () => {
if (codeReader) {
// If we already have a reader, stop it first
stopScanner();
}
const hints = new Map();
if (formats.length) {
hints.set(2, formats); // DecodeHintType.POSSIBLE_FORMATS = 2
}
if (tryHarder) {
hints.set(3, true); // DecodeHintType.TRY_HARDER = 3
}
codeReader = new BrowserMultiFormatReader(hints);
};
/**
* Start the barcode scanner
*/
const startScanner = async (deviceId?: string): Promise<boolean> => {
if (!checkCameraSupport()) {
error.value = 'Device does not support camera access.';
status.value = 'error';
console.warn('[BarcodeScanner] getUserMedia is not supported.');
return false;
}
// Clear any previous errors
error.value = null;
// Check permissions
if (permissionStatus.value !== 'allowed') {
const granted = await requestCameraPermission();
if (!granted) return false;
}
// Initialize reader if needed
if (!codeReader) {
await initializeReader();
}
try {
status.value = 'changing';
// Get devices if we don't have them yet
if (!devices.value.length) {
const availableDevices = await getVideoDevices();
if (!availableDevices.length) {
throw new Error('No cameras found');
}
}
// Find device to use
let selectedDevice: MediaDeviceInfo | null = devices.value[0];
if (deviceId) {
// Use specified device
selectedDevice = devices.value.find(d => d.deviceId === deviceId) || null;
if (selectedDevice) {
currentDeviceIndex.value = devices.value.findIndex(d => d.deviceId === deviceId);
}
} else {
// Find best device
selectedDevice = findBestDevice(devices.value);
}
if (!selectedDevice) {
throw new Error('No suitable camera found');
}
// Ensure video element exists
if (!videoElement.value) {
throw new Error('Video element not available');
}
// Start decoding
controls = await codeReader!.decodeFromVideoDevice(
selectedDevice.deviceId,
videoElement.value,
(result, err) => {
if (err) {
// Just a failure to read - this is normal during scanning
if (!/NotFoundException/.test(err.toString())) {
console.debug('[BarcodeScanner] Decode error:', err);
}
return;
}
if (result) {
barcode.value = result.getText();
status.value = 'scanned';
// Reset after scan if enabled
if (resetAfterScan) {
// Clear any existing timer
if (resetTimer !== null) {
window.clearTimeout(resetTimer);
}
resetTimer = window.setTimeout(() => {
barcode.value = null;
status.value = 'scanning';
resetTimer = null;
}, resetDelay);
}
}
}
);
status.value = 'scanning';
return true;
} catch (err) {
console.error('[BarcodeScanner] Initialization failed:', err);
error.value = err instanceof Error ? err.message : 'Scanner initialization failed';
status.value = 'error';
return false;
}
};
/**
* Stop the barcode scanner
*/
const stopScanner = () => {
if (resetTimer !== null) {
window.clearTimeout(resetTimer);
resetTimer = null;
}
if (codeReader && controls) {
try {
controls.stop();
controls = null;
} catch (err) {
console.warn('[BarcodeScanner] Error stopping scanner:', err);
}
}
status.value = 'idle';
};
/**
* Switch to the next available camera
*/
const switchCamera = async (): Promise<boolean> => {
try {
if (devices.value.length <= 1) {
console.warn('[BarcodeScanner] Only one camera available, cannot switch.');
return false;
}
status.value = 'changing';
stopScanner();
// ⏳ Delay sedikit biar kamera bener-bener "released"
await new Promise((res) => setTimeout(res, 500));
currentDeviceIndex.value = (currentDeviceIndex.value + 1) % devices.value.length;
const nextDevice = devices.value[currentDeviceIndex.value];
return await startScanner(nextDevice.deviceId);
} catch (err) {
console.error('[BarcodeScanner] Failed to switch camera:', err);
status.value = 'error';
error.value = 'Failed to switch camera';
return false;
}
};
/**
* Reset the scanner after a successful scan
*/
const resetScanner = () => {
barcode.value = null;
if (status.value === 'scanned') {
status.value = 'scanning';
}
};
/**
* Get the current device name
*/
const getCurrentDeviceName = (): string => {
if (devices.value.length && currentDeviceIndex.value < devices.value.length) {
return devices.value[currentDeviceIndex.value].label || `Camera ${currentDeviceIndex.value + 1}`;
}
return 'Unknown';
};
// Watch for video element changes
watch(videoElement, async (newEl) => {
if (newEl && autostart) {
// Tunggu DOM benar-benar render
await nextTick();
// Pastikan permission sudah granted
if (permissionStatus.value !== 'allowed') {
const granted = await requestCameraPermission();
if (!granted) return;
}
// Ambil ulang device list dan pilih kamera terbaik
const deviceList = await getVideoDevices();
const bestDevice = findBestDevice(deviceList);
if (bestDevice) {
await startScanner(bestDevice.deviceId); // Gunakan deviceId eksplisit
}
}
});
// Setup on mount
onMounted(async () => {
isSupported.value = checkCameraSupport();
if (!isSupported.value) {
console.warn('[BarcodeScanner] Camera is not supported in this browser.');
return;
}
const hasPermission = await requestCameraPermission();
if (!hasPermission) return;
const availableDevices = await getVideoDevices();
if (availableDevices.length === 0) {
error.value = 'No cameras available.';
return;
}
// Delay start until devices are fully ready
const bestDevice = findBestDevice(availableDevices);
if (autostart && videoElement.value && bestDevice) {
await startScanner(bestDevice.deviceId);
}
});
// Cleanup on unmount
onBeforeUnmount(() => {
stopScanner();
});
return {
// State
barcode,
error,
isSupported,
status,
permissionStatus,
devices,
currentDeviceIndex,
// Methods
startScanner,
stopScanner,
switchCamera,
resetScanner,
getCurrentDeviceName,
// Computed
currentDevice: computed(() => devices.value[currentDeviceIndex.value] || null),
hasMultipleCameras: computed(() => devices.value.length > 1)
};
}