/** * CameraCapture — unified camera for browser + native Capacitor app * * On native (Android / iOS via Capacitor): * → Uses the Camera plugin (via registerPlugin from @capacitor/core) which * calls the OS native camera directly. No WebView permission popup, no * getUserMedia, no dialog needed. The user sees the full native camera UI * (including its own preview + retake), then returns here with the photo. * * On browser (desktop / mobile web): * → Falls back to getUserMedia stream inside this dialog component. */ import { useState, useRef, useCallback, useEffect } from 'react'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { Camera, RotateCcw, Check, Loader2, SwitchCamera, Upload, } from 'lucide-react'; // ─── Capacitor detection ──────────────────────────────────────────────────── // `@capacitor/core` is bundled and always installed, so we import it directly. // `isNativePlatform()` is web-safe (returns false in a browser). function isNative(): boolean { try { return Capacitor.isNativePlatform(); } catch { return false; } } // The Camera plugin is accessed via Capacitor's official `registerPlugin` // factory (from the bundled @capacitor/core), NOT via a bare // `import('@capacitor/camera')`. registerPlugin returns a proxy that dispatches // to the native Camera implementation over the Capacitor bridge, so: // • the bundle contains no `@capacitor/camera` specifier (native WebViews // can't resolve it, and it isn't installed on every build machine), and // • the plugin is reachable even though nothing ever imports the JS package // to trigger its side-effect self-registration (that was the bug that made // Quick Snap open the gallery instead of the camera). // CameraResultType/CameraSource are plain string enums, so we pass their string // values directly. interface CapacitorCameraPlugin { getPhoto(options: { resultType: string; source: string; quality: number; correctOrientation: boolean; saveToGallery?: boolean; allowEditing?: boolean; }): Promise<{ dataUrl?: string }>; } let cameraPlugin: CapacitorCameraPlugin | null = null; function getNativeCamera(): CapacitorCameraPlugin { if (!cameraPlugin) { cameraPlugin = registerPlugin('Camera'); } return cameraPlugin; } async function captureNative(): Promise { const Camera = getNativeCamera(); const photo = await Camera.getPhoto({ resultType: 'dataUrl', // CameraResultType.DataUrl source: 'CAMERA', // CameraSource.Camera quality: 90, correctOrientation: true, saveToGallery: false, allowEditing: false, }); if (!photo.dataUrl) return null; const res = await fetch(photo.dataUrl); const blob = await res.blob(); return new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' }); } // ─── Props ────────────────────────────────────────────────────────────────── interface CameraCaptureProps { open: boolean; onOpenChange: (open: boolean) => void; onCapture: (file: File) => void; isUploading?: boolean; } // ─── Component ────────────────────────────────────────────────────────────── export function CameraCapture({ open, onOpenChange, onCapture, isUploading, }: CameraCaptureProps) { const { toast } = useToast(); const videoRef = useRef(null); const canvasRef = useRef(null); const streamRef = useRef(null); const [hasPermission, setHasPermission] = useState(null); const [capturedImage, setCapturedImage] = useState(null); const [facingMode, setFacingMode] = useState<'user' | 'environment'>('environment'); const [isStarting, setIsStarting] = useState(false); const [isCapturingNative, setIsCapturingNative] = useState(false); // ── Native capture (Capacitor) ──────────────────────────────────────────── const handleNativeCapture = useCallback(async () => { setIsCapturingNative(true); try { const file = await captureNative(); if (file) { onCapture(file); onOpenChange(false); } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); // User cancelled — not an error if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('user cancel')) { onOpenChange(false); return; } toast({ title: 'Camera error', description: 'Could not open the camera. Please check app permissions in Settings.', variant: 'destructive', }); } finally { setIsCapturingNative(false); } }, [onCapture, onOpenChange, toast]); // ── Browser camera ──────────────────────────────────────────────────────── const startCamera = useCallback(async (facing: 'user' | 'environment' = facingMode) => { setIsStarting(true); try { if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: facing, width: { ideal: 1920 }, height: { ideal: 1080 } }, audio: false, }); streamRef.current = stream; if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play(); } setHasPermission(true); setFacingMode(facing); } catch { setHasPermission(false); toast({ title: 'Camera access denied', description: 'Please allow camera access in your browser settings.', variant: 'destructive', }); } finally { setIsStarting(false); } }, [facingMode, toast]); const stopCamera = useCallback(() => { streamRef.current?.getTracks().forEach(track => track.stop()); streamRef.current = null; if (videoRef.current) videoRef.current.srcObject = null; }, []); // On native: immediately trigger the native camera when the dialog opens // On web: start the stream useEffect(() => { if (!open) { stopCamera(); setCapturedImage(null); setHasPermission(null); return; } if (isNative()) { handleNativeCapture(); } else { startCamera(); } return () => { stopCamera(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); const switchCamera = () => { const next = facingMode === 'user' ? 'environment' : 'user'; setCapturedImage(null); startCamera(next); }; const capturePhoto = () => { if (!videoRef.current || !canvasRef.current) return; const video = videoRef.current; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; canvas.width = video.videoWidth; canvas.height = video.videoHeight; ctx.drawImage(video, 0, 0, canvas.width, canvas.height); setCapturedImage(canvas.toDataURL('image/jpeg', 0.9)); }; const confirmPhoto = async () => { if (!capturedImage || !canvasRef.current) return; const res = await fetch(capturedImage); const blob = await res.blob(); const file = new File([blob], `skate-spot-${Date.now()}.jpg`, { type: 'image/jpeg' }); onCapture(file); }; const handleClose = () => { stopCamera(); setCapturedImage(null); onOpenChange(false); }; // ── Native in-progress screen ───────────────────────────────────────────── // While native camera is active this dialog acts as a thin loading screen. if (isCapturingNative) { return ( Opening Camera

Opening camera…

Use your phone camera, then return here

); } // ── Browser camera dialog ───────────────────────────────────────────────── return ( Take a Photo
{/* Loading */} {isStarting && (

Starting camera...

)} {/* No permission */} {hasPermission === false && (

Camera Access Required

Allow camera access in your browser settings, or use the file picker below.

)} {/* Video feed */} {!capturedImage && (
); }