SkateSpots/src/components/CameraCapture.tsx
shakespeare.diy 939a101b38 Fix Quick Snap opening gallery instead of camera on native (v1.0.70)
Root cause: quickCapture.ts relied on window.Capacitor.Plugins.Camera,
which is only populated once something imports '@capacitor/camera'. Since
we deliberately never import that package (to avoid the old white-screen
crash), the plugin was never registered, the native check failed, and Quick
Snap fell through to the web file input — opening the gallery.

Fix: use Capacitor's official registerPlugin('Camera') from the bundled
@capacitor/core. This returns a proxy that dispatches to the native Camera
implementation over the bridge without needing the JS @capacitor/camera
package to self-register, and without putting a bare '@capacitor/camera'
specifier in the bundle. Applied the same pattern to CameraCapture.tsx.

Also: on a permission error, show a clear "enable camera access" toast
instead of silently opening the gallery.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 19:47:36 +07:00

414 lines
15 KiB
TypeScript

/**
* 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<CapacitorCameraPlugin>('Camera');
}
return cameraPlugin;
}
async function captureNative(): Promise<File | null> {
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<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [capturedImage, setCapturedImage] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[340px] bg-black border-[#ff0040]/50">
<DialogTitle className="sr-only">Opening Camera</DialogTitle>
<div className="flex flex-col items-center justify-center py-12 gap-4">
<div className="w-16 h-16 rounded-full bg-[#ff0040]/20 flex items-center justify-center">
<Camera className="h-8 w-8 text-[#ff0040]" />
</div>
<p className="text-white font-bold text-center">Opening camera…</p>
<p className="text-gray-400 text-sm text-center">Use your phone camera, then return here</p>
</div>
</DialogContent>
</Dialog>
);
}
// ── Browser camera dialog ─────────────────────────────────────────────────
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px] p-0 overflow-hidden bg-black border-[#ff0040]/50">
<DialogHeader className="p-4 border-b border-gray-800">
<DialogTitle className="flex items-center gap-2 text-white">
<Camera className="h-5 w-5 text-[#ff0040]" />
Take a Photo
</DialogTitle>
</DialogHeader>
<div className="relative aspect-[4/3] bg-black">
<canvas ref={canvasRef} className="hidden" />
{/* Loading */}
{isStarting && (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-[#ccff00] mx-auto mb-2" />
<p className="text-gray-400 text-sm">Starting camera...</p>
</div>
</div>
)}
{/* No permission */}
{hasPermission === false && (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="text-center p-6 space-y-4">
<Camera className="h-12 w-12 text-gray-600 mx-auto" />
<div>
<h3 className="text-white font-bold mb-2">Camera Access Required</h3>
<p className="text-gray-400 text-sm">
Allow camera access in your browser settings, or use the file picker below.
</p>
</div>
<div className="flex flex-col gap-2">
<Button onClick={() => startCamera()} className="bg-[#ff0040] hover:bg-[#ff0040]/90 gap-2">
<Camera className="h-4 w-4" />
Try Again
</Button>
<label className="cursor-pointer">
<Button variant="outline" className="gap-2 w-full border-gray-700" asChild>
<span>
<Upload className="h-4 w-4" />
Upload from Gallery
</span>
</Button>
<input
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) { onCapture(file); handleClose(); }
}}
/>
</label>
</div>
</div>
</div>
)}
{/* Video feed */}
{!capturedImage && (
<video
ref={videoRef}
autoPlay
playsInline
muted
className={cn(
'w-full h-full object-cover',
(isStarting || hasPermission === false) && 'hidden'
)}
/>
)}
{/* Captured preview */}
{capturedImage && (
<img src={capturedImage} alt="Captured" className="w-full h-full object-cover" />
)}
{/* Switch camera button */}
{!capturedImage && hasPermission && !isStarting && (
<Button
variant="ghost"
size="icon"
onClick={switchCamera}
className="absolute top-2 right-2 bg-black/50 hover:bg-black/70 text-white"
>
<SwitchCamera className="h-5 w-5" />
</Button>
)}
{/* Grid lines */}
{!capturedImage && hasPermission && !isStarting && (
<div className="absolute inset-0 pointer-events-none">
<div className="absolute inset-0 grid grid-cols-3 grid-rows-3">
<div className="border-r border-white/20" />
<div className="border-r border-white/20" />
<div />
<div className="border-r border-t border-white/20" />
<div className="border-r border-t border-white/20" />
<div className="border-t border-white/20" />
<div className="border-r border-t border-white/20" />
<div className="border-r border-t border-white/20" />
<div className="border-t border-white/20" />
</div>
</div>
)}
</div>
{/* Controls */}
<div className="p-4 bg-black border-t border-gray-800">
{!capturedImage ? (
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
onClick={handleClose}
className="border-gray-700 text-gray-400 hover:bg-gray-900"
>
Cancel
</Button>
<Button
onClick={capturePhoto}
disabled={!hasPermission || isStarting}
className="bg-[#ff0040] hover:bg-[#ff0040]/90 text-white px-8 h-12 rounded-full"
>
<Camera className="h-5 w-5" />
</Button>
</div>
) : (
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
onClick={() => setCapturedImage(null)}
disabled={isUploading}
className="gap-2 border-gray-700 text-gray-400 hover:bg-gray-900"
>
<RotateCcw className="h-4 w-4" />
Retake
</Button>
<Button
onClick={confirmPhoto}
disabled={isUploading}
className="gap-2 bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold"
>
{isUploading ? (
<><Loader2 className="h-4 w-4 animate-spin" />Uploading...</>
) : (
<><Check className="h-4 w-4" />Use Photo</>
)}
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}