import React, { useState, useEffect, 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 { NumberInput } from "@/components/ui/number-input"; import { Camera, Plus, X, VideoOff } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; import { useAvailableCameras } from "@/hooks/useAvailableCameras"; import { useCameraStream } from "@/hooks/useCameraStream"; export interface CameraConfig { id: string; name: string; type: string; camera_index?: number; // cv2 index — what the recorder opens device_id: string; // Browser deviceId matched to the cv2 index by AVFoundation localizedName 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 } const CameraConfiguration: React.FC = ({ cameras, onCamerasChange, releaseStreamsRef, }) => { const { toast } = useToast(); const { cameras: availableCameras, isLoading: isLoadingCameras, } = useAvailableCameras(); const [selectedCameraIndex, setSelectedCameraIndex] = useState(""); const [cameraName, setCameraName] = useState(""); // cv2's AVFoundation order is uniqueID-sorted, so plugging/unplugging a // device between sessions shifts indices. The browser device_id stays // stable per-origin, so use it to refresh each seeded camera's // camera_index — otherwise the recorder opens the wrong physical device // and the dropdown's "already added" check guards a stale index. useEffect(() => { if (availableCameras.length === 0 || cameras.length === 0) return; let changed = false; const refreshed = cameras.map((cam) => { if (!cam.device_id) return cam; const match = availableCameras.find((m) => m.deviceId === cam.device_id); if (match && match.index !== cam.camera_index) { changed = true; return { ...cam, camera_index: match.index }; } return cam; }); if (changed) onCamerasChange(refreshed); // We deliberately don't depend on `cameras`/`onCamerasChange` to avoid // re-running every keystroke in the camera-name input — re-syncing only // when the available-cameras list itself changes is sufficient. // eslint-disable-next-line react-hooks/exhaustive-deps }, [availableCameras]); const addCamera = () => { 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; } // Block duplicates by either cv2 index or browser deviceId — a stale // camera_index in a seeded camera can otherwise let the same physical // device sneak in under a different index. const isDuplicate = cameras.some( (cam) => cam.camera_index === selectedCamera.index || (selectedCamera.deviceId && cam.device_id === selectedCamera.deviceId), ); if (isDuplicate) { 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, }; onCamerasChange([...cameras, newCamera]); setSelectedCameraIndex(""); setCameraName(""); toast({ title: "Camera Added", description: `${newCamera.name} has been added to the configuration.`, }); }; const removeCamera = (cameraId: string) => { onCamerasChange(cameras.filter((cam) => cam.id !== cameraId)); toast({ title: "Camera Removed", description: "Camera has been removed from the configuration.", }); }; const updateCamera = (cameraId: string, updates: Partial) => { onCamerasChange( cameras.map((cam) => cam.id === cameraId ? { ...cam, ...updates } : cam ) ); }; // When the recording session is starting, the parent calls // releaseStreamsRef.current() to make every CameraPreview drop its browser // stream so cv2.VideoCapture can grab the camera exclusively. const [streamsPaused, setStreamsPaused] = useState(false); const releaseAllCameraStreams = useCallback(() => { setStreamsPaused(true); }, []); useEffect(() => { if (releaseStreamsRef) { releaseStreamsRef.current = releaseAllCameraStreams; } }, [releaseStreamsRef, releaseAllCameraStreams]); 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)} /> ))}
)} {cameras.length === 0 && (

No cameras configured. Add a camera to get started.

)}
); }; interface CameraPreviewProps { camera: CameraConfig; paused: boolean; onRemove: () => void; onUpdate: (updates: Partial) => void; } const CameraPreview: React.FC = ({ camera, paused, onRemove, onUpdate, }) => { const { videoRef, hasError: streamError } = useCameraStream( camera.device_id, paused ); const showVideo = !paused && camera.device_id && !streamError; return (
{showVideo ? (
{/* Camera Info */}
{camera.name}
Resolution:
{ if (v !== undefined) onUpdate({ width: v }); }} className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" min="320" max="1920" /> × { if (v !== undefined) onUpdate({ height: v }); }} className="bg-gray-800 border-gray-700 text-white text-xs h-6 px-2 w-16" min="240" max="1080" />
FPS: { if (v !== undefined) onUpdate({ fps: v }); }} 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;