Files
web-antrean/composables/useQRScanner.ts
T
2026-01-05 08:32:59 +07:00

575 lines
22 KiB
TypeScript

import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { QR_CODE_ID, SCAN_DEBOUNCE_MS, SUCCESSFUL_SCANS_KEY } from '~/constants/checkin'
export const useQRScanner = (
onQRDetected: (decodedText: string) => void,
showSnackbar: (title: string, message: string, color: string, icon: string) => void
) => {
// Scanner state
const isScanning = ref(false)
const hasCamera = ref(false)
const cameraChecking = ref(true)
const cameraReady = ref(false)
let html5QrCode: any = null
const qrCodeId = QR_CODE_ID
let lastScannedQR: string | null = null
let lastScanTime: number = 0
let isProcessing = false // Flag untuk mencegah pemrosesan berulang
// Daftar QR code yang sudah berhasil di-scan (untuk mencegah double antrian)
const successfulScans = ref<Set<string>>(new Set())
// Detect mobile device
const isMobile = ref(false)
if (typeof window !== 'undefined') {
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(window.innerWidth <= 768)
}
// Load successful scans dari localStorage
const loadSuccessfulScans = () => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(SUCCESSFUL_SCANS_KEY)
if (stored) {
try {
const scansArray = JSON.parse(stored)
successfulScans.value = new Set(scansArray)
} catch (e) {
console.error('Error loading successful scans:', e)
successfulScans.value = new Set()
}
}
}
}
// Simpan successful scan ke localStorage
const saveSuccessfulScan = (qrData: string) => {
if (typeof window !== 'undefined') {
successfulScans.value.add(qrData)
const scansArray = Array.from(successfulScans.value)
localStorage.setItem(SUCCESSFUL_SCANS_KEY, JSON.stringify(scansArray))
}
}
// Cek apakah QR code sudah pernah berhasil di-scan
const isQRCodeAlreadyScanned = (qrData: string): boolean => {
return successfulScans.value.has(qrData)
}
// Check camera availability
const checkCameraAvailability = async () => {
cameraChecking.value = true
hasCamera.value = false
// Check if browser supports mediaDevices
if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
console.error('navigator.mediaDevices is not supported')
showSnackbar('Error', 'Browser tidak mendukung akses kamera. Pastikan menggunakan HTTPS atau localhost.', 'error', 'mdi-camera-off')
cameraChecking.value = false
return
}
try {
// First, request permission by trying to get user media
// This is required for enumerateDevices to return device labels
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
// Stop the stream immediately after getting permission
stream.getTracks().forEach(track => track.stop())
// Now enumerate devices (will have labels after permission granted)
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
hasCamera.value = videoDevices.length > 0
if (hasCamera.value) {
console.log(`Found ${videoDevices.length} camera(s):`, videoDevices.map(d => d.label || d.deviceId))
} else {
console.warn('No video input devices found')
showSnackbar('Warning', 'Tidak ada kamera yang terdeteksi', 'warning', 'mdi-camera-off')
}
} catch (err: any) {
console.error('Error checking camera:', err)
hasCamera.value = false
let errorMessage = 'Gagal mengakses kamera. '
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMessage += 'Izin kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.'
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
errorMessage += 'Tidak ada kamera yang ditemukan.'
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
errorMessage += 'Kamera sedang digunakan aplikasi lain.'
} else if (err.name === 'OverconstrainedError' || err.name === 'ConstraintNotSatisfiedError') {
errorMessage += 'Kamera tidak memenuhi persyaratan.'
} else {
errorMessage += `Error: ${err.message || err.name}`
}
showSnackbar('Error', errorMessage, 'error', 'mdi-camera-off')
} finally {
cameraChecking.value = false
}
}
// Test camera function for debugging
const testCamera = async () => {
try {
showSnackbar('Info', 'Menguji akses kamera...', 'info', 'mdi-camera')
if (typeof navigator === 'undefined') {
showSnackbar('Error', 'navigator is undefined - pastikan di browser, bukan SSR', 'error', 'mdi-alert')
return
}
if (!navigator.mediaDevices) {
showSnackbar('Error', 'navigator.mediaDevices is undefined - pastikan menggunakan HTTPS atau localhost', 'error', 'mdi-alert')
return
}
if (!navigator.mediaDevices.getUserMedia) {
showSnackbar('Error', 'getUserMedia is not supported', 'error', 'mdi-alert')
return
}
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'user',
width: { ideal: 640 },
height: { ideal: 480 }
}
})
showSnackbar('Success', 'Kamera berhasil diakses!', 'success', 'mdi-check-circle')
// Stop stream after 2 seconds
setTimeout(() => {
stream.getTracks().forEach(track => track.stop())
}, 2000)
} catch (err: any) {
console.error('Test camera error:', err)
let errorMsg = `Error: ${err.name || 'Unknown'}`
if (err.message) errorMsg += ` - ${err.message}`
showSnackbar('Error', errorMsg, 'error', 'mdi-alert')
}
}
// Handle QR scan success
const handleQRScanSuccess = (decodedText: string) => {
// Validasi data QR - cek di awal sebelum log apapun
if (!decodedText || decodedText.trim() === '') {
return // Silent return untuk empty QR code
}
// Cegah pemrosesan berulang jika sedang memproses
if (isProcessing) {
return // Silent return untuk mencegah spam log
}
// Debounce: cegah scan berulang dalam waktu singkat - cek sebelum log
const now = Date.now()
if (lastScannedQR === decodedText && (now - lastScanTime) < SCAN_DEBOUNCE_MS) {
return // Silent return untuk debounce
}
// Cek apakah QR code sudah pernah berhasil di-scan (mencegah double antrian)
if (isQRCodeAlreadyScanned(decodedText)) {
console.log('⏭️ Scan diabaikan: QR code sudah pernah berhasil di-scan')
showSnackbar('Peringatan', 'QR Code ini sudah pernah berhasil di-scan. Tidak dapat digunakan lagi untuk mencegah double antrian.', 'warning', 'mdi-alert-circle')
return
}
// Set flag processing dan update last scan info
isProcessing = true
lastScannedQR = decodedText
lastScanTime = now
console.log('🎯 QR Scan Success! Data:', decodedText)
// Scanner tetap berjalan untuk memungkinkan scan QR code berikutnya
// Proses data QR melalui callback
try {
onQRDetected(decodedText)
} catch (error) {
console.error('Error processing QR data:', error)
showSnackbar('Error', 'Gagal memproses data QR Code', 'error', 'mdi-alert')
} finally {
// Reset flag setelah selesai memproses (dengan delay kecil untuk memastikan callback selesai)
setTimeout(() => {
isProcessing = false
}, SCAN_DEBOUNCE_MS)
}
}
// QR Scanner Functions
const startScanning = async () => {
// Check camera first
if (!hasCamera.value) {
await checkCameraAvailability()
if (!hasCamera.value) {
showSnackbar('Error', 'Kamera tidak tersedia. Silakan gunakan input manual atau pastikan kamera terhubung.', 'error', 'mdi-camera-off')
return
}
}
try {
// Set scanning state first to render the element
isScanning.value = true
// Wait for DOM to update and element to be rendered
await nextTick()
// Wait a bit more to ensure element is fully rendered
await new Promise(resolve => setTimeout(resolve, 100))
// Check if element exists
const qrElement = document.getElementById(qrCodeId)
if (!qrElement) {
console.error(`Element with id "${qrCodeId}" not found in DOM`)
isScanning.value = false
showSnackbar('Error', 'Element scanner tidak ditemukan. Silakan refresh halaman.', 'error', 'mdi-alert')
return
}
// Dynamic import html5-qrcode
if (!html5QrCode) {
const { Html5Qrcode: Html5QrcodeClass } = await import('html5-qrcode')
html5QrCode = new Html5QrcodeClass(qrCodeId)
console.log('Html5Qrcode initialized')
}
if (html5QrCode) {
cameraReady.value = false
// Konfigurasi kamera untuk mobile dan desktop
const cameraConfig = isMobile.value
? { facingMode: "environment" } // Mobile: gunakan kamera belakang
: { facingMode: "user" } // Desktop: gunakan kamera depan (webcam)
// Video constraints yang lebih fleksibel
const baseVideoConstraints = isMobile.value
? {
facingMode: "environment",
width: { ideal: 640, min: 320 },
height: { ideal: 480, min: 240 }
}
: {
facingMode: "user",
width: { ideal: 1280, min: 640 },
height: { ideal: 720, min: 480 }
}
// Tambahkan advanced constraints hanya jika didukung
const videoConstraints: any = { ...baseVideoConstraints }
console.log('Starting QR scanner with config:', {
isMobile: isMobile.value,
cameraConfig,
videoConstraints
})
try {
await html5QrCode.start(
cameraConfig,
{
fps: isMobile.value ? 15 : 20, // FPS optimal untuk deteksi
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
// QR box dengan ukuran lebih besar untuk deteksi lebih mudah
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
// Gunakan 80-90% dari viewfinder untuk area scanning yang lebih besar
const percentageSize = Math.floor(minEdgeSize * 0.80)
// Atau ukuran tetap yang lebih besar
const fixedSize = isMobile.value ? 250 : 250
// Gunakan yang lebih besar antara percentage atau fixed
const qrboxSize = Math.max(percentageSize, fixedSize)
// Pastikan tidak melebihi viewfinder
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80)
console.log('QR Box size:', finalSize, 'Viewfinder:', viewfinderWidth, 'x', viewfinderHeight)
return {
width: Math.max(finalSize, 250), // Minimum 250px untuk deteksi lebih baik
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: videoConstraints,
rememberLastUsedCamera: true,
showTorchButtonIfSupported: true,
// Tambahkan opsi untuk meningkatkan deteksi
verbose: false // Set true untuk debugging
},
(decodedText: string, decodedResult: any) => {
// QR Code berhasil di-scan
console.log('✅ QR Code detected:', decodedText)
console.log('📊 Decoded result:', decodedResult)
// Validasi dan proses QR code
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
} else {
console.warn('Empty QR code detected')
}
},
(errorMessage: string) => {
// Log error untuk debugging - hanya log error penting
// Error ini biasanya muncul terus menerus saat tidak ada QR code yang terdeteksi
// Jadi kita filter untuk menghindari spam console
if (errorMessage) {
// Filter out common non-critical errors
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code parse error, error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner warning:', errorMessage)
}
}
}
)
// Set camera ready setelah sedikit delay untuk memastikan video sudah dimuat
setTimeout(() => {
cameraReady.value = true
console.log('Camera ready, scanner is active')
}, 500)
showSnackbar('Info', 'Scanner aktif. Arahkan kamera ke QR code dan pastikan pencahayaan cukup', 'info', 'mdi-camera')
} catch (cameraError: any) {
console.error('Camera error:', cameraError)
// Jika kamera belakang tidak tersedia di mobile, coba kamera depan
if (isMobile.value && cameraError.name === 'NotFoundError') {
try {
console.log('Trying front camera as fallback...')
await html5QrCode.start(
{ facingMode: "user" },
{
fps: 15,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
const percentageSize = Math.floor(minEdgeSize * 0.85)
const fixedSize = 300
const qrboxSize = Math.max(percentageSize, fixedSize)
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.95)
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
facingMode: "user",
width: { ideal: 640, min: 320 },
height: { ideal: 480, min: 240 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (front camera):', decodedText)
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner (front) warning:', errorMessage)
}
}
}
)
setTimeout(() => {
cameraReady.value = true
}, 500)
showSnackbar('Info', 'Menggunakan kamera depan. Arahkan QR code ke kamera', 'info', 'mdi-camera')
} catch (fallbackError: any) {
console.error('Fallback camera also failed:', fallbackError)
throw cameraError // Throw original error
}
} else {
// Coba menggunakan deviceId langsung jika facingMode gagal
try {
console.log('Trying to get camera devices...')
const devices = await navigator.mediaDevices.enumerateDevices()
const videoDevices = devices.filter(device => device.kind === 'videoinput')
if (videoDevices.length > 0) {
// Gunakan kamera pertama yang tersedia
const deviceId = videoDevices[0].deviceId
console.log('Using device:', deviceId, videoDevices[0].label)
await html5QrCode.start(
{ deviceId: { exact: deviceId } },
{
fps: isMobile.value ? 15 : 20,
qrbox: function(viewfinderWidth: number, viewfinderHeight: number) {
const minEdgeSize = Math.min(viewfinderWidth, viewfinderHeight)
const percentageSize = Math.floor(minEdgeSize * 0.80)
const fixedSize = isMobile.value ? 300 : 400
const qrboxSize = Math.max(percentageSize, fixedSize)
const finalSize = Math.min(qrboxSize, minEdgeSize * 0.80)
return {
width: Math.max(finalSize, 250),
height: Math.max(finalSize, 250)
}
},
aspectRatio: 1.0,
disableFlip: false,
videoConstraints: {
deviceId: { exact: deviceId },
width: { ideal: isMobile.value ? 640 : 1280, min: isMobile.value ? 320 : 640 },
height: { ideal: isMobile.value ? 480 : 720, min: isMobile.value ? 240 : 480 }
},
rememberLastUsedCamera: true,
verbose: false
},
(decodedText: string, decodedResult: any) => {
console.log('✅ QR Code detected (deviceId):', decodedText)
if (decodedText && decodedText.trim() !== '') {
handleQRScanSuccess(decodedText)
}
},
(errorMessage: string) => {
if (errorMessage) {
const isCriticalError = !errorMessage.includes('NotFoundException') &&
!errorMessage.includes('No QR') &&
!errorMessage.includes('QR code parse error') &&
!errorMessage.includes('QR code decode error')
if (isCriticalError) {
console.warn('QR Scanner (deviceId) warning:', errorMessage)
}
}
}
)
setTimeout(() => {
cameraReady.value = true
}, 500)
showSnackbar('Info', 'Scanner aktif menggunakan kamera yang tersedia', 'info', 'mdi-camera')
} else {
throw cameraError
}
} catch (deviceError: any) {
console.error('DeviceId approach also failed:', deviceError)
throw cameraError
}
}
}
}
} catch (err: any) {
console.error('Error starting scanner:', err)
isScanning.value = false
cameraReady.value = false
// Don't set hasCamera to false if it was detected, just log the error
let errorMessage = 'Gagal memulai scanner. '
if (err.message && err.message.includes('not found')) {
errorMessage = 'Element scanner tidak ditemukan. Silakan refresh halaman dan coba lagi.'
console.error('Element not found error. Element ID:', qrCodeId)
} else if (err.name === 'NotAllowedError') {
errorMessage = 'Akses kamera ditolak. Mohon izinkan akses kamera di pengaturan browser.'
} else if (err.name === 'NotFoundError') {
errorMessage = 'Kamera tidak ditemukan. Silakan gunakan input manual.'
hasCamera.value = false
} else if (err.name === 'NotReadableError') {
errorMessage = 'Kamera sedang digunakan aplikasi lain. Tutup aplikasi lain yang menggunakan kamera.'
} else {
errorMessage += err.message || err.name || 'Unknown error'
}
showSnackbar('Error', errorMessage, 'error', 'mdi-alert')
}
}
const stopScanning = async () => {
const scannerInstance = html5QrCode
// Reset state immediately to update UI
isScanning.value = false
cameraReady.value = false
// Clear reference immediately to prevent re-entry
html5QrCode = null
// If no instance, nothing to clean up
if (!scannerInstance) {
return
}
try {
// Stop the scanner (this stops the camera stream)
if (typeof scannerInstance.stop === 'function') {
await scannerInstance.stop().catch((err: any) => {
// Ignore stop errors - scanner might already be stopped
console.warn('Scanner stop error (ignored):', err?.message || err)
})
}
} catch (err: any) {
// Ignore errors from stop
console.warn('Error stopping scanner (ignored):', err?.message || err)
}
try {
// Clear the scanner (this cleans up DOM and resources)
// Only try to clear if stop was successful or if clear method exists
if (typeof scannerInstance.clear === 'function') {
await scannerInstance.clear().catch((err: any) => {
// Ignore clear errors - DOM might already be cleaned up
console.warn('Scanner clear error (ignored):', err?.message || err)
})
}
} catch (err: any) {
// Ignore errors from clear
console.warn('Error clearing scanner (ignored):', err?.message || err)
}
// Show notification after cleanup attempts
try {
showSnackbar('Info', 'Scanner dihentikan', 'info', 'mdi-camera-off')
} catch (e) {
// Ignore snackbar errors
}
}
// Initialize on mount
if (typeof window !== 'undefined') {
loadSuccessfulScans()
}
return {
// State
isScanning,
hasCamera,
cameraChecking,
cameraReady,
// Functions
checkCameraAvailability,
testCamera,
startScanning,
stopScanning,
saveSuccessfulScan,
isQRCodeAlreadyScanned,
}
}