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();
+ }
})();