// Mobile-friendly camera control + analyze-on-click + upload fallback
document.addEventListener("DOMContentLoaded", () => {
const video = document.getElementById("camera-feed");
const startBtn = document.getElementById("start-camera");
const analyzeBtn = document.getElementById("analyze-button");
const uploadBtn = document.getElementById("upload-photo");
const photoInput = document.getElementById("photo-input");
const contextList = document.getElementById("context-list");
// Optional inputs (add these to your HTML if you want):
//
//
//
//
//
//
//
const cityInput = document.getElementById("city-input");
const attrIds = [
"soft_bag",
"foam",
"paper_cup",
"carton",
"greasy_or_wet",
// "hazard" is optional to send; include if you want:
"hazard",
];
const attrEls = Object.fromEntries(
attrIds.map((id) => [id, document.getElementById(`attr-${id}`)])
);
// avoid duplicate bindings
if (!analyzeBtn || analyzeBtn.dataset.bound === "true") return;
analyzeBtn.dataset.bound = "true";
let stream = null;
let inFlight = false;
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
function collectContext() {
// city is case-insensitive; backend normalizes
const city = cityInput?.value?.trim() || "default";
const attrs = {};
for (const [k, el] of Object.entries(attrEls)) {
if (el && typeof el.checked === "boolean") attrs[k] = !!el.checked;
}
return { city, attrs };
}
async function startCamera() {
const base = { width: { ideal: 1280 }, height: { ideal: 720 }, aspectRatio: { ideal: 16 / 9 } };
const envStrict = { video: { facingMode: { exact: "environment" }, ...base } };
const envLoose = { video: { facingMode: "environment", ...base } };
try {
stream = await navigator.mediaDevices.getUserMedia(envStrict);
} catch {
try {
stream = await navigator.mediaDevices.getUserMedia(envLoose);
} catch {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
}
}
video.srcObject = stream;
video.setAttribute("playsinline", "");
video.muted = true;
await new Promise((r) => {
if (video.readyState >= 2) r();
else video.addEventListener("loadedmetadata", r, { once: true });
});
try { await video.play(); } catch (_) {}
analyzeBtn.disabled = false;
startBtn && (startBtn.textContent = "Camera On");
startBtn && (startBtn.disabled = true);
window.addEventListener("beforeunload", () => stream?.getTracks().forEach(t => t.stop()));
}
startBtn?.addEventListener("click", async () => {
try {
await startCamera(); // HTTPS required on mobile (localhost is OK)
} catch (e) {
console.error("Camera error:", e);
alert("Couldn’t open the camera. You can upload a photo instead.");
uploadBtn?.focus();
}
});
function grabFrameCanvas() {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const track = stream?.getVideoTracks?.()[0];
const s = track?.getSettings?.() || {};
let w = s.width || video.videoWidth || 640;
let h = s.height || video.videoHeight || 480;
// portrait heuristic for phones
const portraitScreen = isMobile && window.innerHeight > window.innerWidth;
const needRotate = portraitScreen && w > h;
if (needRotate) {
canvas.width = h; canvas.height = w;
ctx.save(); ctx.translate(h, 0); ctx.rotate(Math.PI / 2);
ctx.drawImage(video, 0, 0, w, h); ctx.restore();
} else {
canvas.width = w; canvas.height = h;
ctx.drawImage(video, 0, 0, w, h);
}
return canvas;
}
// Resize & convert any image File to a JPEG data URL
async function fileToJpegDataURL(file, { maxDim = 1600, quality = 0.85 } = {}) {
// Prefer createImageBitmap for speed & orientation handling where supported
let bitmap;
try {
bitmap = await createImageBitmap(file);
} catch {
// Fallback: load via
const url = URL.createObjectURL(file);
try {
bitmap = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
} finally {
URL.revokeObjectURL(url);
}
}
const { width: w0, height: h0 } = bitmap;
const scale = Math.min(1, maxDim / Math.max(w0, h0));
const w = Math.round(w0 * scale);
const h = Math.round(h0 * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(bitmap, 0, 0, w, h);
// Produce JPEG; keeps payload small and avoids HEIC/PNG backend issues
return canvas.toDataURL("image/jpeg", quality);
}
// POST JSON and surface non-JSON errors (like 413/415) nicely
async function postJson(url, payload) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify(payload),
});
const status = res.status;
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch { data = { error: text || res.statusText || "Unknown error" }; }
if (!res.ok) {
let hint = "";
if (status === 413) hint = " (image too large — try a smaller photo)";
if (status === 415) hint = " (unsupported format — JPEG should fix)";
if (!data.error) data.error = `HTTP ${status}${hint}`;
else data.error += hint;
}
return data;
}
async function sendImagePayload(imageData) {
const { city, attrs } = collectContext();
return postJson("/process_image", { image_data: imageData, city, attrs });
}
async function sendCanvas(canvas) {
const imageData = canvas.toDataURL("image/jpeg", 0.85);
return sendImagePayload(imageData);
}
const esc = (s) =>
String(s ?? "").replace(/[&<>"']/g, (m) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
// Prefer backend-provided confidence_text; fallback to formatted percent
function confidenceText(data) {
if (data?.confidence_text) return data.confidence_text;
const c = Number(data?.confidence);
if (!Number.isNaN(c)) {
const pct = c <= 1 ? c * 100 : c;
return `${pct.toFixed(1)} % Confidence Score`;
}
return "—";
}
function renderResult(data) {
// Support both old (label) and new (material/action) API fields
const material = data.material ?? data.label ?? "Unknown";
const action = data.action ?? "Unknown";
const why = data.why ?? "";
const tip = data.tip ?? "";
const abstained = !!data.abstained;
const li = document.createElement("li");
li.className = `result-item ${abstained ? "result-item--abstained" : ""}`;
li.innerHTML = `