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

353 lines (318 loc) 11.9 kB
"use strict"; import { ConnectionState, Room } from 'livekit-client'; import { useEffect, useState } from 'react'; import { EventKeys } from "../events/embed.event.js"; import { APIService } from "../api/api.js"; import { getAgentData } from "../store/store.key.js"; // 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; const MAX_CONNECTION_ATTEMPTS = 3; const STABLE_CONNECTION_TIMEOUT = 5000; // 5 seconds // Create getters for the room instance const getRoomInstance = () => { if (!roomInstance) { console.log('Creating new Room instance'); // 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 } }); } return roomInstance; }; // Reset connection attempt counter const resetConnectionAttempts = () => { connectionAttempts = 0; // Clear any pending stable connection timer if (stableConnectionTimerId) { clearTimeout(stableConnectionTimerId); stableConnectionTimerId = null; } }; // Cleanup function to reset the room state const resetRoomState = () => { if (roomInstance) { try { console.log('Resetting room state'); console.log('isDisconnecting', isDisconnecting); // Only disconnect if currently connected if (roomInstance.state !== ConnectionState.Disconnected && !isDisconnecting) { isDisconnecting = true; roomInstance.disconnect().finally(() => { isDisconnecting = false; }); } // Don't destroy the room instance, just reset its state hasListeners = false; resetConnectionAttempts(); } catch (e) { console.error('Error resetting room state:', e); } } }; 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 [stableConnection, setStableConnection] = useState(false); console.log('ConnectionState_connected', connectionState // tokenDetails?.token ); // Setup event listeners for the room const setupRoomListeners = () => { if (hasListeners || !roomInstance) return; console.log('Setting up room listeners'); const handleConnectionChange = state => { console.log('Connection state changed:', 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); } stableConnectionTimerId = setTimeout(() => { console.log('Connection marked as stable'); setStableConnection(true); }, STABLE_CONNECTION_TIMEOUT); } else if (state === ConnectionState.Disconnected) { // Mark connection as unstable setStableConnection(false); // 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) { console.log('Room disconnected unexpectedly - not auto-reconnecting'); } } }; const handleTrackSubscribed = track => { if (track.kind === 'audio') { track.setVolume(1.0); } }; // Handle participant disconnected - this could be helpful to detect server-side kicks roomInstance.on('participantDisconnected', () => { console.log('Participant disconnected from room'); }); roomInstance.on('connectionStateChanged', handleConnectionChange); roomInstance.on('trackSubscribed', handleTrackSubscribed); // Listen for SDP negotiation errors to handle them better roomInstance.on('mediaDevicesError', e => { console.log('Media devices error:', e.message); }); hasListeners = true; }; // Initialize room and listeners useEffect(() => { 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); } }; }, []); // 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); const connectToRoom = async () => { // Prevent multiple connection attempts if (isConnecting) return; // Limit connection attempts to prevent infinite loops if (connectionAttempts >= MAX_CONNECTION_ATTEMPTS) { console.log(`Maximum connection attempts (${MAX_CONNECTION_ATTEMPTS}) reached. Not trying again.`); setError(`Failed to connect after ${MAX_CONNECTION_ATTEMPTS} attempts`); return; } connectionAttempts++; try { isConnecting = true; console.log(`Connecting to LiveKit room... (attempt ${connectionAttempts})`); // Only attempt to connect if we're disconnected if (room.state === ConnectionState.Disconnected) { // Update state before connection attempt setConnectionState(ConnectionState.Connecting); await room.connect(tokenDetails.server_url, tokenDetails.token, { autoSubscribe: true // Ensure we subscribe to tracks automatically }); // Explicitly set to connected if connection was successful setConnectionState(room.state); console.log('Connected to LiveKit room'); } else if (room.state === ConnectionState.Connected) { console.log('Room is already connected'); // Ensure our state matches setConnectionState(ConnectionState.Connected); } else { console.log('Room is in transition state:', room.state); // 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); console.error('Connection error:', 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')) { console.log('SDP error detected, will not retry automatically'); } } finally { isConnecting = false; } }; connectToRoom(); // No cleanup here - we don't want to disconnect when token changes or component unmounts }, [tokenDetails]); // Log connection status periodically for debugging useEffect(() => { const debugInterval = setInterval(() => { if (roomInstance) { const state = roomInstance.state; const participantCount = roomInstance.numParticipants; console.log(`[DEBUG] Room state: ${state}, Participants: ${participantCount}, Stable: ${stableConnection}`); } }, 10000); // Every 10 seconds return () => clearInterval(debugInterval); }, [stableConnection]); // Generate token and connect const generateVoiceToken = async () => { console.log('generateVoiceToken'); // Don't generate a new token if we're already connecting/connected if (isConnecting || isLoading) { console.log('Already connecting or loading, skipping token generation'); return; } // Reset connection attempts when starting fresh resetConnectionAttempts(); setStableConnection(false); const userData = await getAgentData(EventKeys.USER_DATA); setIsLoading(true); setError(null); console.log('userData', userData); 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 { console.log('Room already connected, skipping token update'); } } 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) { console.log('Already disconnecting, skipping'); return; } try { console.log('Ending call'); isDisconnecting = true; setTokenDetails(null); setIsMicMuted(false); resetConnectionAttempts(); setStableConnection(false); 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; } }; // Mute microphone const muteMic = () => { const room = getRoomInstance(); if (room.localParticipant) { room.localParticipant.setMicrophoneEnabled(false); setIsMicMuted(true); console.log('Microphone muted'); } }; // Unmute microphone const unmuteMic = () => { const room = getRoomInstance(); if (room.localParticipant) { room.localParticipant.setMicrophoneEnabled(true); setIsMicMuted(false); console.log('Microphone unmuted'); } }; // Clean up everything (use only when the app is shutting down) const cleanup = () => { endCall(); resetRoomState(); }; return { initializeVoiceAgent: generateVoiceToken, endCall, muteMic, unmuteMic, isMicMuted, connectionState, room: getRoomInstance(), tokenDetails, isLoading, error, roomRef: { current: getRoomInstance() }, cleanup }; }; //# sourceMappingURL=voiceagent.js.map