Skip to content

Commit 1ad9ee0

Browse files
committed
Add camera selection to webcam component
Update WebCamComponent to support multiple camera devices, allowing users to select their preferred camera. Updated related API and hook types to use BackendRes and Image[], and improved error handling and device enumeration logic.
1 parent fe2f98d commit 1ad9ee0

3 files changed

Lines changed: 166 additions & 29 deletions

File tree

frontend/src/api/api-functions/face_clusters.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { faceClustersEndpoints } from '../apiEndpoints';
22
import { apiClient } from '../axiosConfig';
33
import { APIResponse } from '@/types/API';
4+
import { BackendRes } from '@/hooks/useQueryExtension';
5+
import type { Image } from '@/types/Media';
46

57
//Request Types
68
export interface RenameClusterRequest {
@@ -57,8 +59,8 @@ export const fetchSearchedFaces = async (
5759

5860
export const fetchSearchedFacesBase64 = async (
5961
request: FetchSearchedFacesBase64Request,
60-
): Promise<APIResponse> => {
61-
const response = await apiClient.post<APIResponse>(
62+
): Promise<BackendRes<Image[]>> => {
63+
const response = await apiClient.post<BackendRes<Image[]>>(
6264
faceClustersEndpoints.searchForFacesBase64,
6365
request,
6466
);

frontend/src/components/WebCam/WebCamComponent.tsx

Lines changed: 161 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { useState, useRef, useCallback } from 'react';
1+
import { useState, useRef, useCallback, useEffect } from 'react';
22
import { useMutationFeedback } from '../../hooks/useMutationFeedback.tsx';
33
import Webcam from 'react-webcam';
4-
import { X, RotateCcw, Search } from 'lucide-react';
4+
import { X, RotateCcw, Search, Camera, ChevronDown } from 'lucide-react';
55
import { Button } from '@/components/ui/button';
66
import {
77
Dialog,
@@ -10,17 +10,21 @@ import {
1010
DialogHeader,
1111
DialogTitle,
1212
} from '@/components/ui/dialog';
13+
import {
14+
DropdownMenu,
15+
DropdownMenuContent,
16+
DropdownMenuItem,
17+
DropdownMenuTrigger,
18+
} from '@/components/ui/dropdown-menu';
1319
import { useDispatch } from 'react-redux';
1420
import { startSearch, clearSearch } from '@/features/searchSlice';
1521
import type { Image } from '@/types/Media';
1622
import { usePictoMutation } from '@/hooks/useQueryExtension';
1723
import { fetchSearchedFacesBase64 } from '@/api/api-functions';
1824
import { showInfoDialog } from '@/features/infoDialogSlice';
1925
import { setImages } from '@/features/imageSlice.ts';
20-
21-
const videoConstraints = {
22-
facingMode: 'user',
23-
};
26+
import { DefaultError } from '@tanstack/react-query';
27+
import { BackendRes } from '@/hooks/useQueryExtension';
2428

2529
interface WebcamComponentProps {
2630
isOpen: boolean;
@@ -30,21 +34,29 @@ interface WebcamComponentProps {
3034
function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
3135
const [showCamera, setShowCamera] = useState(true);
3236
const [capturedImageUrl, setCapturedImageUrl] = useState<string | null>(null);
37+
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
38+
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('');
3339
const webcamRef = useRef<Webcam>(null);
3440
const dispatch = useDispatch();
3541

36-
const getSearchImagesBase64 = usePictoMutation({
42+
const searchByFaceMutation = usePictoMutation<
43+
BackendRes<Image[]>,
44+
DefaultError,
45+
string,
46+
unknown,
47+
Image[]
48+
>({
3749
mutationFn: async (base64_data: string) =>
3850
fetchSearchedFacesBase64({ base64_data }),
3951
});
4052

41-
useMutationFeedback(getSearchImagesBase64, {
53+
useMutationFeedback(searchByFaceMutation, {
4254
showLoading: true,
4355
loadingMessage: 'Searching faces...',
4456
errorTitle: 'Search Error',
4557
errorMessage: 'Failed to search images. Please try again.',
4658
onSuccess: () => {
47-
const result = getSearchImagesBase64.data?.data as Image[];
59+
const result = searchByFaceMutation.data?.data;
4860
if (result && result.length > 0) {
4961
dispatch(setImages(result));
5062
} else {
@@ -59,28 +71,96 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
5971
dispatch(setImages([]));
6072
dispatch(clearSearch());
6173
}
62-
getSearchImagesBase64.reset();
74+
searchByFaceMutation.reset();
6375
},
6476
});
6577

78+
// Enumerate available video devices
79+
useEffect(() => {
80+
if (!isOpen) return;
81+
82+
let isMounted = true;
83+
let currentStream: MediaStream | null = null;
84+
85+
const getDevices = async () => {
86+
try {
87+
currentStream = await navigator.mediaDevices.getUserMedia({
88+
video: true,
89+
});
90+
91+
currentStream.getTracks().forEach((track) => track.stop());
92+
currentStream = null;
93+
94+
const allDevices = await navigator.mediaDevices.enumerateDevices();
95+
const videoDevices = allDevices.filter((d) => d.kind === 'videoinput');
96+
97+
if (!isMounted) return;
98+
99+
setDevices(videoDevices);
100+
101+
const savedDeviceId = localStorage.getItem('preferredCamera');
102+
103+
if (
104+
savedDeviceId &&
105+
videoDevices.some((d) => d.deviceId === savedDeviceId)
106+
) {
107+
setSelectedDeviceId(savedDeviceId);
108+
} else if (videoDevices.length > 0) {
109+
setSelectedDeviceId(videoDevices[0].deviceId);
110+
}
111+
} catch (error) {
112+
if (!isMounted) return;
113+
setDevices([]);
114+
} finally {
115+
if (currentStream) {
116+
currentStream.getTracks().forEach((track) => track.stop());
117+
}
118+
}
119+
};
120+
121+
getDevices();
122+
123+
return () => {
124+
isMounted = false;
125+
};
126+
}, [isOpen]);
127+
const handleCameraChange = (deviceId: string) => {
128+
setSelectedDeviceId(deviceId);
129+
localStorage.setItem('preferredCamera', deviceId);
130+
};
131+
132+
const videoConstraints = {
133+
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
134+
};
135+
66136
const capture = useCallback(() => {
67137
if (webcamRef.current) {
68138
const imageSrc = webcamRef.current.getScreenshot();
69-
setCapturedImageUrl(imageSrc);
70-
setShowCamera(false);
139+
if (imageSrc) {
140+
setCapturedImageUrl(imageSrc);
141+
setShowCamera(false);
142+
} else {
143+
dispatch(
144+
showInfoDialog({
145+
title: 'Capture Failed',
146+
message: 'Could not capture an image. Please try again.',
147+
variant: 'error',
148+
}),
149+
);
150+
}
71151
}
72-
}, [webcamRef]);
152+
}, [webcamRef, dispatch]);
73153

74154
const handleRetake = () => {
75155
setCapturedImageUrl(null);
76156
setShowCamera(true);
77157
};
78158

79159
const handleSearchCapturedImage = () => {
80-
onClose();
81160
if (capturedImageUrl) {
82161
dispatch(startSearch(capturedImageUrl));
83-
getSearchImagesBase64.mutate(capturedImageUrl);
162+
searchByFaceMutation.mutate(capturedImageUrl);
163+
onClose();
84164
} else {
85165
dispatch(
86166
showInfoDialog({
@@ -89,7 +169,6 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
89169
variant: 'error',
90170
}),
91171
);
92-
handleClose();
93172
}
94173
};
95174

@@ -99,6 +178,11 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
99178
onClose();
100179
};
101180

181+
const getSelectedDeviceLabel = () => {
182+
const device = devices.find((d) => d.deviceId === selectedDeviceId);
183+
return device?.label || 'Default Camera';
184+
};
185+
102186
return (
103187
<Dialog
104188
open={isOpen}
@@ -121,16 +205,67 @@ function WebcamComponent({ isOpen, onClose }: WebcamComponentProps) {
121205
<div className="flex flex-col items-center gap-4 py-4">
122206
{showCamera && !capturedImageUrl ? (
123207
<div className="flex flex-col items-center gap-4">
124-
<Webcam
125-
audio={false}
126-
ref={webcamRef}
127-
screenshotFormat="image/jpeg"
128-
videoConstraints={videoConstraints}
129-
className="w-full max-w-md rounded-lg border"
130-
/>
131-
<Button onClick={capture} className="w-40">
132-
Capture Photo
133-
</Button>
208+
{devices.length === 0 ? (
209+
<div className="w-full max-w-md rounded-lg border p-6 text-center">
210+
<p className="font-medium">No camera detected</p>
211+
<p className="mt-2 text-sm text-neutral-600">
212+
Make sure your device has a camera connected and permissions
213+
are granted.
214+
</p>
215+
</div>
216+
) : (
217+
<>
218+
<Webcam
219+
audio={false}
220+
ref={webcamRef}
221+
screenshotFormat="image/jpeg"
222+
videoConstraints={videoConstraints}
223+
className="w-full max-w-md rounded-lg border"
224+
/>
225+
</>
226+
)}
227+
228+
{/* Camera Selection Dropdown */}
229+
<div className="flex w-full max-w-md flex-col items-center gap-4">
230+
<DropdownMenu>
231+
<DropdownMenuTrigger asChild>
232+
<Button
233+
variant="outline"
234+
className="w-full justify-between"
235+
disabled={devices.length <= 1}
236+
>
237+
<div className="flex items-center gap-2">
238+
<Camera className="h-4 w-4" />
239+
<span className="truncate">
240+
{getSelectedDeviceLabel()}
241+
</span>
242+
</div>
243+
<ChevronDown className="h-4 w-4 opacity-50" />
244+
</Button>
245+
</DropdownMenuTrigger>
246+
<DropdownMenuContent className="w-[var(--radix-dropdown-menu-trigger-width)]">
247+
{devices.length > 1 ? (
248+
devices.map((device, index) => (
249+
<DropdownMenuItem
250+
key={device.deviceId}
251+
onClick={() => handleCameraChange(device.deviceId)}
252+
className="cursor-pointer"
253+
>
254+
{device.label || `Camera ${index + 1}`}
255+
</DropdownMenuItem>
256+
))
257+
) : (
258+
<DropdownMenuItem disabled>
259+
No other cameras detected
260+
</DropdownMenuItem>
261+
)}
262+
</DropdownMenuContent>
263+
</DropdownMenu>
264+
265+
<Button onClick={capture} className="">
266+
Capture Photo
267+
</Button>
268+
</div>
134269
</div>
135270
) : capturedImageUrl ? (
136271
<div className="flex flex-col items-center gap-4">

frontend/src/hooks/useQueryExtension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616

1717
import { getErrorMessage } from '@/lib/utils';
1818

19-
interface BackendRes<T = any> {
19+
export interface BackendRes<T = any> {
2020
success: boolean;
2121
error?: string;
2222
message?: string;

0 commit comments

Comments
 (0)