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>(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, } }