Skip to content

Commit 319f9c2

Browse files
Merge pull request #624 from rohan-pandeyy/feat/add-webcam-selection-dropdown
Enhancement: Add Camera Selection to Webcam Component.
2 parents 8007412 + fc950ae commit 319f9c2

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 === 0}
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)