1- import { useState , useRef , useCallback } from 'react' ;
1+ import { useState , useRef , useCallback , useEffect } from 'react' ;
22import { useMutationFeedback } from '../../hooks/useMutationFeedback.tsx' ;
33import Webcam from 'react-webcam' ;
4- import { X , RotateCcw , Search } from 'lucide-react' ;
4+ import { X , RotateCcw , Search , Camera , ChevronDown } from 'lucide-react' ;
55import { Button } from '@/components/ui/button' ;
66import {
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' ;
1319import { useDispatch } from 'react-redux' ;
1420import { startSearch , clearSearch } from '@/features/searchSlice' ;
1521import type { Image } from '@/types/Media' ;
1622import { usePictoMutation } from '@/hooks/useQueryExtension' ;
1723import { fetchSearchedFacesBase64 } from '@/api/api-functions' ;
1824import { showInfoDialog } from '@/features/infoDialogSlice' ;
1925import { 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
2529interface WebcamComponentProps {
2630 isOpen : boolean ;
@@ -30,21 +34,29 @@ interface WebcamComponentProps {
3034function 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" >
0 commit comments