575 lines
22 KiB
TypeScript
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,
|
|
}
|
|
}
|