1- import { useState , useEffect , useCallback , useRef } from 'react' ;
2- import LayercodeClient , { type AgentConfig , type AuthorizeSessionRequest } from '@layercode/js-sdk' ;
1+ import React , { useState , useEffect , useCallback , useRef } from 'react' ;
2+ import LayercodeClient , {
3+ type AgentConfig ,
4+ type AuthorizeSessionRequest ,
5+ type LayercodeAudioInputDevice ,
6+ listAudioInputDevices ,
7+ watchAudioInputDevices ,
8+ } from 'layercode-js-sdk' ;
39
410/**
511 * Configuration options for the useLayercodeAgent hook.
@@ -26,6 +32,13 @@ interface UseLayercodeAgentOptions {
2632 enableAmplitudeMonitoring ?: boolean ;
2733}
2834
35+ const normalizeDeviceId = ( deviceId ?: string | null ) : string | null => {
36+ if ( ! deviceId || deviceId === 'default' ) {
37+ return null ;
38+ }
39+ return deviceId ;
40+ } ;
41+
2942/**
3043 * Hook for connecting to a Layercode agent in a React application
3144 *
@@ -66,10 +79,54 @@ const useLayercodeAgent = (
6679 const [ audioOutput , _setAudioOutput ] = useState < boolean > ( options . audioOutput ?? true ) ;
6780 const [ isMuted , setIsMuted ] = useState ( false ) ;
6881 const [ internalConversationId , setInternalConversationId ] = useState < string | null | undefined > ( conversationId ) ;
82+ const [ availableInputDevices , setAvailableInputDevices ] = useState < LayercodeAudioInputDevice [ ] > ( [ ] ) ;
83+ const [ activeInputDeviceId , setActiveInputDeviceId ] = useState < string | null > ( null ) ;
84+ const [ preferredInputDeviceId , setPreferredInputDeviceId ] = useState < string | null > ( null ) ;
85+ const [ isInputDeviceListLoading , setIsInputDeviceListLoading ] = useState ( false ) ;
86+ const [ inputDeviceListError , setInputDeviceListError ] = useState < string | null > ( null ) ;
6987 const conversationIdRef = useRef < string | undefined > ( conversationId ) ;
88+ const preferredInputDeviceRef = useRef < string | null > ( null ) ;
89+ const mountedRef = useRef ( true ) ;
7090 // Reference to the LayercodeClient instance
7191 const clientRef = useRef < LayercodeClient | null > ( null ) ;
7292
93+ const refreshInputDevices = useCallback ( async ( ) => {
94+ if ( typeof window === 'undefined' || typeof navigator === 'undefined' ) {
95+ return [ ] as LayercodeAudioInputDevice [ ] ;
96+ }
97+
98+ setIsInputDeviceListLoading ( true ) ;
99+ try {
100+ const devices = await listAudioInputDevices ( ) ;
101+ if ( ! mountedRef . current ) {
102+ return devices ;
103+ }
104+ setAvailableInputDevices ( devices ) ;
105+ setInputDeviceListError ( null ) ;
106+
107+ if ( ! devices . length ) {
108+ setActiveInputDeviceId ( null ) ;
109+ return devices ;
110+ }
111+
112+ if ( preferredInputDeviceRef . current === null ) {
113+ const defaultDevice = devices . find ( ( device ) => device . default ) ?? devices [ 0 ] ;
114+ setActiveInputDeviceId ( normalizeDeviceId ( defaultDevice ?. deviceId ) ) ;
115+ }
116+
117+ return devices ;
118+ } catch ( error ) {
119+ if ( mountedRef . current ) {
120+ setInputDeviceListError ( error instanceof Error ? error . message : 'Unable to access microphones' ) ;
121+ }
122+ throw error ;
123+ } finally {
124+ if ( mountedRef . current ) {
125+ setIsInputDeviceListLoading ( false ) ;
126+ }
127+ }
128+ } , [ ] ) ;
129+
73130 useEffect ( ( ) => {
74131 conversationIdRef . current = conversationId ;
75132 if ( conversationId !== undefined ) {
@@ -79,13 +136,54 @@ const useLayercodeAgent = (
79136 }
80137 } , [ conversationId ] ) ;
81138
139+ useEffect ( ( ) => {
140+ return ( ) => {
141+ mountedRef . current = false ;
142+ } ;
143+ } , [ ] ) ;
144+
82145 useEffect ( ( ) => {
83146 if ( ! enableAmplitudeMonitoring ) {
84147 setUserAudioAmplitude ( 0 ) ;
85148 setAgentAudioAmplitude ( 0 ) ;
86149 }
87150 } , [ enableAmplitudeMonitoring ] ) ;
88151
152+ useEffect ( ( ) => {
153+ preferredInputDeviceRef . current = preferredInputDeviceId ;
154+ } , [ preferredInputDeviceId ] ) ;
155+
156+ useEffect ( ( ) => {
157+ if ( typeof window === 'undefined' ) {
158+ return ;
159+ }
160+
161+ refreshInputDevices ( ) . catch ( ( error ) => {
162+ console . warn ( 'Layercode: failed to load microphone devices' , error ) ;
163+ } ) ;
164+ } , [ refreshInputDevices ] ) ;
165+
166+ useEffect ( ( ) => {
167+ if ( typeof window === 'undefined' || typeof navigator === 'undefined' ) {
168+ return ;
169+ }
170+
171+ const unsubscribe = watchAudioInputDevices ( ( devices ) => {
172+ setAvailableInputDevices ( devices ) ;
173+ setInputDeviceListError ( null ) ;
174+ if ( devices . length && preferredInputDeviceRef . current === null ) {
175+ const defaultDevice = devices . find ( ( device ) => device . default ) ?? devices [ 0 ] ;
176+ setActiveInputDeviceId ( normalizeDeviceId ( defaultDevice ?. deviceId ) ) ;
177+ } else if ( ! devices . length ) {
178+ setActiveInputDeviceId ( null ) ;
179+ }
180+ } ) ;
181+
182+ return ( ) => {
183+ unsubscribe ?.( ) ;
184+ } ;
185+ } , [ ] ) ;
186+
89187 const createClient = useCallback (
90188 ( initialConversationId : string | null ) => {
91189 console . log ( 'Creating LayercodeClient instance' ) ;
@@ -105,6 +203,17 @@ const useLayercodeAgent = (
105203 _setAudioOutput ( next ) ;
106204 onAudioOutputChanged ?.( next ) ;
107205 } ,
206+ onDeviceSwitched : ( deviceId : string ) => {
207+ const normalized = normalizeDeviceId ( deviceId ) ;
208+ setActiveInputDeviceId ( normalized ) ;
209+ if ( preferredInputDeviceRef . current === null ) {
210+ setPreferredInputDeviceId ( normalized ) ;
211+ }
212+ } ,
213+ onDevicesChanged : ( devices : Array < MediaDeviceInfo & { default : boolean } > ) => {
214+ setAvailableInputDevices ( devices as LayercodeAudioInputDevice [ ] ) ;
215+ setInputDeviceListError ( null ) ;
216+ } ,
108217 onConnect : ( { conversationId, config } : { conversationId : string | null ; config ?: AgentConfig } ) => {
109218 setInternalConversationId ( ( current ) => {
110219 if ( conversationIdRef . current === undefined ) {
@@ -224,6 +333,28 @@ const useLayercodeAgent = (
224333 [ _setAudioOutput , clientRef , audioOutput ]
225334 ) ;
226335
336+ const selectInputDevice = useCallback (
337+ async ( deviceId : string | null ) => {
338+ const normalized = normalizeDeviceId ( deviceId ) ;
339+ setPreferredInputDeviceId ( normalized ) ;
340+ preferredInputDeviceRef . current = normalized ;
341+ setInputDeviceListError ( null ) ;
342+
343+ if ( ! clientRef . current ) {
344+ return ;
345+ }
346+
347+ try {
348+ await clientRef . current . setPreferredInputDevice ( normalized ) ;
349+ } catch ( error ) {
350+ const message = error instanceof Error ? error . message : 'Unable to switch microphone' ;
351+ setInputDeviceListError ( message ) ;
352+ throw error ;
353+ }
354+ } ,
355+ [ ]
356+ ) ;
357+
227358 const triggerUserTurnStarted = useCallback ( ( ) => {
228359 clientRef . current ?. triggerUserTurnStarted ( ) ;
229360 } , [ ] ) ;
@@ -251,6 +382,7 @@ const useLayercodeAgent = (
251382 const client = createClient ( nextConversationId ?? null ) ;
252383
253384 try {
385+ await client . setPreferredInputDevice ( preferredInputDeviceRef . current ) ;
254386 await client . connect ( ) ;
255387 } catch ( error ) {
256388 console . error ( 'Failed to connect to agent:' , error ) ;
@@ -289,6 +421,8 @@ const useLayercodeAgent = (
289421
290422 setAudioInput,
291423 setAudioOutput,
424+ refreshInputDevices,
425+ selectInputDevice,
292426
293427 // State
294428 status,
@@ -300,7 +434,105 @@ const useLayercodeAgent = (
300434 conversationId : internalConversationId ,
301435 audioInput,
302436 audioOutput,
437+ availableInputDevices,
438+ activeInputDeviceId,
439+ preferredInputDeviceId,
440+ isInputDeviceListLoading,
441+ inputDeviceListError,
442+ } ;
443+ } ;
444+ type UseLayercodeAgentReturn = ReturnType < typeof useLayercodeAgent > ;
445+
446+ type NativeSelectProps = Omit < React . SelectHTMLAttributes < HTMLSelectElement > , 'value' | 'onChange' > ;
447+
448+ interface MicrophoneSelectProps extends NativeSelectProps {
449+ agent : UseLayercodeAgentReturn ;
450+ label ?: React . ReactNode ;
451+ helperText ?: React . ReactNode ;
452+ emptyLabel ?: React . ReactNode ;
453+ containerClassName ?: string ;
454+ autoRefresh ?: boolean ;
455+ }
456+
457+ const MicrophoneSelect = ( {
458+ agent,
459+ label = 'Microphone' ,
460+ helperText,
461+ emptyLabel = 'No microphones detected' ,
462+ containerClassName,
463+ className,
464+ autoRefresh = true ,
465+ disabled,
466+ ...selectProps
467+ } : LayercodeMicrophoneSelectProps ) => {
468+ const selectId = React . useId ( ) ;
469+ const { refreshInputDevices, selectInputDevice, availableInputDevices, isInputDeviceListLoading, inputDeviceListError, preferredInputDeviceId, activeInputDeviceId } = agent ;
470+
471+ useEffect ( ( ) => {
472+ if ( ! autoRefresh ) {
473+ return ;
474+ }
475+
476+ refreshInputDevices ( ) . catch ( ( error ) => {
477+ console . warn ( 'Layercode: failed to refresh microphones' , error ) ;
478+ } ) ;
479+ } , [ autoRefresh , refreshInputDevices ] ) ;
480+
481+ const handleChange = ( event : React . ChangeEvent < HTMLSelectElement > ) => {
482+ const value = event . target . value ;
483+ selectInputDevice ( value === 'default' ? null : value ) ;
303484 } ;
485+
486+ const currentValue = preferredInputDeviceId ?? activeInputDeviceId ?? 'default' ;
487+
488+ const hasDevices = availableInputDevices . length > 0 ;
489+
490+ return (
491+ < div className = { containerClassName } >
492+ { label ? (
493+ < label className = "layercode-mic-select__label" htmlFor = { selectId } >
494+ { label }
495+ </ label >
496+ ) : null }
497+
498+ < select
499+ id = { selectId }
500+ className = { className }
501+ value = { currentValue }
502+ onChange = { handleChange }
503+ disabled = { disabled || isInputDeviceListLoading || ( ! hasDevices && ! inputDeviceListError ) }
504+ { ...selectProps }
505+ >
506+ < option value = "default" > System default microphone</ option >
507+ { availableInputDevices . map ( ( device ) => {
508+ const optionValue = device . deviceId || 'default' ;
509+ const labelText = device . label || ( device . default ? 'System default microphone' : 'Microphone' ) ;
510+ const suffix = device . default && optionValue !== 'default' ? ' (default)' : '' ;
511+ return (
512+ < option key = { `${ device . deviceId } -${ device . label } ` } value = { optionValue } >
513+ { labelText }
514+ { suffix }
515+ </ option >
516+ ) ;
517+ } ) }
518+ </ select >
519+
520+ { isInputDeviceListLoading ? < div className = "layercode-mic-select__helper" > Loading microphones…</ div > : null }
521+
522+ { inputDeviceListError ? < div className = "layercode-mic-select__error" > { inputDeviceListError } </ div > : null }
523+
524+ { ! isInputDeviceListLoading && ! inputDeviceListError && ! hasDevices ? (
525+ < div className = "layercode-mic-select__helper" > { emptyLabel } </ div >
526+ ) : null }
527+
528+ { helperText ? < div className = "layercode-mic-select__helper" > { helperText } </ div > : null }
529+ </ div >
530+ ) ;
304531} ;
305532
306- export { useLayercodeAgent , UseLayercodeAgentOptions } ; // Export the type too
533+ type LayercodeMicrophoneSelectProps = MicrophoneSelectProps ;
534+
535+ export { useLayercodeAgent , MicrophoneSelect } ;
536+ export { MicrophoneSelect as LayercodeMicrophoneSelect } ;
537+ export type { UseLayercodeAgentOptions , UseLayercodeAgentReturn , MicrophoneSelectProps } ;
538+ export type { MicrophoneSelectProps as LayercodeMicrophoneSelectProps } ;
0 commit comments