diff --git a/app.js b/app.js index 9478cf5..a2a2129 100644 --- a/app.js +++ b/app.js @@ -88,6 +88,20 @@ var pendingQrToken = null; var html5QrCodeInstance = null; + // Enroll wajah saat daftar + var enrollFaceModal = document.getElementById('enroll-face-modal'); + var enrollFaceVideo = document.getElementById('enroll-face-video'); + var enrollFaceStatus = document.getElementById('enroll-face-status'); + var enrollFaceError = document.getElementById('enroll-face-error'); + var enrollFaceStart = document.getElementById('enroll-face-start'); + var enrollFaceBackdrop = document.getElementById('enroll-face-backdrop'); + var enrollStream = null; + var enrollIntervalId = null; + var enrollCollectedImages = []; + var enrollTargetCount = 5; + var enrollMinCount = 3; + var enrollSubmitting = false; + // Device credentials — bisa diisi di Pengaturan, default untuk development var DEFAULT_DEVICE_CODE = 'MOBILE_APP'; var DEFAULT_API_KEY = 'MOBILE_APP_SECRET'; @@ -535,8 +549,8 @@ saveStudentProfile(profile); renderStudentSummary(homeStudentSummary, profile); - goToScreen('home'); - showToast('Registrasi berhasil. Siap presensi.', 'success'); + openEnrollFaceModal(); + showToast('Registrasi berhasil. Rekam wajah untuk verifikasi absen.', 'success'); }).catch(function (err) { btnCompleteRegister.disabled = false; btnCompleteRegister.textContent = 'Simpan & Mulai'; @@ -877,6 +891,162 @@ if (cameraModal) cameraModal.classList.add('hidden'); } + function captureEnrollFrameAsBase64() { + if (!enrollFaceVideo || enrollFaceVideo.readyState < 2) return null; + var w = enrollFaceVideo.videoWidth || 640; + var h = enrollFaceVideo.videoHeight || 480; + if (w === 0 || h === 0) return null; + var canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + var ctx = canvas.getContext('2d'); + ctx.drawImage(enrollFaceVideo, 0, 0, w, h); + return canvas.toDataURL('image/jpeg', 0.9); + } + + function closeEnrollFaceModal() { + if (enrollIntervalId) { + clearInterval(enrollIntervalId); + enrollIntervalId = null; + } + enrollCollectedImages = []; + if (enrollStream) { + enrollStream.getTracks().forEach(function (t) { t.stop(); }); + enrollStream = null; + } + if (enrollFaceVideo) enrollFaceVideo.srcObject = null; + if (enrollFaceModal) enrollFaceModal.classList.add('hidden'); + if (enrollFaceStatus) { + enrollFaceStatus.classList.add('hidden'); + enrollFaceStatus.textContent = ''; + } + } + + function openEnrollFaceModal() { + if (!ensureConfig()) { + goToScreen('home'); + return; + } + if (!enrollFaceModal || !enrollFaceVideo || !currentStudentProfile || !currentStudentProfile.student_id) { + goToScreen('home'); + return; + } + enrollCollectedImages = []; + enrollSubmitting = false; + if (enrollFaceError) enrollFaceError.classList.add('hidden'); + if (enrollFaceStatus) { + enrollFaceStatus.textContent = 'Menyiapkan kamera…'; + enrollFaceStatus.classList.remove('hidden'); + } + enrollFaceModal.classList.remove('hidden'); + enrollFaceVideo.style.display = 'block'; + var constraints = { video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, audio: false }; + navigator.mediaDevices.getUserMedia(constraints).then(function (stream) { + enrollStream = stream; + enrollFaceVideo.srcObject = stream; + enrollFaceVideo.onloadedmetadata = function () { + enrollFaceVideo.play().then(function () { + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Arahkan wajah ke kamera lalu tekan Rekam.'; + }).catch(function () {}); + }; + }).catch(function () { + if (enrollFaceError) { + enrollFaceError.textContent = 'Kamera tidak tersedia atau izin ditolak. Bisa dilewati.'; + enrollFaceError.classList.remove('hidden'); + } + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Kamera gagal. Tekan Lewati untuk lanjut.'; + }); + } + + function startEnrollCapture() { + if (enrollSubmitting || !enrollStream || !enrollFaceVideo || typeof faceapi === 'undefined') return; + if (!currentStudentProfile || !currentStudentProfile.student_id) return; + enrollSubmitting = true; + enrollCollectedImages = []; + if (enrollFaceStart) { enrollFaceStart.disabled = true; enrollFaceStart.textContent = 'Merekam…'; } + var weightsUrl = 'https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/weights/'; + faceapi.nets.tinyFaceDetector.loadFromUri(weightsUrl).then(function () { + var tick = 0; + var lastFaceCount = 0; + enrollIntervalId = setInterval(function () { + if (!enrollStream || !enrollFaceVideo.srcObject || enrollFaceVideo.readyState < 2) return; + var detect = faceapi.detectSingleFace(enrollFaceVideo, new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 })); + detect.then(function (detection) { + if (detection && enrollCollectedImages.length < enrollTargetCount) { + var b64 = captureEnrollFrameAsBase64(); + if (b64) { + enrollCollectedImages.push(b64); + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Rekam ' + enrollCollectedImages.length + '/' + enrollTargetCount + '…'; + } + } + if (enrollCollectedImages.length >= enrollMinCount) { + clearInterval(enrollIntervalId); + enrollIntervalId = null; + var imgs = enrollCollectedImages.slice(0, enrollTargetCount); + var url = backendBase() + '/api/face/enroll-live'; + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Mengirim ke server…'; + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ student_id: currentStudentProfile.student_id, images: imgs }) + }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); }) + .then(function (r) { + enrollSubmitting = false; + if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; } + if (r.ok && r.data && (r.data.saved || 0) > 0) { + showToast('Wajah berhasil direkam. Siap untuk verifikasi absen.', 'success'); + } else { + showToast('Rekam wajah gagal. Bisa dicoba nanti dari pengaturan.', 'info'); + } + closeEnrollFaceModal(); + goToScreen('home'); + }).catch(function () { + enrollSubmitting = false; + if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; } + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal mengirim. Coba lagi atau Lewati.'; + showToast('Gagal mengirim rekaman wajah.', 'error'); + }); + } + }).catch(function () {}); + tick++; + if (tick > 80) { + clearInterval(enrollIntervalId); + enrollIntervalId = null; + enrollSubmitting = false; + if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; } + if (enrollCollectedImages.length >= 1) { + var url = backendBase() + '/api/face/enroll-live'; + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Mengirim ' + enrollCollectedImages.length + ' foto…'; + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify({ student_id: currentStudentProfile.student_id, images: enrollCollectedImages }) + }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); }) + .then(function (r) { + if (r.ok && (r.data && r.data.saved > 0)) showToast('Wajah berhasil direkam.', 'success'); + else showToast('Rekam wajah gagal. Bisa dicoba nanti.', 'info'); + closeEnrollFaceModal(); + goToScreen('home'); + }).catch(function () { + enrollSubmitting = false; + if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; } + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal mengirim. Coba lagi atau Lewati.'; + showToast('Gagal mengirim rekaman wajah.', 'error'); + }); + } else { + enrollSubmitting = false; + if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; } + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Wajah tidak terdeteksi. Arahkan wajah ke kamera lalu Rekam lagi.'; + } + } + }, 500); + }).catch(function () { + enrollSubmitting = false; + if (enrollFaceStart) { enrollFaceStart.disabled = false; enrollFaceStart.textContent = 'Rekam (3–5 foto)'; } + if (enrollFaceStatus) enrollFaceStatus.textContent = 'Gagal memuat deteksi wajah. Lewati atau coba lagi.'; + }); + } + function stopCamera() { if (cameraStream) { cameraStream.getTracks().forEach(function (t) { t.stop(); }); @@ -1235,6 +1405,14 @@ if (cameraBackdrop) { cameraBackdrop.addEventListener('click', closeCameraModal); } + if (enrollFaceBackdrop) { + enrollFaceBackdrop.addEventListener('click', function () { + showToast('Rekam wajah wajib untuk melanjutkan.', 'info'); + }); + } + if (enrollFaceStart) { + enrollFaceStart.addEventListener('click', startEnrollCapture); + } if (resultCloseBtn) { resultCloseBtn.addEventListener('click', function () { resultBox.classList.add('hidden'); diff --git a/index.html b/index.html index a6c67b9..9cf1ecc 100644 --- a/index.html +++ b/index.html @@ -315,6 +315,27 @@ + +
+