UNPKG

@revrag-ai/embed-react-native

Version:

A powerful React Native library for integrating AI-powered voice agents into mobile applications. Features real-time voice communication, intelligent speech processing, customizable UI components, and comprehensive event handling for building conversation

454 lines (414 loc) 15.6 kB
"use strict"; import { ConnectionState, Room } from 'livekit-client'; import { useEffect, useRef, useState } from 'react'; import { APIService } from "../api/api.js"; import { EventKeys } from "../events/embed.event.js"; import { getAgentData } from "../store/store.key.js"; // Store original console.warn before patching const originalWarn = console.warn; // Patch console.warn to filter duplicate listener warnings from WebRTC/LiveKit // This catches warnings that are emitted directly by react-native-webrtc and event-target-shim console.warn = (...args) => { // Check if this is the duplicate listener warning from WebRTC or event-target-shim const message = String(args[0] || ''); if (message.includes('event listener wasn') && message.includes('added already')) { // Silently ignore this warning in both dev and prod return; } // Pass all other warnings through to original handler originalWarn.apply(console, args); }; // Create a singleton instance of Room that persists across hook instances // This ensures we don't create multiple Room instances that could conflict let roomInstance = null; let isConnecting = false; let isDisconnecting = false; let hasListeners = false; let connectionAttempts = 0; let stableConnectionTimerId = null; let reconnectTimerId = null; const MAX_CONNECTION_ATTEMPTS = 3; // Store listener references for cleanup let connectionChangeHandler = null; let trackSubscribedHandler = null; let participantDisconnectedHandler = null; let mediaDevicesErrorHandler = null; let connectionQualityChangedHandler = null; let reconnectingHandler = null; // Create getters for the room instance const getRoomInstance = () => { if (!roomInstance) { // Configure the room with the right options at creation time roomInstance = new Room({ adaptiveStream: true, dynacast: true, // Using the most stable configuration for React Native publishDefaults: { simulcast: false // Disable simulcast to reduce SDP complexity } // Note: LiveKit uses internal ping/pong mechanism for connection health // Ping timeout warnings are normal for temporary network issues and // LiveKit will automatically handle reconnection when network recovers // These warnings are now suppressed (see setWarningHandler above) }); } return roomInstance; }; // Reset connection attempt counter const resetConnectionAttempts = () => { connectionAttempts = 0; // Clear any pending stable connection timer if (stableConnectionTimerId) { clearTimeout(stableConnectionTimerId); stableConnectionTimerId = null; } // Clear any pending reconnect timer if (reconnectTimerId) { clearTimeout(reconnectTimerId); reconnectTimerId = null; } }; // Remove all event listeners from the room const removeRoomListeners = () => { if (roomInstance && hasListeners) { try { if (connectionChangeHandler) { roomInstance.off('connectionStateChanged', connectionChangeHandler); connectionChangeHandler = null; } if (trackSubscribedHandler) { roomInstance.off('trackSubscribed', trackSubscribedHandler); trackSubscribedHandler = null; } if (participantDisconnectedHandler) { roomInstance.off('participantDisconnected', participantDisconnectedHandler); participantDisconnectedHandler = null; } if (mediaDevicesErrorHandler) { roomInstance.off('mediaDevicesError', mediaDevicesErrorHandler); mediaDevicesErrorHandler = null; } if (connectionQualityChangedHandler) { roomInstance.off('connectionQualityChanged', connectionQualityChangedHandler); connectionQualityChangedHandler = null; } if (reconnectingHandler) { roomInstance.off('reconnecting', reconnectingHandler); reconnectingHandler = null; } hasListeners = false; } catch (e) { // Silently handle cleanup errors } } }; // Cleanup function to reset the room state const resetRoomState = () => { if (roomInstance) { try { // Remove event listeners first removeRoomListeners(); // Only disconnect if currently connected if (roomInstance.state !== ConnectionState.Disconnected && !isDisconnecting) { isDisconnecting = true; roomInstance.disconnect().finally(() => { isDisconnecting = false; }); } resetConnectionAttempts(); } catch (e) { // todo: handle error resetting room state } } }; export const useVoiceAgent = () => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [tokenDetails, setTokenDetails] = useState(null); const [isMicMuted, setIsMicMuted] = useState(false); const [connectionState, setConnectionState] = useState(() => { // Initialize with the current room state if it exists const room = getRoomInstance(); return room ? room.state : ConnectionState.Disconnected; }); const roomRef = useRef(getRoomInstance()); // Guard against React 18 Strict Mode double initialization const initializedRef = useRef(false); const connectRoomWithToken = useRef(); // Initialize connection helper (stable ref to avoid re-definition) connectRoomWithToken.current = async details => { const room = getRoomInstance(); // Prevent multiple connection attempts if (isConnecting) return; // Limit connection attempts to prevent infinite loops if (connectionAttempts >= MAX_CONNECTION_ATTEMPTS) { setError(`Failed to connect after ${MAX_CONNECTION_ATTEMPTS} attempts`); return; } connectionAttempts++; try { isConnecting = true; // Clear any pending reconnect timer since we're attempting now if (reconnectTimerId) { clearTimeout(reconnectTimerId); reconnectTimerId = null; } // Only attempt to connect if we're disconnected if (room.state === ConnectionState.Disconnected) { // Update state before connection attempt setConnectionState(ConnectionState.Connecting); await room.connect(details.server_url, details.token, { autoSubscribe: true // Ensure we subscribe to tracks automatically }); // Explicitly set to connected if connection was successful setConnectionState(room.state); } else if (room.state === ConnectionState.Connected) { // Ensure our state matches setConnectionState(ConnectionState.Connected); } else { // Sync our state with the room's current state setConnectionState(room.state); } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to connect to room'; setError(message); // Sync state to disconnected if we failed to connect setConnectionState(room.state); // Don't keep retrying if we hit an SDP error if (message.includes('SDP') || message.includes('sdp')) { // Stop further retries by exhausting attempts connectionAttempts = MAX_CONNECTION_ATTEMPTS; } } finally { isConnecting = false; } }; // Setup event listeners for the room const setupRoomListeners = () => { if (!roomInstance) return; // Always clear previous listeners before re-registering to avoid duplicates removeRoomListeners(); // Define all handlers as stable references connectionChangeHandler = state => { // Use a function to ensure we're setting with the latest state setConnectionState(() => state); // Handle connection state changes if (state === ConnectionState.Connected) { // Reset connection attempts when connected successfully resetConnectionAttempts(); // Set up a timer to mark the connection as stable after a few seconds // This prevents immediate disconnections from being treated as stable if (stableConnectionTimerId) { clearTimeout(stableConnectionTimerId); } } else if (state === ConnectionState.Disconnected) { // Mark connection as unstable // Clear any pending stable connection timer if (stableConnectionTimerId) { clearTimeout(stableConnectionTimerId); stableConnectionTimerId = null; } // If disconnected unexpectedly and we have token details, don't automatically reconnect // This prevents connection loops if (tokenDetails && !isDisconnecting) { // todo: handle room disconnected } } }; trackSubscribedHandler = track => { if (track.kind === 'audio') { track.setVolume(1.0); } }; participantDisconnectedHandler = () => { // todo: handle participant disconnected }; mediaDevicesErrorHandler = e => { throw new Error(`Media devices error: ${e.message}`); // todo: handle media devices error }; connectionQualityChangedHandler = quality => { // Monitor connection quality to detect network issues // This helps understand when ping timeouts might occur if (__DEV__) { console.log('[LiveKit] Connection quality changed:', quality); } // If quality is poor, ping timeouts are more likely // The connection will self-heal when network improves }; reconnectingHandler = () => { // Called when LiveKit detects connection issues and attempts to reconnect if (__DEV__) { console.log('[LiveKit] Attempting to reconnect...'); } // This is expected behavior when ping timeouts occur // LiveKit will automatically handle reconnection }; // Add listeners with stored references - only if not already added if (!hasListeners) { roomInstance.on('participantDisconnected', participantDisconnectedHandler); roomInstance.on('connectionStateChanged', connectionChangeHandler); roomInstance.on('trackSubscribed', trackSubscribedHandler); roomInstance.on('mediaDevicesError', mediaDevicesErrorHandler); roomInstance.on('connectionQualityChanged', connectionQualityChangedHandler); roomInstance.on('reconnecting', reconnectingHandler); hasListeners = true; } }; // Initialize room and listeners useEffect(() => { // Guard against React 18 Strict Mode double initialization if (initializedRef.current) { // Already initialized, just sync state const room = getRoomInstance(); setConnectionState(room.state); return; } initializedRef.current = true; const room = getRoomInstance(); setupRoomListeners(); // Sync local state with room state setConnectionState(room.state); return () => { // Do NOT disconnect or destroy the room on component unmount // This ensures the singleton persists across component lifecycle // But we do want to update local state if the component is unmounted if (!tokenDetails) { setTokenDetails(null); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Connect to LiveKit when token is set useEffect(() => { const room = getRoomInstance(); if (!tokenDetails || isConnecting) return; // Always sync our state with the room's current state setConnectionState(room.state); connectRoomWithToken.current?.(tokenDetails); // No cleanup here - we don't want to disconnect when token changes or component unmounts }, [tokenDetails]); // Attempt reconnect if disconnected while a token is present useEffect(() => { if (connectionState === ConnectionState.Disconnected && tokenDetails && !isDisconnecting && !isConnecting) { // Schedule a reconnect with small backoff to avoid tight loops if (reconnectTimerId) { clearTimeout(reconnectTimerId); } reconnectTimerId = setTimeout(() => { connectRoomWithToken.current?.(tokenDetails); }, 1000); } // Clear timer when state changes away from Disconnected return () => { if (reconnectTimerId && connectionState !== ConnectionState.Disconnected) { clearTimeout(reconnectTimerId); reconnectTimerId = null; } }; }, [connectionState, tokenDetails]); // Generate token and connect const generateVoiceToken = async () => { // Don't generate a new token if we're already connecting/connected if (isConnecting || isLoading) { // todo: handle already connecting or loading return; } // Reset connection attempts when starting fresh resetConnectionAttempts(); const userData = await getAgentData(EventKeys.USER_DATA); setIsLoading(true); setError(null); try { const apiService = APIService.getInstance(); const response = await apiService.getTokenDetails({ app_user_id: userData?.app_user_id, call_type: 'EMBEDDED' }); if (!response.data) throw new Error('No voice token found'); // Only set token details if we're not already connected const room = getRoomInstance(); if (room.state !== ConnectionState.Connected) { setTokenDetails(response.data); } else { // todo: handle room already connected } } catch (err) { const message = err instanceof Error ? err.message : 'Failed to generate voice token'; setError(message); throw new Error(message); } finally { setIsLoading(false); } }; // End call const endCall = async () => { if (isDisconnecting) { // todo: handle already disconnecting return; } try { isDisconnecting = true; setTokenDetails(null); setIsMicMuted(false); resetConnectionAttempts(); const room = getRoomInstance(); if (room.state !== ConnectionState.Disconnected) { // Update state before disconnection setConnectionState(ConnectionState.Connecting); await room.disconnect(); // Update state after disconnection setConnectionState(ConnectionState.Disconnected); } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to end call'); // Make sure state is correctly reflected even if there's an error const room = getRoomInstance(); setConnectionState(room.state); } finally { isDisconnecting = false; } }; const unmuteMic = async () => { if (!isMicMuted) return; // 👈 important const room = getRoomInstance(); await room.localParticipant.setMicrophoneEnabled(true); setIsMicMuted(false); }; const muteMic = async () => { if (isMicMuted) return; // 👈 important const room = getRoomInstance(); await room.localParticipant.setMicrophoneEnabled(false); setIsMicMuted(true); }; // Clean up everything (use only when the app is shutting down) const cleanup = () => { endCall(); resetRoomState(); }; const getPopupDescription = async () => { const userData = await getAgentData(EventKeys.USER_DATA); const apiService = APIService.getInstance(); const response = await apiService.getPopupDescription({ app_user_id: userData?.app_user_id }); if (!response.data) throw new Error('No popup description found'); return response.data; }; return { initializeVoiceAgent: generateVoiceToken, endCall, muteMic, unmuteMic, isMicMuted, connectionState, room: getRoomInstance(), tokenDetails, isLoading, error, roomRef, cleanup, getPopupDescription }; }; //# sourceMappingURL=voiceagent.js.map