416 lines
15 KiB
TypeScript
416 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 { useTranslation } from '@/contexts/language';
|
|
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 { t } = useTranslation();
|
|
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: t('miscUi.cameraErrorTitle'),
|
|
description: t('miscUi.cameraErrorDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsCapturingNative(false);
|
|
}
|
|
}, [onCapture, onOpenChange, toast, t]);
|
|
|
|
// ── 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: t('miscUi.cameraDeniedTitle'),
|
|
description: t('miscUi.cameraDeniedDesc'),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsStarting(false);
|
|
}
|
|
}, [facingMode, toast, t]);
|
|
|
|
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">{t('miscUi.openingCamera')}</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">{t('miscUi.openingCamera')}</p>
|
|
<p className="text-gray-400 text-sm text-center">{t('miscUi.usePhoneCamera')}</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">{t('miscUi.startingCamera')}</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">{t('miscUi.cameraAccessRequired')}</h3>
|
|
<p className="text-gray-400 text-sm">
|
|
{t('miscUi.cameraAccessHint')}
|
|
</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" />
|
|
{t('common.tryAgain')}
|
|
</Button>
|
|
<label className="cursor-pointer">
|
|
<Button variant="outline" className="gap-2 w-full border-gray-700" asChild>
|
|
<span>
|
|
<Upload className="h-4 w-4" />
|
|
{t('miscUi.uploadFromGallery')}
|
|
</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={t('legacyMiscUi.altCaptured')} 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" />{t('miscUi.uploading')}</>
|
|
) : (
|
|
<><Check className="h-4 w-4" />{t('miscUi.usePhoto')}</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|