d7da7afe5e
Build and push image / build (push) Successful in 1m20s
Two root causes for "camera access denied" without a permission prompt: 1. iOS Safari blocks getUserMedia on non-localhost HTTP origins entirely — no prompt, silent denial. Now detects window.isSecureContext and shows "HTTPS required" instead of the cryptic error. 2. html5-qrcode does async DOM work (creating video elements, styling) between the user tap and the actual getUserMedia call, which breaks iOS Safari's transient user activation window. Now calls navigator.mediaDevices.getUserMedia() directly in the tap handler to trigger the permission prompt while the gesture is still active, then stops that stream and lets html5-qrcode reuse the granted permission. Also improved error messages for denied permissions with guidance on how to fix it in browser settings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
308 lines
9.3 KiB
TypeScript
308 lines
9.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Html5Qrcode } from "html5-qrcode";
|
|
import { Icon } from "./primitives/index.js";
|
|
|
|
export function CameraScanner({
|
|
onScan,
|
|
onClose,
|
|
}: {
|
|
onScan: (decodedText: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const scannerRef = useRef<Html5Qrcode | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [started, setStarted] = useState(false);
|
|
const [torchOn, setTorchOn] = useState(false);
|
|
const [torchAvailable, setTorchAvailable] = useState(false);
|
|
const lastScan = useRef("");
|
|
const lastScanTime = useRef(0);
|
|
|
|
const startScanner = useCallback(async () => {
|
|
const id = "apothecary-scanner";
|
|
let scanner = scannerRef.current;
|
|
if (!scanner) {
|
|
scanner = new Html5Qrcode(id, { verbose: false });
|
|
scannerRef.current = scanner;
|
|
}
|
|
if (scanner.isScanning) return;
|
|
|
|
await scanner.start(
|
|
{ facingMode: "environment" },
|
|
{ fps: 10, qrbox: (w, h) => ({ width: Math.min(w - 40, 280), height: Math.min(h - 40, 280) }) },
|
|
(text) => {
|
|
const now = Date.now();
|
|
if (text === lastScan.current && now - lastScanTime.current < 3000) return;
|
|
lastScan.current = text;
|
|
lastScanTime.current = now;
|
|
onScan(text);
|
|
},
|
|
() => {},
|
|
);
|
|
setStarted(true);
|
|
try {
|
|
const caps = scanner.getRunningTrackCameraCapabilities();
|
|
if (caps.torchFeature().isSupported()) {
|
|
setTorchAvailable(true);
|
|
}
|
|
} catch {
|
|
// torch check can fail on some browsers
|
|
}
|
|
}, [onScan]);
|
|
|
|
const startCamera = useCallback(async () => {
|
|
if (!window.isSecureContext) {
|
|
setError(
|
|
"Camera requires a secure connection (HTTPS). " +
|
|
"Access this site over https:// or on localhost to use the scanner.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!navigator.mediaDevices?.getUserMedia) {
|
|
setError("Camera API not available in this browser.");
|
|
return;
|
|
}
|
|
|
|
// Request camera permission directly in the user gesture handler.
|
|
// html5-qrcode does async DOM work before calling getUserMedia internally,
|
|
// which breaks iOS Safari's transient user activation window.
|
|
// By calling getUserMedia here first, we trigger the permission prompt
|
|
// while the gesture is still active, then let html5-qrcode reuse the grant.
|
|
let stream: MediaStream;
|
|
try {
|
|
stream = await navigator.mediaDevices.getUserMedia({
|
|
video: { facingMode: "environment" },
|
|
});
|
|
} catch (err: unknown) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("denied") || msg.includes("NotAllowedError")) {
|
|
setError(
|
|
"Camera permission was denied. Open your browser settings to allow camera access for this site, then try again.",
|
|
);
|
|
} else {
|
|
setError(msg);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Permission granted — stop this stream so html5-qrcode can open its own
|
|
stream.getTracks().forEach((t) => t.stop());
|
|
|
|
try {
|
|
await startScanner();
|
|
} catch (err: unknown) {
|
|
setError(err instanceof Error ? err.message : "Scanner failed to start");
|
|
}
|
|
}, [startScanner]);
|
|
|
|
useEffect(() => {
|
|
// Auto-start on desktop where getUserMedia works outside gestures
|
|
const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
if (!isTouchDevice && window.isSecureContext) {
|
|
startCamera();
|
|
}
|
|
return () => {
|
|
if (scannerRef.current?.isScanning) {
|
|
scannerRef.current.stop().catch(() => {});
|
|
}
|
|
};
|
|
}, [startCamera]);
|
|
|
|
const toggleTorch = () => {
|
|
try {
|
|
const caps = scannerRef.current?.getRunningTrackCameraCapabilities();
|
|
if (caps?.torchFeature().isSupported()) {
|
|
const next = !torchOn;
|
|
caps.torchFeature().apply(next);
|
|
setTorchOn(next);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const insecure = !window.isSecureContext;
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 50,
|
|
background: "#000",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
}}
|
|
>
|
|
{/* Top bar */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "env(safe-area-inset-top, 12px) 16px 12px",
|
|
position: "relative",
|
|
zIndex: 2,
|
|
}}
|
|
>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: "50%",
|
|
background: "rgba(0,0,0,0.5)",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
color: "#fff",
|
|
}}
|
|
>
|
|
<Icon name="close" size={22} color="#fff" />
|
|
</button>
|
|
<div
|
|
className="serif"
|
|
style={{ color: "#fff", fontSize: 18, fontWeight: 500 }}
|
|
>
|
|
Scan
|
|
</div>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
{torchAvailable && (
|
|
<button
|
|
onClick={toggleTorch}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: "50%",
|
|
background: torchOn ? "var(--sage)" : "rgba(0,0,0,0.5)",
|
|
border: "none",
|
|
cursor: "pointer",
|
|
color: "#fff",
|
|
}}
|
|
>
|
|
<Icon name="flash" size={20} color="#fff" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Camera viewport */}
|
|
<div
|
|
ref={containerRef}
|
|
style={{ flex: 1, position: "relative", overflow: "hidden" }}
|
|
>
|
|
<div
|
|
id="apothecary-scanner"
|
|
style={{ width: "100%", height: "100%" }}
|
|
/>
|
|
{!started && !error && (
|
|
<div
|
|
onClick={insecure ? undefined : startCamera}
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 16,
|
|
padding: 32,
|
|
color: "#fff",
|
|
textAlign: "center",
|
|
cursor: insecure ? "default" : "pointer",
|
|
}}
|
|
>
|
|
{insecure ? (
|
|
<>
|
|
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
|
|
<div style={{ fontSize: 16, fontWeight: 500 }}>HTTPS required</div>
|
|
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>
|
|
Camera access requires a secure connection. Access this site
|
|
over <strong>https://</strong> to use the barcode scanner.
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div
|
|
style={{
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: "50%",
|
|
background: "var(--sage)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Icon name="camera" size={36} color="#fff" />
|
|
</div>
|
|
<div style={{ fontSize: 16, fontWeight: 500 }}>Tap to start camera</div>
|
|
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>
|
|
Allow camera access when prompted
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 16,
|
|
padding: 32,
|
|
color: "#fff",
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
<Icon name="camera" size={48} color="rgba(255,255,255,0.5)" />
|
|
<div style={{ fontSize: 16, fontWeight: 500 }}>Camera unavailable</div>
|
|
<div style={{ fontSize: 13, opacity: 0.7, maxWidth: 300 }}>{error}</div>
|
|
{!insecure && (
|
|
<button
|
|
onClick={() => { setError(null); startCamera(); }}
|
|
style={{
|
|
marginTop: 8,
|
|
padding: "10px 24px",
|
|
borderRadius: 8,
|
|
background: "var(--sage)",
|
|
color: "#fff",
|
|
border: "none",
|
|
fontSize: 14,
|
|
fontWeight: 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
Try again
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Bottom hint */}
|
|
<div
|
|
style={{
|
|
padding: "16px 24px",
|
|
paddingBottom: "calc(16px + env(safe-area-inset-bottom, 0px))",
|
|
textAlign: "center",
|
|
color: "rgba(255,255,255,0.7)",
|
|
fontSize: 14,
|
|
}}
|
|
>
|
|
{started ? "Point at a barcode or QR code" : ""}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|