leLab / src /components /recording /CameraConfiguration.tsx
Nicolas Rabault
Add working camera
42d8fb8
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<CameraConfigurationProps> = ({
cameras,
onCamerasChange,
releaseStreamsRef,
}) => {
const { baseUrl, fetchWithHeaders } = useApi();
const { toast } = useToast();
const [availableCameras, setAvailableCameras] = useState<AvailableCamera[]>(
[]
);
const [selectedCameraIndex, setSelectedCameraIndex] = useState<string>("");
const [cameraName, setCameraName] = useState("");
const [isLoadingCameras, setIsLoadingCameras] = useState(false);
const [cameraStreams, setCameraStreams] = useState<Map<string, MediaStream>>(
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<CameraConfig>) => {
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 (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white border-b border-gray-700 pb-2">
Camera Configuration
</h3>
{/* Add Camera Section */}
<div className="bg-gray-800/50 rounded-lg p-4 space-y-4">
<h4 className="text-md font-medium text-gray-300">Add Camera</h4>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-300">
Available Cameras
</Label>
<Select
value={selectedCameraIndex}
onValueChange={setSelectedCameraIndex}
disabled={isLoadingCameras}
>
<SelectTrigger className="bg-gray-800 border-gray-700 text-white">
<SelectValue
placeholder={
isLoadingCameras ? "Loading cameras..." : "Select camera"
}
/>
</SelectTrigger>
<SelectContent className="bg-gray-800 border-gray-700">
{availableCameras.map((camera) => (
<SelectItem
key={camera.index}
value={camera.index.toString()}
className="text-white hover:bg-gray-700"
disabled={
!camera.available ||
cameras.some((cam) => cam.camera_index === camera.index)
}
>
{camera.name} (Index {camera.index})
{cameras.some((cam) => cam.camera_index === camera.index) &&
" (Already added)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-300">
Camera Name
</Label>
<Input
value={cameraName}
onChange={(e) => setCameraName(e.target.value)}
placeholder="e.g., workspace_cam"
className="bg-gray-800 border-gray-700 text-white"
/>
</div>
<div className="space-y-2 flex flex-col justify-end">
<Button
onClick={addCamera}
className="bg-blue-500 hover:bg-blue-600 text-white"
disabled={!selectedCameraIndex || !cameraName.trim()}
>
<Plus className="w-4 h-4 mr-2" />
Add Camera
</Button>
</div>
</div>
</div>
{/* Configured Cameras */}
{cameras.length > 0 && (
<div className="space-y-4">
<h4 className="text-md font-medium text-gray-300">
Configured Cameras ({cameras.length})
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 gap-4">
{cameras.map((camera) => (
<CameraPreview
key={camera.id}
camera={camera}
stream={cameraStreams.get(camera.id)}
onRemove={() => removeCamera(camera.id)}
onUpdate={(updates) => updateCamera(camera.id, updates)}
onStartPreview={() => startCameraPreview(camera)}
/>
))}
</div>
</div>
)}
{cameras.length === 0 && (
<div className="text-center py-8 text-gray-500">
<Camera className="w-12 h-12 mx-auto mb-4 text-gray-600" />
<p>No cameras configured. Add a camera to get started.</p>
</div>
)}
</div>
);
};
interface CameraPreviewProps {
camera: CameraConfig;
stream?: MediaStream;
onRemove: () => void;
onUpdate: (updates: Partial<CameraConfig>) => void;
onStartPreview: () => void;
}
const CameraPreview: React.FC<CameraPreviewProps> = ({
camera,
stream,
onRemove,
onUpdate,
onStartPreview,
}) => {
const videoRef = useRef<HTMLVideoElement>(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 (
<div className="bg-gray-900 rounded-lg border border-gray-700 overflow-hidden">
{/* Camera Preview */}
<div className="aspect-[4/3] bg-gray-800 relative">
{/* Always show the video element if we have a stream, regardless of isPreviewActive */}
{stream ? (
<>
<video
ref={videoRef}
autoPlay
muted
playsInline
className="w-full h-full object-cover"
onLoadedMetadata={() =>
console.log("Video metadata loaded for:", camera.name)
}
onPlay={() =>
console.log("Video started playing for:", camera.name)
}
onError={(e) => console.error("Video error for:", camera.name, e)}
onCanPlay={() => console.log("Video can play for:", camera.name)}
/>
<div className="absolute top-2 left-2">
<div className="flex items-center gap-1 bg-black/50 px-2 py-1 rounded text-xs">
<div className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-green-400">
{isPreviewActive ? "LIVE" : "LOADING"}
</span>
</div>
</div>
</>
) : (
<div className="w-full h-full flex flex-col items-center justify-center">
<VideoOff className="w-8 h-8 text-gray-500 mb-2" />
<span className="text-gray-500 text-sm">Preview not available</span>
<Button
onClick={onStartPreview}
size="sm"
className="mt-2 bg-blue-500 hover:bg-blue-600"
>
<Video className="w-3 h-3 mr-1" />
Start Preview
</Button>
</div>
)}
</div>
{/* Camera Info */}
<div className="p-3 space-y-2">
<div className="flex items-center justify-between">
<h5 className="font-medium text-white truncate">{camera.name}</h5>
<Button
onClick={onRemove}
size="sm"
variant="ghost"
className="text-red-400 hover:text-red-300 hover:bg-red-900/20 p-1"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-1 gap-2 text-xs text-gray-400">
<div className="flex items-center gap-2">
<span className="w-16">Resolution:</span>
<div className="flex items-center gap-1">
<Input
type="number"
value={camera.width}
onChange={(e) =>
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"
/>
<span className="flex items-center">×</span>
<Input
type="number"
value={camera.height}
onChange={(e) =>
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"
/>
</div>
</div>
<div className="flex items-center gap-2">
<span className="w-16">FPS:</span>
<Input
type="number"
value={camera.fps || 30}
onChange={(e) =>
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"
/>
</div>
</div>
<div className="text-xs text-gray-500">
Type: {camera.type} | Device: {camera.device_id?.substring(0, 10)}...
</div>
</div>
</div>
);
};
export default CameraConfiguration;