laring-backend / frontend /src /components /CameraFeed.jsx
Zaid
fix: improve webcam startup UX with immediate raw video preview and resolve pacing deadlock with a timeout fallback
3c92957
Raw
History Blame Contribute Delete
12.4 kB
import React, { useEffect, useRef, useState } from 'react';
import { Camera, CameraOff, RefreshCw, AlertTriangle } from 'lucide-react';
const CameraFeed = ({ onMetadataReceived, wsUrl = "ws://localhost:8000/ws" }) => {
const [isActive, setIsActive] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [fps, setFps] = useState(0);
// Camera devices and pacing
const [devices, setDevices] = useState([]);
const [selectedDeviceId, setSelectedDeviceId] = useState('');
const isProcessingRef = useRef(false);
const videoRef = useRef(null);
const canvasRef = useRef(null);
const wsRef = useRef(null);
const streamRef = useRef(null);
const requestRef = useRef(null);
const lastFrameTimeRef = useRef(0);
const lastFrameSentTimeRef = useRef(0);
const fpsIntervalRef = useRef(1000 / 12);
const frameCountRef = useRef(0);
const fpsTimeRef = useRef(0);
const [processedImgSrc, setProcessedImgSrc] = useState(null);
const getDevices = async () => {
try {
const allDevices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = allDevices.filter(device => device.kind === 'videoinput');
setDevices(videoDevices);
if (videoDevices.length > 0 && !selectedDeviceId) {
setSelectedDeviceId(videoDevices[0].deviceId);
}
} catch (err) {
console.error("Error listing cameras:", err);
}
};
useEffect(() => {
canvasRef.current = document.createElement('canvas');
canvasRef.current.width = 640;
canvasRef.current.height = 480;
getDevices();
return () => {
stopCamera();
};
}, []);
const connectWebSocket = () => {
if (wsRef.current) {
wsRef.current.close();
}
console.log("Connecting to WebSocket: ", wsUrl);
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
setErrorMsg("");
console.log("WebSocket connection established");
};
ws.onmessage = (event) => {
try {
const data = jsonParseSafely(event.data);
if (!data) return;
if (data.type === "frame_data") {
setProcessedImgSrc(data.image);
onMetadataReceived(data);
frameCountRef.current++;
const now = performance.now();
if (now - fpsTimeRef.current >= 1000) {
setFps(frameCountRef.current);
frameCountRef.current = 0;
fpsTimeRef.current = now;
}
// Unlock frame-processing and schedule next frame
isProcessingRef.current = false;
requestRef.current = requestAnimationFrame(processLoop);
} else if (data.type === "game_update") {
onMetadataReceived({ game: data.game });
}
} catch (err) {
console.error("Error reading WebSocket message:", err);
}
};
ws.onclose = () => {
setIsConnected(false);
console.log("WebSocket connection closed");
};
ws.onerror = (err) => {
console.error("WebSocket error:", err);
setErrorMsg("خادم الخلفية (Python Backend) لا يستجيب. تأكد من تشغيل ملف backend/server.py");
};
};
const jsonParseSafely = (str) => {
try {
return JSON.parse(str);
} catch (e) {
return null;
}
};
const startCamera = async () => {
try {
setErrorMsg("");
const constraints = {
video: {
width: 640,
height: 480,
frameRate: { max: 15 },
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined
},
audio: false
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
setIsActive(true);
connectWebSocket();
lastFrameTimeRef.current = performance.now();
fpsTimeRef.current = performance.now();
frameCountRef.current = 0;
isProcessingRef.current = false;
requestRef.current = requestAnimationFrame(processLoop);
// Refresh devices to get labels
setTimeout(getDevices, 500);
} catch (err) {
console.error("Failed to access camera:", err);
setErrorMsg("تم رفض الوصول إلى كاميرا الويب. يرجى السماح بالوصول للكاميرا من إعدادات المتصفح.");
}
};
const stopCamera = () => {
setIsActive(false);
setProcessedImgSrc(null);
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop());
streamRef.current = null;
}
if (videoRef.current) {
videoRef.current.srcObject = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
setFps(0);
isProcessingRef.current = false;
};
const processLoop = () => {
if (!videoRef.current || !canvasRef.current || !wsRef.current) {
return;
}
const now = performance.now();
// If we've been waiting for a response for more than 450ms, force unlock to prevent freeze
if (isProcessingRef.current && (now - lastFrameSentTimeRef.current > 450)) {
isProcessingRef.current = false;
}
if (isProcessingRef.current) {
requestRef.current = requestAnimationFrame(processLoop);
return;
}
const video = videoRef.current;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (video.readyState === video.HAVE_ENOUGH_DATA) {
isProcessingRef.current = true;
lastFrameSentTimeRef.current = performance.now();
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const base64Img = canvas.toDataURL('image/jpeg', 0.6);
if (wsRef.current.readyState === WebSocket.OPEN) {
const payload = {
action: "process_frame",
image: base64Img
};
wsRef.current.send(JSON.stringify(payload));
} else {
isProcessingRef.current = false;
requestRef.current = requestAnimationFrame(processLoop);
}
} else {
requestRef.current = requestAnimationFrame(processLoop);
}
};
const sendGameAction = (action, payload = {}) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ action, ...payload }));
}
};
window.sendGameActionToWs = sendGameAction;
return (
<div className="glass-panel flex-col gap-4 relative overflow-hidden" style={{ minHeight: '400px' }}>
<div className="flex-between mb-2 pb-3" style={{ borderBottom: '1px solid rgba(0, 240, 255, 0.1)' }}>
<h2 className="cyber-font flex-center-gap text-cyan-400" style={{ fontSize: '1.1rem', fontWeight: 800 }}>
<Camera className="w-5 h-5 text-[var(--neon-cyan)] animate-pulse" />
البث المباشر للكاميرا
</h2>
<div className="flex-center-gap">
{isActive && (
<span className="text-xs text-[var(--text-secondary)] cyber-font">
معدل الإطارات: <span className="text-[var(--neon-cyan)] font-bold">{fps}</span>
</span>
)}
<span className="status-badge cyber-font" style={{
backgroundColor: isConnected ? 'rgba(0, 240, 255, 0.1)' : 'rgba(255, 0, 60, 0.1)',
color: isConnected ? 'var(--neon-cyan)' : 'var(--neon-red)',
border: '1px solid',
borderColor: isConnected ? 'rgba(0, 240, 255, 0.2)' : 'rgba(255, 0, 60, 0.2)',
}}>
<span className="w-2 h-2 rounded-full" style={{
backgroundColor: isConnected ? 'var(--neon-cyan)' : 'var(--neon-red)',
animation: isConnected ? 'pulse-cyan 1.5s infinite' : 'none'
}} />
{isConnected ? 'متصل' : 'غير متصل'}
</span>
</div>
</div>
{errorMsg && (
<div className="alert-box mb-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" style={{ marginTop: '2px' }} />
<span>{errorMsg}</span>
</div>
)}
{/* Video stream rendering */}
<div className="video-container" style={{ position: 'relative', width: '100%', height: '360px', backgroundColor: '#000', borderRadius: '8px', overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
<video
ref={videoRef}
style={{ display: (isActive && !processedImgSrc) ? 'block' : 'none', width: '100%', height: '100%', objectFit: 'cover' }}
autoPlay
playsInline
muted
/>
{processedImgSrc ? (
<img
src={processedImgSrc}
alt="AI Processed Stream"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : !isActive ? (
<div className="flex-col flex-center gap-3 text-center p-3" style={{ color: 'var(--text-secondary)' }}>
<CameraOff className="w-12 h-12 stroke-1 opacity-50 mb-2" />
<p style={{ fontSize: '1rem', fontWeight: 600 }}>محرك المعالجة البيومترية غير نشط.</p>
<p style={{ fontSize: '11px', maxWidth: '320px', opacity: 0.7 }}>يرجى تفعيل بث الكاميرا من الزر في الأسفل لتشغيل المعالجة والتحليل الذاتي للغرفة في الوقت الفعلي.</p>
</div>
) : (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', color: '#00f0ff', zIndex: 10 }}>
<div style={{ width: '32px', height: '32px', border: '3px solid rgba(0,240,255,0.2)', borderTopColor: '#00f0ff', borderRadius: '50%', animation: 'spin 1s linear infinite', marginBottom: '8px' }}></div>
<p className="cyber-font" style={{ fontSize: '11px' }}>جاري تحميل معالجة الذكاء الاصطناعي...</p>
</div>
)}
</div>
<div className="flex-col gap-3 mt-2" style={{ alignItems: 'center', width: '100%' }}>
{devices.length > 1 && !isActive && (
<div className="flex gap-2 items-center justify-center mb-2" dir="rtl">
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>اختر الكاميرا المتاحة:</span>
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="bg-black/80 border border-cyan-500/30 text-cyan-400 rounded px-3 py-1 text-xs outline-none cyber-font cursor-pointer"
style={{ maxWidth: '240px' }}
>
{devices.map(device => (
<option key={device.deviceId} value={device.deviceId}>
{device.label || `كاميرا ${device.deviceId.slice(0, 5)}`}
</option>
))}
</select>
</div>
)}
<div className="flex gap-4" style={{ justifyContent: 'center', width: '100%' }}>
{!isActive ? (
<button
onClick={startCamera}
className="cyber-btn"
>
<Camera className="w-4 h-4" />
تشغيل الكاميرا
</button>
) : (
<button
onClick={stopCamera}
className="cyber-btn cyber-btn-magenta"
>
<CameraOff className="w-4 h-4" />
إيقاف الكاميرا
</button>
)}
{isActive && !isConnected && (
<button
onClick={connectWebSocket}
className="cyber-btn"
>
<RefreshCw className="w-4 h-4" />
إعادة الاتصال
</button>
)}
</div>
</div>
</div>
);
};
export default CameraFeed;