357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||
|
|
import { Camera, SwitchCamera, Upload, AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
|
||
|
|
import { BoothConfig } from "@/types/booth";
|
||
|
|
import { useI18n } from "@/lib/i18n";
|
||
|
|
import { MascotGuide } from "@/components/MascotGuide";
|
||
|
|
|
||
|
|
interface CameraScreenProps {
|
||
|
|
config: BoothConfig;
|
||
|
|
onPhotoCaptured: (imageBase64: string) => void;
|
||
|
|
onBack: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const CameraScreen: React.FC<CameraScreenProps> = ({
|
||
|
|
config,
|
||
|
|
onPhotoCaptured,
|
||
|
|
onBack,
|
||
|
|
}) => {
|
||
|
|
const { t, isWeChat } = useI18n();
|
||
|
|
const [facingMode, setFacingMode] = useState<"user" | "environment">(
|
||
|
|
config.camera.defaultFacingMode || "user"
|
||
|
|
);
|
||
|
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
||
|
|
const [cameraError, setCameraError] = useState<string | null>(null);
|
||
|
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||
|
|
const [isFlashing, setIsFlashing] = useState(false);
|
||
|
|
const [hasMultipleCameras, setHasMultipleCameras] = useState<boolean>(false);
|
||
|
|
const [isSwitchingCamera, setIsSwitchingCamera] = useState<boolean>(false);
|
||
|
|
|
||
|
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||
|
|
const streamRef = useRef<MediaStream | null>(null);
|
||
|
|
|
||
|
|
const stopStream = useCallback(() => {
|
||
|
|
if (streamRef.current) {
|
||
|
|
streamRef.current.getTracks().forEach((track) => {
|
||
|
|
try {
|
||
|
|
track.stop();
|
||
|
|
} catch (e) {
|
||
|
|
console.warn("Error stopping track:", e);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
streamRef.current = null;
|
||
|
|
}
|
||
|
|
if (videoRef.current) {
|
||
|
|
videoRef.current.srcObject = null;
|
||
|
|
}
|
||
|
|
setStream(null);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const checkCameraDevices = useCallback(async () => {
|
||
|
|
if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||
|
|
const videoInputs = devices.filter((device) => device.kind === "videoinput");
|
||
|
|
setHasMultipleCameras(videoInputs.length > 1);
|
||
|
|
} catch (err) {
|
||
|
|
console.warn("Could not enumerate camera devices:", err);
|
||
|
|
}
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (typeof navigator !== "undefined" && navigator.mediaDevices?.addEventListener) {
|
||
|
|
const handleDeviceChange = () => {
|
||
|
|
checkCameraDevices();
|
||
|
|
};
|
||
|
|
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||
|
|
return () => {
|
||
|
|
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}, [checkCameraDevices]);
|
||
|
|
|
||
|
|
const startCamera = useCallback(async (targetFacingMode: "user" | "environment") => {
|
||
|
|
try {
|
||
|
|
setCameraError(null);
|
||
|
|
stopStream();
|
||
|
|
|
||
|
|
if (
|
||
|
|
typeof window !== "undefined" &&
|
||
|
|
!window.isSecureContext &&
|
||
|
|
window.location.hostname !== "localhost" &&
|
||
|
|
window.location.hostname !== "127.0.0.1"
|
||
|
|
) {
|
||
|
|
setCameraError(
|
||
|
|
t({
|
||
|
|
en: "Camera access requires HTTPS or localhost. Please take a photo with your device.",
|
||
|
|
zh: "摄像头需要安全连接(HTTPS)。请直接使用手机拍摄或上传照片。"
|
||
|
|
})
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
|
||
|
|
setCameraError(
|
||
|
|
t({
|
||
|
|
en: "Live camera is not supported. Please click Upload to take a photo.",
|
||
|
|
zh: "当前环境不支持实时取景,请点击右下角按钮直接拍照或上传。"
|
||
|
|
})
|
||
|
|
);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
|
|
|
||
|
|
let newStream: MediaStream | null = null;
|
||
|
|
try {
|
||
|
|
const primaryConstraints: MediaStreamConstraints = {
|
||
|
|
audio: false,
|
||
|
|
video: {
|
||
|
|
facingMode: { ideal: targetFacingMode },
|
||
|
|
width: { ideal: 1920 },
|
||
|
|
height: { ideal: 1080 },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
newStream = await navigator.mediaDevices.getUserMedia(primaryConstraints);
|
||
|
|
} catch (tier1Error: any) {
|
||
|
|
try {
|
||
|
|
const secondaryConstraints: MediaStreamConstraints = {
|
||
|
|
audio: false,
|
||
|
|
video: {
|
||
|
|
facingMode: { ideal: targetFacingMode },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
newStream = await navigator.mediaDevices.getUserMedia(secondaryConstraints);
|
||
|
|
} catch (tier2Error: any) {
|
||
|
|
newStream = await navigator.mediaDevices.getUserMedia({
|
||
|
|
audio: false,
|
||
|
|
video: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!newStream) {
|
||
|
|
throw new Error("Unable to acquire media stream.");
|
||
|
|
}
|
||
|
|
|
||
|
|
streamRef.current = newStream;
|
||
|
|
setStream(newStream);
|
||
|
|
|
||
|
|
if (videoRef.current) {
|
||
|
|
const video = videoRef.current;
|
||
|
|
video.srcObject = newStream;
|
||
|
|
video.setAttribute("playsinline", "true");
|
||
|
|
video.setAttribute("webkit-playsinline", "true");
|
||
|
|
video.onloadedmetadata = () => {
|
||
|
|
video.play().catch((playErr) => {
|
||
|
|
console.log("[Camera] Video play deferred:", playErr);
|
||
|
|
});
|
||
|
|
};
|
||
|
|
}
|
||
|
|
checkCameraDevices();
|
||
|
|
} catch (err: any) {
|
||
|
|
console.error("Camera access error:", err);
|
||
|
|
let errorMsg = t({
|
||
|
|
en: "Camera access was denied or not found. Please click Upload to take/choose a photo.",
|
||
|
|
zh: "无法访问摄像头。请点击下方上传按钮直接拍照或从相册选择。"
|
||
|
|
});
|
||
|
|
setCameraError(errorMsg);
|
||
|
|
}
|
||
|
|
}, [stopStream, checkCameraDevices, t]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
startCamera(facingMode);
|
||
|
|
return () => {
|
||
|
|
stopStream();
|
||
|
|
};
|
||
|
|
}, [facingMode, startCamera, stopStream]);
|
||
|
|
|
||
|
|
const toggleCameraFacingMode = async () => {
|
||
|
|
if (isSwitchingCamera) return;
|
||
|
|
setIsSwitchingCamera(true);
|
||
|
|
const nextMode = facingMode === "user" ? "environment" : "user";
|
||
|
|
setFacingMode(nextMode);
|
||
|
|
setTimeout(() => {
|
||
|
|
setIsSwitchingCamera(false);
|
||
|
|
}, 400);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleCapture = () => {
|
||
|
|
if (!videoRef.current) return;
|
||
|
|
const video = videoRef.current;
|
||
|
|
const videoWidth = video.videoWidth || 1080;
|
||
|
|
const videoHeight = video.videoHeight || 1920;
|
||
|
|
|
||
|
|
const canvas = document.createElement("canvas");
|
||
|
|
canvas.width = videoWidth;
|
||
|
|
canvas.height = videoHeight;
|
||
|
|
const ctx = canvas.getContext("2d");
|
||
|
|
if (!ctx) return;
|
||
|
|
|
||
|
|
if (facingMode === "user") {
|
||
|
|
ctx.translate(canvas.width, 0);
|
||
|
|
ctx.scale(-1, 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||
|
|
const photoBase64 = canvas.toDataURL("image/jpeg", 0.92);
|
||
|
|
|
||
|
|
setIsFlashing(true);
|
||
|
|
setTimeout(() => {
|
||
|
|
setIsFlashing(false);
|
||
|
|
stopStream();
|
||
|
|
onPhotoCaptured(photoBase64);
|
||
|
|
}, 200);
|
||
|
|
};
|
||
|
|
|
||
|
|
const startCountdown = () => {
|
||
|
|
if (countdown !== null) return;
|
||
|
|
const seconds = config.camera.countdownSeconds || 3;
|
||
|
|
setCountdown(seconds);
|
||
|
|
|
||
|
|
let remaining = seconds;
|
||
|
|
const interval = setInterval(() => {
|
||
|
|
remaining -= 1;
|
||
|
|
if (remaining > 0) {
|
||
|
|
setCountdown(remaining);
|
||
|
|
} else {
|
||
|
|
clearInterval(interval);
|
||
|
|
setCountdown(null);
|
||
|
|
handleCapture();
|
||
|
|
}
|
||
|
|
}, 1000);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
|
|
const file = e.target.files?.[0];
|
||
|
|
if (!file) return;
|
||
|
|
|
||
|
|
const reader = new FileReader();
|
||
|
|
reader.onload = () => {
|
||
|
|
const result = reader.result as string;
|
||
|
|
stopStream();
|
||
|
|
onPhotoCaptured(result);
|
||
|
|
};
|
||
|
|
reader.readAsDataURL(file);
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="relative flex-1 flex flex-col items-center justify-between p-2 sm:p-4 overflow-hidden select-none">
|
||
|
|
{/* Top Header */}
|
||
|
|
<div className="w-full flex items-center justify-between px-2 mb-1 z-30 min-h-[40px]">
|
||
|
|
<button
|
||
|
|
onClick={() => {
|
||
|
|
stopStream();
|
||
|
|
onBack();
|
||
|
|
}}
|
||
|
|
className="p-2 px-3 rounded-2xl btn-cartoon-dark text-white flex items-center space-x-1 font-black text-xs cursor-pointer shadow-md"
|
||
|
|
>
|
||
|
|
<ArrowLeft size={16} className="stroke-[3]" />
|
||
|
|
<span>{t(config.labels.backButton)}</span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{hasMultipleCameras ? (
|
||
|
|
<button
|
||
|
|
onClick={toggleCameraFacingMode}
|
||
|
|
disabled={isSwitchingCamera || countdown !== null}
|
||
|
|
className="p-2.5 rounded-2xl btn-cartoon-dark text-amber-300 hover:text-white transition active:scale-95 shadow-md"
|
||
|
|
title="Switch front/back camera"
|
||
|
|
>
|
||
|
|
<SwitchCamera size={18} className={`stroke-[2.5] ${isSwitchingCamera ? "animate-spin" : ""}`} />
|
||
|
|
</button>
|
||
|
|
) : (
|
||
|
|
<div className="w-10" />
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Video Viewport - Made bigger, flex-1 with Mascot overlaying */}
|
||
|
|
<div className="relative w-full max-w-md flex-1 min-h-0 rounded-3xl overflow-hidden bg-black border-4 border-black shadow-[0_8px_0_#090314,0_0_25px_rgba(255,109,0,0.3)] flex items-center justify-center my-1">
|
||
|
|
{cameraError ? (
|
||
|
|
<div className="p-6 text-center text-red-200 flex flex-col items-center justify-center space-y-4">
|
||
|
|
<AlertCircle size={44} className="text-amber-400 stroke-[2.5]" />
|
||
|
|
<p className="text-xs sm:text-sm font-bold leading-relaxed">{cameraError}</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<video
|
||
|
|
ref={videoRef}
|
||
|
|
autoPlay
|
||
|
|
playsInline
|
||
|
|
muted
|
||
|
|
className={`w-full h-full object-cover ${facingMode === "user" ? "scale-x-[-1]" : ""}`}
|
||
|
|
/>
|
||
|
|
{isFlashing && <div className="absolute inset-0 bg-white z-50 animate-flash" />}
|
||
|
|
{countdown !== null && (
|
||
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-[2px] z-40">
|
||
|
|
<span className="text-8xl sm:text-9xl font-black text-amber-400 animate-ping drop-shadow-[0_8px_0_#090314]">
|
||
|
|
{countdown}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{/* Martial Framing Guides */}
|
||
|
|
<div className="absolute inset-x-6 top-6 bottom-20 pointer-events-none border border-dashed border-amber-400/30 rounded-2xl flex flex-col justify-start items-center py-3">
|
||
|
|
<span className="text-[10px] font-black tracking-widest text-amber-300/60 uppercase">
|
||
|
|
⚡ {t({ en: "HEAD & MARTIAL POSE", zh: "头部与武道姿势区域" })} ⚡
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Mascot Character - Overlaying bottom-left of the picture frame */}
|
||
|
|
<div className="absolute -bottom-4 -left-3 sm:-bottom-2 sm:-left-2 z-30 pointer-events-none">
|
||
|
|
<MascotGuide
|
||
|
|
pose="kick"
|
||
|
|
size="lg"
|
||
|
|
speechPosition="right"
|
||
|
|
speechText={{
|
||
|
|
en: "Strike your pose! Punch, kick, or victory stance!",
|
||
|
|
zh: "摆出你的武道英姿!冲拳、高踢或胜利架势!",
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Bottom Action Bar */}
|
||
|
|
<div className="w-full max-w-sm flex items-center justify-between px-6 py-2 z-20 min-h-[76px]">
|
||
|
|
{/* Upload / Device Photo Button */}
|
||
|
|
{config.camera.allowUpload ? (
|
||
|
|
<div>
|
||
|
|
<input
|
||
|
|
ref={fileInputRef}
|
||
|
|
type="file"
|
||
|
|
accept="image/*"
|
||
|
|
className="hidden"
|
||
|
|
onChange={handleFileUpload}
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
onClick={() => fileInputRef.current?.click()}
|
||
|
|
className="p-3.5 rounded-2xl btn-cartoon-dark text-amber-300 transition active:scale-95 shadow-md flex items-center justify-center"
|
||
|
|
title={t(config.labels.cameraUpload)}
|
||
|
|
>
|
||
|
|
<Upload size={20} className="stroke-[2.5]" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="w-12" />
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Shutter Button */}
|
||
|
|
<button
|
||
|
|
onClick={startCountdown}
|
||
|
|
disabled={Boolean(cameraError) || countdown !== null || !stream}
|
||
|
|
className="w-18 h-18 sm:w-20 sm:h-20 rounded-full btn-cartoon-orange flex items-center justify-center disabled:opacity-50 active:scale-95 shadow-lg"
|
||
|
|
title={t(config.labels.cameraSnap)}
|
||
|
|
>
|
||
|
|
<Camera size={32} className="text-white drop-shadow-[0_2px_0_#9a3412]" />
|
||
|
|
</button>
|
||
|
|
|
||
|
|
{/* Balance spacer */}
|
||
|
|
<div className="w-12" />
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|