From 164705f8475a06b3ddee1a9a6c03d5ea7ed4dbe8 Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Mon, 1 Jun 2026 07:37:58 -0700 Subject: [PATCH] feat(qr): live camera overlay, decode loop, and file fallback Co-Authored-By: Claude Opus 4.8 --- web/resources/static/js/qr-scan.js | 169 ++++++++++++++++++++++++++++- 1 file changed, 168 insertions(+), 1 deletion(-) diff --git a/web/resources/static/js/qr-scan.js b/web/resources/static/js/qr-scan.js index 2bd9fe1..e8d0461 100644 --- a/web/resources/static/js/qr-scan.js +++ b/web/resources/static/js/qr-scan.js @@ -35,6 +35,173 @@ module.exports = { parseConnectPath }; } - // Browser-only wiring is added in a later task; bail out under Node. + // Browser-only wiring below; bail out under Node (used only for tests). if (typeof document === "undefined") return; + + let activeStream = null; + let rafId = null; + let overlayEl = null; + let hintTimer = null; + + function stopCamera() { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + if (activeStream) { + activeStream.getTracks().forEach((t) => t.stop()); + activeStream = null; + } + } + + function closeOverlay() { + stopCamera(); + if (hintTimer) { + clearTimeout(hintTimer); + hintTimer = null; + } + if (overlayEl && overlayEl.parentNode) { + overlayEl.parentNode.removeChild(overlayEl); + } + overlayEl = null; + } + + function navigateTo(path) { + stopCamera(); + window.location.assign(path); + } + + function showHint(hintEl, msg) { + if (!hintEl) return; + hintEl.textContent = msg; + hintEl.classList.add("show"); + if (hintTimer) clearTimeout(hintTimer); + hintTimer = setTimeout(() => hintEl.classList.remove("show"), 2000); + } + + function buildOverlay() { + const overlay = document.createElement("div"); + overlay.className = "qr-scan-overlay"; + overlay.innerHTML = + '
' + + '
' + + '' + + '
' + + '

' + + "
" + + ''; + document.body.appendChild(overlay); + overlay + .querySelectorAll("[data-qr-close]") + .forEach((el) => el.addEventListener("click", closeOverlay)); + return overlay; + } + + function decodeLoop(video, canvas, ctx, hintEl) { + if (!activeStream) return; // overlay was closed + if (video.readyState === video.HAVE_ENOUGH_DATA) { + const w = video.videoWidth; + const h = video.videoHeight; + if (w && h) { + canvas.width = w; + canvas.height = h; + ctx.drawImage(video, 0, 0, w, h); + const img = ctx.getImageData(0, 0, w, h); + const result = window.jsQR ? window.jsQR(img.data, w, h) : null; + if (result && result.data) { + const path = parseConnectPath(result.data, window.location.origin); + if (path) { + navigateTo(path); + return; + } + showHint(hintEl, "not an atmo.quest code — keep scanning"); + } + } + } + rafId = requestAnimationFrame(() => decodeLoop(video, canvas, ctx, hintEl)); + } + + function fallbackToFile(fileInput, hintEl) { + showHint(hintEl, "camera unavailable — tap to take a photo of the QR"); + if (fileInput) fileInput.click(); + } + + async function startLiveScan(fileInput) { + overlayEl = buildOverlay(); + const video = overlayEl.querySelector(".qr-scan-video"); + const hintEl = overlayEl.querySelector(".qr-scan-hint"); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + fallbackToFile(fileInput, hintEl); + return; + } + try { + activeStream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: "environment" }, + audio: false, + }); + } catch (err) { + fallbackToFile(fileInput, hintEl); + return; + } + video.srcObject = activeStream; + await video.play().catch(() => {}); + rafId = requestAnimationFrame(() => decodeLoop(video, canvas, ctx, hintEl)); + } + + function decodeImageFile(file) { + const reader = new FileReader(); + reader.onload = function () { + const img = new Image(); + img.onload = function () { + const canvas = document.createElement("canvas"); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(img, 0, 0); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height); + const result = window.jsQR + ? window.jsQR(data.data, canvas.width, canvas.height) + : null; + const hintEl = overlayEl && overlayEl.querySelector(".qr-scan-hint"); + if (result && result.data) { + const path = parseConnectPath(result.data, window.location.origin); + if (path) { + navigateTo(path); + return; + } + } + showHint(hintEl, "no atmo.quest code found — try again"); + }; + img.src = reader.result; + }; + reader.readAsDataURL(file); + } + + function initQrScan() { + document.querySelectorAll("[data-qr-scan]").forEach((btn) => { + const fileInput = btn.parentNode + ? btn.parentNode.querySelector('input[type="file"]') + : null; + btn.addEventListener("click", function (e) { + e.preventDefault(); + startLiveScan(fileInput); + }); + if (fileInput) { + fileInput.addEventListener("change", function () { + if (fileInput.files && fileInput.files[0]) { + decodeImageFile(fileInput.files[0]); + } + }); + } + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initQrScan); + } else { + initQrScan(); + } })(); -- 2.51.2