@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
319 lines (289 loc) • 10.4 kB
JavaScript
;
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;
// 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
}
});
}
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 {
// 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) {
// 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;
});
// Setup event listeners for the room
const setupRoomListeners = () => {
if (hasListeners || !roomInstance) return;
const handleConnectionChange = 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
}
}
};
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', () => {
// todo: handle participant disconnected
});
roomInstance.on('connectionStateChanged', handleConnectionChange);
roomInstance.on('trackSubscribed', handleTrackSubscribed);
// Listen for SDP negotiation errors to handle them better
roomInstance.on('mediaDevicesError', e => {
throw new Error(`Media devices error: ${e.message}`);
// todo: handle media devices error
});
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);
}
};
// 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);
const connectToRoom = async () => {
// 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;
// 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);
} 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')) {}
} finally {
isConnecting = false;
}
};
connectToRoom();
// No cleanup here - we don't want to disconnect when token changes or component unmounts
}, [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;
}
};
// Mute microphone
const muteMic = () => {
const room = getRoomInstance();
if (room.localParticipant) {
room.localParticipant.setMicrophoneEnabled(false);
setIsMicMuted(true);
}
};
// Unmute microphone
const unmuteMic = () => {
const room = getRoomInstance();
if (room.localParticipant) {
room.localParticipant.setMicrophoneEnabled(true);
setIsMicMuted(false);
}
};
// 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: {
current: getRoomInstance()
},
cleanup,
getPopupDescription
};
};
//# sourceMappingURL=voiceagent.js.map