import React, { useState, useEffect, useRef, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Input } from "@/components/ui/input"; import { Camera, Plus, X, Video, VideoOff } from "lucide-react"; import { useApi } from "@/contexts/ApiContext"; import { useToast } from "@/hooks/use-toast"; export interface CameraConfig { id: string; name: string; type: string; camera_index?: number; // Keep for backend compatibility device_id: string; // Use this for actual camera selection width: number; height: number; fps?: number; } interface CameraConfigurationProps { cameras: CameraConfig[]; onCamerasChange: (cameras: CameraConfig[]) => void; releaseStreamsRef?: React.MutableRefObject<(() => void) | null>; // Ref to expose stream release function } interface AvailableCamera { index: number; deviceId: string; name: string; available: boolean; } const CameraConfiguration: React.FC = ({ cameras, onCamerasChange, releaseStreamsRef, }) => { const { baseUrl, fetchWithHeaders } = useApi(); const { toast } = useToast(); const [availableCameras, setAvailableCameras] = useState( [] ); const [selectedCameraIndex, setSelectedCameraIndex] = useState(""); const [cameraName, setCameraName] = useState(""); const [isLoadingCameras, setIsLoadingCameras] = useState(false); const [cameraStreams, setCameraStreams] = useState>( new Map() ); // Fetch available cameras on component mount useEffect(() => { fetchAvailableCameras(); }, []); const fetchAvailableCameras = async () => { console.log("🚀 fetchAvailableCameras() called"); setIsLoadingCameras(true); try { console.log( "📡 Trying backend endpoint:", `${baseUrl}/available-cameras` ); const response = await fetchWithHeaders(`${baseUrl}/available-cameras`); console.log("📡 Backend response status:", response.status, response.ok); if (response.ok) { const data = await response.json(); console.log("📡 Backend camera data received:", data); setAvailableCameras(data.cameras || []); // Always also try browser detection to get device IDs console.log("🔄 Also running browser detection for device IDs..."); await detectBrowserCameras(); } else { console.log("📡 Backend failed, falling back to browser detection"); // Fallback to browser camera detection await detectBrowserCameras(); } } catch (error) { console.error("📡 Error fetching cameras from backend:", error); console.log("🔄 Falling back to browser detection due to error"); // Fallback to browser camera detection await detectBrowserCameras(); } finally { setIsLoadingCameras(false); console.log("✅ fetchAvailableCameras() completed"); } }; const detectBrowserCameras = async () => { try { // First, request camera permissions to get proper device IDs and labels console.log("🔐 Requesting camera permissions for device detection..."); try { const tempStream = await navigator.mediaDevices.getUserMedia({ video: true, }); console.log("✅ Camera permission granted, stopping temp stream"); tempStream.getTracks().forEach((track) => track.stop()); } catch (permError) { console.warn( "⚠️ Camera permission denied, device IDs may be empty:", permError ); } const devices = await navigator.mediaDevices.enumerateDevices(); const videoDevices = devices.filter( (device) => device.kind === "videoinput" ); console.log( "🔍 Raw video devices from enumerateDevices:", videoDevices.map((d) => ({ deviceId: d.deviceId, label: d.label, kind: d.kind, })) ); const detectedCameras = videoDevices.map((device, index) => ({ index, deviceId: device.deviceId || `fallback_${index}`, // Fallback if deviceId is empty name: device.label || `Camera ${index + 1}`, available: true, })); console.log("🎬 Browser cameras with indices mapped:", detectedCameras); setAvailableCameras(detectedCameras); } catch (error) { console.error("Error detecting browser cameras:", error); toast({ title: "Camera Detection Failed", description: "Could not detect available cameras. Please check permissions.", variant: "destructive", }); } }; const startCameraPreview = async (cameraConfig: CameraConfig) => { try { console.log( "🎥 Starting camera preview for:", cameraConfig.name, "with device_id:", cameraConfig.device_id, "camera_index:", cameraConfig.camera_index ); // Create constraints with fallbacks to avoid OverconstrainedError const constraints: MediaStreamConstraints = { video: { width: { ideal: cameraConfig.width, min: 320, max: 1920 }, height: { ideal: cameraConfig.height, min: 240, max: 1080 }, frameRate: { ideal: cameraConfig.fps || 30, min: 10, max: 60 }, }, }; // Only add deviceId if it's not a fallback if ( cameraConfig.device_id && !cameraConfig.device_id.startsWith("fallback_") ) { (constraints.video as MediaTrackConstraints).deviceId = { exact: cameraConfig.device_id, // Changed from 'ideal' to 'exact' }; console.log( "🔧 Using EXACT deviceId constraint:", cameraConfig.device_id ); } else { console.log("⚠️ No valid deviceId, will use default camera"); } console.log( "📋 Final constraints:", JSON.stringify(constraints, null, 2) ); const stream = await navigator.mediaDevices.getUserMedia(constraints); // Get the actual device being used const videoTrack = stream.getVideoTracks()[0]; if (videoTrack) { const settings = videoTrack.getSettings(); console.log("✅ Actual camera settings:", { deviceId: settings.deviceId, label: videoTrack.label, width: settings.width, height: settings.height, }); // Check if we got the camera we requested if ( cameraConfig.device_id && settings.deviceId !== cameraConfig.device_id ) { console.warn( "⚠️ CAMERA MISMATCH! Requested:", cameraConfig.device_id, "Got:", settings.deviceId ); } else { console.log("✅ Camera match confirmed!"); } } console.log( "Camera stream created successfully for:", cameraConfig.name, { streamId: stream.id, tracks: stream.getTracks().length, videoTracks: stream.getVideoTracks().length, active: stream.active, } ); setCameraStreams((prev) => { const newMap = new Map(prev.set(cameraConfig.id, stream)); console.log("Updated camera streams map:", Array.from(newMap.keys())); return newMap; }); // Force a small delay to ensure state update await new Promise((resolve) => setTimeout(resolve, 100)); return stream; } catch (error: unknown) { console.error("Error starting camera preview:", error); const isMediaError = error instanceof Error; const errorName = isMediaError ? error.name : ""; const errorMessage = isMediaError ? error.message : "Unknown error"; // If constraints failed, try with basic constraints if ( errorName === "OverconstrainedError" || errorName === "NotReadableError" ) { try { console.log("Retrying with basic constraints..."); const basicStream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 }, }); setCameraStreams( (prev) => new Map(prev.set(cameraConfig.id, basicStream)) ); toast({ title: "Camera Preview Started", description: `${cameraConfig.name} started with basic settings due to constraint issues.`, }); return basicStream; } catch (basicError) { console.error("Error with basic constraints:", basicError); } } toast({ title: "Camera Preview Failed", description: `Could not start preview for ${cameraConfig.name}: ${errorMessage}`, variant: "destructive", }); return null; } }; const stopCameraPreview = (cameraId: string) => { const stream = cameraStreams.get(cameraId); if (stream) { stream.getTracks().forEach((track) => track.stop()); setCameraStreams((prev) => { const newMap = new Map(prev); newMap.delete(cameraId); return newMap; }); } }; const addCamera = async () => { if (!selectedCameraIndex || !cameraName.trim()) { toast({ title: "Missing Information", description: "Please select a camera and provide a name.", variant: "destructive", }); return; } const cameraIndex = parseInt(selectedCameraIndex); const selectedCamera = availableCameras.find( (cam) => cam.index === cameraIndex ); if (!selectedCamera) { toast({ title: "Invalid Camera", description: "Selected camera is not available.", variant: "destructive", }); return; } // Check if camera is already added if (cameras.some((cam) => cam.camera_index === cameraIndex)) { toast({ title: "Camera Already Added", description: "This camera is already in the configuration.", variant: "destructive", }); return; } const newCamera: CameraConfig = { id: `camera_${Date.now()}`, name: cameraName.trim(), type: "opencv", camera_index: selectedCamera.index, device_id: selectedCamera.deviceId, width: 640, height: 480, fps: 30, }; console.log("🆕 Creating new camera config:", { name: newCamera.name, camera_index: newCamera.camera_index, device_id: newCamera.device_id, selectedCamera: selectedCamera, }); const updatedCameras = [...cameras, newCamera]; onCamerasChange(updatedCameras); // Start preview for the new camera await startCameraPreview(newCamera); // Reset form setSelectedCameraIndex(""); setCameraName(""); toast({ title: "Camera Added", description: `${newCamera.name} has been added to the configuration.`, }); }; const removeCamera = (cameraId: string) => { stopCameraPreview(cameraId); const updatedCameras = cameras.filter((cam) => cam.id !== cameraId); onCamerasChange(updatedCameras); toast({ title: "Camera Removed", description: "Camera has been removed from the configuration.", }); }; const updateCamera = (cameraId: string, updates: Partial) => { const updatedCameras = cameras.map((cam) => cam.id === cameraId ? { ...cam, ...updates } : cam ); onCamerasChange(updatedCameras); }; // Function to release all camera streams (for recording start) const releaseAllCameraStreams = useCallback(() => { console.log("🔓 Releasing all camera streams for recording..."); cameraStreams.forEach((stream, cameraId) => { console.log(`🔓 Stopping stream for camera: ${cameraId}`); stream.getTracks().forEach((track) => track.stop()); }); setCameraStreams(new Map()); console.log("✅ All camera streams released"); }, [cameraStreams]); // Expose the release function to parent component via ref useEffect(() => { if (releaseStreamsRef) { releaseStreamsRef.current = releaseAllCameraStreams; } }, [releaseStreamsRef, releaseAllCameraStreams]); // Clean up streams on component unmount useEffect(() => { return () => { cameraStreams.forEach((stream) => { stream.getTracks().forEach((track) => track.stop()); }); }; }, []); return (

Camera Configuration

{/* Add Camera Section */}

Add Camera

setCameraName(e.target.value)} placeholder="e.g., workspace_cam" className="bg-gray-800 border-gray-700 text-white" />
{/* Configured Cameras */} {cameras.length > 0 && (

Configured Cameras ({cameras.length})

{cameras.map((camera) => ( removeCamera(camera.id)} onUpdate={(updates) => updateCamera(camera.id, updates)} onStartPreview={() => startCameraPreview(camera)} /> ))}
)} {cameras.length === 0 && (

No cameras configured. Add a camera to get started.

)}
); }; interface CameraPreviewProps { camera: CameraConfig; stream?: MediaStream; onRemove: () => void; onUpdate: (updates: Partial) => void; onStartPreview: () => void; } const CameraPreview: React.FC = ({ camera, stream, onRemove, onUpdate, onStartPreview, }) => { const videoRef = useRef(null); const [isPreviewActive, setIsPreviewActive] = useState(false); // Debug logging for props console.log("CameraPreview render for:", camera.name, { hasStream: !!stream, streamActive: stream?.active, isPreviewActive, streamId: stream?.id, }); useEffect(() => { const video = videoRef.current; if (video && stream) { console.log("Setting stream to video element for camera:", camera.name); video.srcObject = stream; // Explicitly play the video to ensure it starts const playVideo = async () => { try { await video.play(); console.log("Video playing successfully for camera:", camera.name); setIsPreviewActive(true); } catch (error) { console.error("Error playing video for camera:", camera.name, error); // Try to play without audio in case autoplay is blocked video.muted = true; try { await video.play(); console.log("Video playing muted for camera:", camera.name); setIsPreviewActive(true); } catch (mutedError) { console.error("Error playing muted video:", mutedError); setIsPreviewActive(false); } } }; // Wait for metadata to load before playing if (video.readyState >= 1) { playVideo(); } else { video.addEventListener("loadedmetadata", playVideo, { once: true }); } } else { console.log("No stream or video element for camera:", camera.name); setIsPreviewActive(false); } }, [stream, camera.name]); useEffect(() => { // Auto-start preview when camera is added if (!stream && !isPreviewActive) { console.log("Auto-starting preview for camera:", camera.name); onStartPreview(); } }, [stream, isPreviewActive, onStartPreview, camera.name]); return (
{/* Camera Preview */}
{/* Always show the video element if we have a stream, regardless of isPreviewActive */} {stream ? ( <>
{/* Camera Info */}
{camera.name}
Resolution:
onUpdate({ width: parseInt(e.target.value) || 640 }) } className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" min="320" max="1920" /> × onUpdate({ height: parseInt(e.target.value) || 480 }) } className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" min="240" max="1080" />
FPS: onUpdate({ fps: parseInt(e.target.value) || 30 }) } className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" min="10" max="60" />
Type: {camera.type} | Device: {camera.device_id?.substring(0, 10)}...
); }; export default CameraConfiguration;