@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
JavaScript
;
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