@@ -25,6 +25,13 @@ import { delay, isDev } from '@shared/utils/utils'
2525import { InlineNotification } from '@renderer/components/Nethesis/InlineNotification'
2626
2727type DeviceType = 'audioInput' | 'audioOutput' | 'videoInput'
28+
29+ type DeviceError = {
30+ type : 'permission' | 'enumeration' | 'validation'
31+ message : string
32+ retryable : boolean
33+ }
34+
2835const LOCALSTORAGE_KEYS = {
2936 audioInput : 'phone-island-audio-input-device' ,
3037 audioOutput : 'phone-island-audio-output-device' ,
@@ -66,6 +73,9 @@ export function SettingsDeviceDialog() {
6673 audioOutput : MediaDeviceInfo [ ]
6774 videoInput : MediaDeviceInfo [ ]
6875 } | null > ( null )
76+ const [ deviceError , setDeviceError ] = useState < DeviceError | null > ( null )
77+ const [ isLoadingDevices , setIsLoadingDevices ] = useState ( false )
78+ const [ retryCount , setRetryCount ] = useState ( 0 )
6979
7080 const schema : z . ZodType < PreferredDevices > = z . object ( {
7181 audioInput : z . string ( ) ,
@@ -132,24 +142,73 @@ export function SettingsDeviceDialog() {
132142 [ devices ] ,
133143 )
134144
135- const initDevices = async ( ) => {
136- const devices = await getMediaDevices ( )
137- Log . info ( 'Available devices:' , devices )
138- setDevices ( devices )
145+ const initDevices = async ( isRetry = false ) => {
146+ if ( isRetry ) {
147+ setRetryCount ( prev => prev + 1 )
148+ }
149+
150+ setIsLoadingDevices ( true )
151+ setDeviceError ( null )
152+
153+ try {
154+ const devices = await getMediaDevices ( )
155+ Log . info ( 'Available devices:' , devices )
156+ setDevices ( devices )
157+
158+ if ( devices && Object . values ( devices ) . every ( deviceList => deviceList . length === 0 ) ) {
159+ setDeviceError ( {
160+ type : 'validation' ,
161+ message : t ( 'Devices.No devices found' ) ,
162+ retryable : true
163+ } )
164+ }
165+ } catch ( error ) {
166+ Log . error ( 'Failed to initialize devices:' , error )
167+ setDeviceError ( {
168+ type : 'enumeration' ,
169+ message : t ( 'Devices.Failed to load devices' ) ,
170+ retryable : true
171+ } )
172+ } finally {
173+ setIsLoadingDevices ( false )
174+ }
139175 }
140176
141177 async function getMediaDevices ( ) {
178+ if ( ! navigator . mediaDevices || ! navigator . mediaDevices . enumerateDevices ) {
179+ throw new Error ( t ( 'Devices.Media devices not supported' ) || 'Media devices not supported' )
180+ }
181+
142182 try {
183+ // Request permissions first to get device labels
184+ try {
185+ await navigator . mediaDevices . getUserMedia ( { audio : true , video : true } )
186+ } catch ( permissionError ) {
187+ Log . warning ( 'Media permissions not granted, device labels may be limited:' , permissionError )
188+ // Don't set error, just log warning - continue to enumerate devices
189+ }
190+
143191 const devices = await navigator . mediaDevices . enumerateDevices ( )
144192
193+ if ( ! devices || devices . length === 0 ) {
194+ throw new Error ( t ( 'Devices.No media devices found' ) || 'No media devices found' )
195+ }
196+
145197 const audioInput = devices . filter ( ( device ) => device . kind === 'audioinput' )
146198 const audioOutput = devices . filter ( ( device ) => device . kind === 'audiooutput' )
147199 const videoInput = devices . filter ( ( device ) => device . kind === 'videoinput' )
148200
149- return { audioInput, audioOutput, videoInput }
201+ // Validate device availability
202+ const validatedDevices = {
203+ audioInput : audioInput . filter ( device => device . deviceId && device . deviceId !== 'default' ) ,
204+ audioOutput : audioOutput . filter ( device => device . deviceId && device . deviceId !== 'default' ) ,
205+ videoInput : videoInput . filter ( device => device . deviceId && device . deviceId !== 'default' )
206+ }
207+
208+ return validatedDevices
150209 } catch ( err ) {
151- console . error ( 'Error reading audio and video devices:' , err )
152- return null
210+ Log . error ( 'Error reading audio and video devices:' , err )
211+ throw err
153212 }
154213 }
155214
@@ -189,6 +248,18 @@ export function SettingsDeviceDialog() {
189248 account ?. data ?. default_device ?. type == 'webrtc' ||
190249 account ?. data ?. mainPresence !== 'online'
191250
251+ const handleRetryDevices = ( ) => {
252+ if ( retryCount < 3 ) {
253+ initDevices ( true )
254+ } else {
255+ setDeviceError ( {
256+ type : 'enumeration' ,
257+ message : t ( 'Devices.Max retry attempts reached' ) ,
258+ retryable : false
259+ } )
260+ }
261+ }
262+
192263 const DeviceDropdown = useCallback (
193264 ( { name } : { name : DeviceType } ) => {
194265 return (
@@ -295,9 +366,50 @@ export function SettingsDeviceDialog() {
295366 className = 'flex flex-col gap-1'
296367 >
297368 { /* Input field with clear button next to it */ }
298- < DeviceDropdown name = 'audioInput' />
299- < DeviceDropdown name = 'audioOutput' />
300- < DeviceDropdown name = 'videoInput' />
369+ { isLoadingDevices ? (
370+ < div className = 'flex items-center justify-center py-8' >
371+ < div className = 'animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500' > </ div >
372+ < span className = 'ml-2 text-gray-600 dark:text-gray-400' >
373+ { t ( 'Devices.Loading devices...' ) }
374+ </ span >
375+ </ div >
376+ ) : deviceError ? (
377+ < InlineNotification
378+ title = { t ( 'Common.Error' ) }
379+ type = 'error'
380+ className = 'mb-4'
381+ >
382+ < div className = 'flex flex-col gap-2' >
383+ < p > { deviceError . message } </ p >
384+ { deviceError . retryable && (
385+ < Button
386+ variant = 'ghost'
387+ size = 'small'
388+ onClick = { handleRetryDevices }
389+ disabled = { retryCount >= 3 }
390+ className = 'self-start'
391+ >
392+ { retryCount >= 3 ? t ( 'Devices.Max retries reached' ) : t ( 'Common.Try again' ) }
393+ { retryCount > 0 && `(${ retryCount } /3)` }
394+ </ Button >
395+ ) }
396+ </ div >
397+ </ InlineNotification >
398+ ) : devices ? (
399+ < >
400+ < DeviceDropdown name = 'audioInput' />
401+ < DeviceDropdown name = 'audioOutput' />
402+ < DeviceDropdown name = 'videoInput' />
403+ </ >
404+ ) : (
405+ < InlineNotification
406+ title = { t ( 'Common.Warning' ) }
407+ type = 'warning'
408+ className = 'mb-4'
409+ >
410+ < p > { t ( 'Devices.No devices available' ) } </ p >
411+ </ InlineNotification >
412+ ) }
301413
302414 { /* Inline notification */ }
303415 { isDeviceUnavailable && (
0 commit comments