NOT TESTED ON REAL DEVICE - simulator only verification Components: - LiveKit Cloud agent deployment (julia-agent/julia-ai/) - React Native LiveKit client (hooks/useLiveKitRoom.ts) - Voice call screen with audio session management - WellNuo voice_ask API integration in Python agent Tech stack: - LiveKit Cloud for agent hosting - @livekit/react-native SDK - Deepgram STT/TTS (via LiveKit Cloud) - Silero VAD for voice activity detection Known issues: - Microphone permissions may need manual testing on real device - LiveKit audio playback not verified on physical hardware - Agent greeting audio not confirmed working end-to-end Next steps: - Test on physical iOS device - Verify microphone capture works - Confirm TTS audio playback - Test full conversation loop
641 lines
17 KiB
TypeScript
641 lines
17 KiB
TypeScript
/**
|
|
* Voice Call Screen - Fullscreen LiveKit Voice Call with Julia AI
|
|
*
|
|
* ARCHITECTURE:
|
|
* - ALL LiveKit/WebRTC logic is in useLiveKitRoom hook
|
|
* - This component ONLY handles UI rendering
|
|
* - No direct LiveKit imports here!
|
|
*
|
|
* Features:
|
|
* - Phone call-like UI with Julia avatar
|
|
* - Call duration timer
|
|
* - Mute/unmute
|
|
* - Debug logs panel (collapsible)
|
|
* - Proper cleanup on unmount
|
|
*/
|
|
|
|
import React, { useEffect, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
Platform,
|
|
Animated,
|
|
Easing,
|
|
Dimensions,
|
|
ScrollView,
|
|
Alert,
|
|
} from 'react-native';
|
|
import * as Clipboard from 'expo-clipboard';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useRouter } from 'expo-router';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
import { VOICE_NAME } from '@/services/livekitService';
|
|
import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext';
|
|
import { useLiveKitRoom, ConnectionState } from '@/hooks/useLiveKitRoom';
|
|
|
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
|
|
|
export default function VoiceCallScreen() {
|
|
const router = useRouter();
|
|
const { clearTranscript, addTranscriptEntry } = useVoiceTranscript();
|
|
|
|
// Debug logs panel state
|
|
const [showLogs, setShowLogs] = React.useState(false);
|
|
const [logsMinimized, setLogsMinimized] = React.useState(false);
|
|
const logsScrollRef = useRef<ScrollView>(null);
|
|
|
|
// LiveKit hook - ALL logic is here
|
|
const {
|
|
state,
|
|
error,
|
|
roomName,
|
|
callDuration,
|
|
isMuted,
|
|
isAgentSpeaking,
|
|
canPlayAudio,
|
|
logs,
|
|
participantCount,
|
|
connect,
|
|
disconnect,
|
|
toggleMute,
|
|
clearLogs,
|
|
} = useLiveKitRoom({
|
|
userId: `user-${Date.now()}`,
|
|
onTranscript: (role, text) => {
|
|
addTranscriptEntry(role, text);
|
|
},
|
|
});
|
|
|
|
// Animations
|
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
const rotateAnim = useRef(new Animated.Value(0)).current;
|
|
const avatarScale = useRef(new Animated.Value(0.8)).current;
|
|
|
|
// Clear transcript and start call on mount
|
|
useEffect(() => {
|
|
clearTranscript();
|
|
connect();
|
|
|
|
return () => {
|
|
// Cleanup handled by the hook
|
|
};
|
|
}, []);
|
|
|
|
// Navigate back on disconnect or error
|
|
useEffect(() => {
|
|
if (state === 'disconnected' || state === 'error') {
|
|
const timeout = setTimeout(() => {
|
|
router.back();
|
|
}, state === 'error' ? 2000 : 500);
|
|
return () => clearTimeout(timeout);
|
|
}
|
|
}, [state, router]);
|
|
|
|
// Pulse animation for active call
|
|
useEffect(() => {
|
|
if (state === 'connected') {
|
|
const pulse = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1.1,
|
|
duration: 1500,
|
|
easing: Easing.inOut(Easing.ease),
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(pulseAnim, {
|
|
toValue: 1,
|
|
duration: 1500,
|
|
easing: Easing.inOut(Easing.ease),
|
|
useNativeDriver: true,
|
|
}),
|
|
])
|
|
);
|
|
pulse.start();
|
|
|
|
// Avatar entrance animation
|
|
Animated.spring(avatarScale, {
|
|
toValue: 1,
|
|
friction: 8,
|
|
tension: 40,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
|
|
return () => pulse.stop();
|
|
}
|
|
}, [state, pulseAnim, avatarScale]);
|
|
|
|
// Rotate animation for connecting states
|
|
useEffect(() => {
|
|
const connectingStates: ConnectionState[] = [
|
|
'initializing',
|
|
'configuring_audio',
|
|
'requesting_token',
|
|
'connecting',
|
|
'reconnecting',
|
|
];
|
|
|
|
if (connectingStates.includes(state)) {
|
|
const rotate = Animated.loop(
|
|
Animated.timing(rotateAnim, {
|
|
toValue: 1,
|
|
duration: 2000,
|
|
easing: Easing.linear,
|
|
useNativeDriver: true,
|
|
})
|
|
);
|
|
rotate.start();
|
|
return () => rotate.stop();
|
|
} else {
|
|
rotateAnim.setValue(0);
|
|
}
|
|
}, [state, rotateAnim]);
|
|
|
|
// End call handler
|
|
const handleEndCall = async () => {
|
|
await disconnect();
|
|
router.back();
|
|
};
|
|
|
|
// Copy logs to clipboard
|
|
const copyLogs = async () => {
|
|
const logsText = logs.map(l => `[${l.timestamp}] ${l.message}`).join('\n');
|
|
await Clipboard.setStringAsync(logsText);
|
|
Alert.alert('Copied!', `${logs.length} log entries copied to clipboard`);
|
|
};
|
|
|
|
// Format duration as MM:SS
|
|
const formatDuration = (seconds: number): string => {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = seconds % 60;
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
// Get status text based on state
|
|
const getStatusText = (): string => {
|
|
switch (state) {
|
|
case 'idle':
|
|
return 'Starting...';
|
|
case 'initializing':
|
|
return 'Initializing...';
|
|
case 'configuring_audio':
|
|
return 'Configuring audio...';
|
|
case 'requesting_token':
|
|
return 'Requesting token...';
|
|
case 'connecting':
|
|
return 'Connecting...';
|
|
case 'connected':
|
|
if (isAgentSpeaking) return 'Julia is speaking...';
|
|
if (!canPlayAudio) return 'Waiting for audio...';
|
|
return 'Connected';
|
|
case 'reconnecting':
|
|
return 'Reconnecting...';
|
|
case 'disconnected':
|
|
return 'Disconnected';
|
|
case 'error':
|
|
return error || 'Error occurred';
|
|
default:
|
|
return 'Unknown state';
|
|
}
|
|
};
|
|
|
|
// Is call currently connecting?
|
|
const isConnecting = [
|
|
'idle',
|
|
'initializing',
|
|
'configuring_audio',
|
|
'requesting_token',
|
|
'connecting',
|
|
].includes(state);
|
|
|
|
// Is call active?
|
|
const isActive = state === 'connected';
|
|
|
|
// Rotation interpolation
|
|
const spin = rotateAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ['0deg', '360deg'],
|
|
});
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
|
{/* Background gradient effect */}
|
|
<View style={styles.backgroundGradient} />
|
|
|
|
{/* Top bar */}
|
|
<View style={styles.topBar}>
|
|
<TouchableOpacity style={styles.backButton} onPress={handleEndCall}>
|
|
<Ionicons name="chevron-down" size={28} color={AppColors.white} />
|
|
</TouchableOpacity>
|
|
<View style={styles.topBarCenter}>
|
|
<Text style={styles.encryptedText}>LiveKit + Deepgram</Text>
|
|
{roomName && (
|
|
<Text style={styles.roomNameText}>{roomName}</Text>
|
|
)}
|
|
</View>
|
|
<TouchableOpacity
|
|
style={styles.logsButton}
|
|
onPress={() => setShowLogs(!showLogs)}
|
|
>
|
|
<Ionicons
|
|
name={showLogs ? 'code-slash' : 'code'}
|
|
size={22}
|
|
color={showLogs ? AppColors.success : AppColors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Main content */}
|
|
<View style={styles.content}>
|
|
{/* Avatar */}
|
|
<Animated.View
|
|
style={[
|
|
styles.avatarContainer,
|
|
{
|
|
transform: [
|
|
{ scale: isActive ? pulseAnim : avatarScale },
|
|
{ rotate: isConnecting ? spin : '0deg' },
|
|
],
|
|
},
|
|
]}
|
|
>
|
|
<View style={styles.avatar}>
|
|
<Text style={styles.avatarText}>J</Text>
|
|
</View>
|
|
{isActive && <View style={styles.activeIndicator} />}
|
|
</Animated.View>
|
|
|
|
{/* Name and status */}
|
|
<Text style={styles.name}>Julia AI</Text>
|
|
<Text style={styles.voiceName}>{VOICE_NAME} voice</Text>
|
|
|
|
{isActive ? (
|
|
<View style={styles.statusContainer}>
|
|
<View style={styles.activeDot} />
|
|
<Text style={styles.duration}>{formatDuration(callDuration)}</Text>
|
|
</View>
|
|
) : (
|
|
<Text style={styles.status}>{getStatusText()}</Text>
|
|
)}
|
|
|
|
{/* Additional status info */}
|
|
{isActive && (
|
|
<Text style={styles.listeningStatus}>
|
|
{getStatusText()}
|
|
{participantCount > 1 && ` • ${participantCount} participants`}
|
|
</Text>
|
|
)}
|
|
|
|
{/* Error display */}
|
|
{state === 'error' && error && (
|
|
<View style={styles.errorContainer}>
|
|
<Ionicons name="alert-circle" size={20} color={AppColors.error} />
|
|
<Text style={styles.errorText}>{error}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Debug logs panel */}
|
|
{showLogs && (
|
|
<View style={[styles.logsPanel, logsMinimized && styles.logsPanelMinimized]}>
|
|
<View style={styles.logsPanelHeader}>
|
|
<TouchableOpacity
|
|
style={styles.minimizeButton}
|
|
onPress={() => setLogsMinimized(!logsMinimized)}
|
|
>
|
|
<Ionicons
|
|
name={logsMinimized ? 'chevron-up' : 'chevron-down'}
|
|
size={20}
|
|
color={AppColors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
<Text style={styles.logsPanelTitle}>
|
|
Logs ({logs.length}) • State: {state}
|
|
</Text>
|
|
<View style={styles.logsPanelButtons}>
|
|
<TouchableOpacity style={styles.copyButton} onPress={copyLogs}>
|
|
<Ionicons name="copy-outline" size={16} color={AppColors.white} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.clearButton} onPress={clearLogs}>
|
|
<Ionicons name="trash-outline" size={16} color={AppColors.white} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
style={styles.closeLogsButton}
|
|
onPress={() => setShowLogs(false)}
|
|
>
|
|
<Ionicons name="close" size={18} color={AppColors.white} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
{!logsMinimized && (
|
|
<ScrollView
|
|
ref={logsScrollRef}
|
|
style={styles.logsScrollView}
|
|
onContentSizeChange={() => logsScrollRef.current?.scrollToEnd()}
|
|
>
|
|
{logs.map((log, index) => (
|
|
<Text
|
|
key={index}
|
|
style={[
|
|
styles.logEntry,
|
|
log.level === 'error' && styles.logEntryError,
|
|
log.level === 'warn' && styles.logEntryWarn,
|
|
]}
|
|
>
|
|
[{log.timestamp}] {log.message}
|
|
</Text>
|
|
))}
|
|
{logs.length === 0 && (
|
|
<Text style={styles.logEntryEmpty}>Waiting for events...</Text>
|
|
)}
|
|
</ScrollView>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Bottom controls */}
|
|
<View style={styles.controls}>
|
|
{/* Mute button */}
|
|
<TouchableOpacity
|
|
style={[styles.controlButton, isMuted && styles.controlButtonActive]}
|
|
onPress={toggleMute}
|
|
disabled={!isActive}
|
|
>
|
|
<Ionicons
|
|
name={isMuted ? 'mic-off' : 'mic'}
|
|
size={28}
|
|
color={isMuted ? AppColors.error : AppColors.white}
|
|
/>
|
|
<Text style={styles.controlLabel}>{isMuted ? 'Unmute' : 'Mute'}</Text>
|
|
</TouchableOpacity>
|
|
|
|
{/* End call button */}
|
|
<TouchableOpacity style={styles.endCallButton} onPress={handleEndCall}>
|
|
<Ionicons name="call" size={32} color={AppColors.white} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Speaker button (placeholder for future) */}
|
|
<TouchableOpacity style={styles.controlButton} disabled>
|
|
<Ionicons name="volume-high" size={28} color={AppColors.white} />
|
|
<Text style={styles.controlLabel}>Speaker</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#1a1a2e',
|
|
},
|
|
backgroundGradient: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: '50%',
|
|
backgroundColor: '#16213e',
|
|
borderBottomLeftRadius: SCREEN_WIDTH,
|
|
borderBottomRightRadius: SCREEN_WIDTH,
|
|
transform: [{ scaleX: 2 }],
|
|
},
|
|
topBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
},
|
|
backButton: {
|
|
width: 44,
|
|
height: 44,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
topBarCenter: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
},
|
|
encryptedText: {
|
|
fontSize: FontSizes.xs,
|
|
color: 'rgba(255,255,255,0.5)',
|
|
},
|
|
roomNameText: {
|
|
fontSize: 10,
|
|
color: 'rgba(255,255,255,0.3)',
|
|
marginTop: 2,
|
|
},
|
|
logsButton: {
|
|
width: 44,
|
|
height: 44,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingBottom: 100,
|
|
},
|
|
avatarContainer: {
|
|
width: 150,
|
|
height: 150,
|
|
marginBottom: Spacing.xl,
|
|
},
|
|
avatar: {
|
|
width: 150,
|
|
height: 150,
|
|
borderRadius: 75,
|
|
backgroundColor: AppColors.success,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
shadowColor: AppColors.success,
|
|
shadowOffset: { width: 0, height: 0 },
|
|
shadowOpacity: 0.5,
|
|
shadowRadius: 20,
|
|
elevation: 10,
|
|
},
|
|
avatarText: {
|
|
fontSize: 64,
|
|
fontWeight: '600',
|
|
color: AppColors.white,
|
|
},
|
|
activeIndicator: {
|
|
position: 'absolute',
|
|
bottom: 10,
|
|
right: 10,
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
backgroundColor: AppColors.success,
|
|
borderWidth: 3,
|
|
borderColor: '#1a1a2e',
|
|
},
|
|
name: {
|
|
fontSize: 32,
|
|
fontWeight: '700',
|
|
color: AppColors.white,
|
|
marginBottom: Spacing.xs,
|
|
},
|
|
voiceName: {
|
|
fontSize: FontSizes.sm,
|
|
color: 'rgba(255,255,255,0.6)',
|
|
marginBottom: Spacing.md,
|
|
},
|
|
statusContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
activeDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: AppColors.success,
|
|
marginRight: Spacing.sm,
|
|
},
|
|
duration: {
|
|
fontSize: FontSizes.lg,
|
|
color: AppColors.white,
|
|
fontVariant: ['tabular-nums'],
|
|
},
|
|
status: {
|
|
fontSize: FontSizes.base,
|
|
color: 'rgba(255,255,255,0.7)',
|
|
},
|
|
listeningStatus: {
|
|
fontSize: FontSizes.sm,
|
|
color: 'rgba(255,255,255,0.5)',
|
|
marginTop: Spacing.md,
|
|
fontStyle: 'italic',
|
|
},
|
|
errorContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginTop: Spacing.md,
|
|
paddingHorizontal: Spacing.lg,
|
|
},
|
|
errorText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.error,
|
|
marginLeft: Spacing.sm,
|
|
flex: 1,
|
|
},
|
|
controls: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-evenly',
|
|
alignItems: 'center',
|
|
paddingVertical: Spacing.xl,
|
|
paddingHorizontal: Spacing.lg,
|
|
},
|
|
controlButton: {
|
|
alignItems: 'center',
|
|
padding: Spacing.md,
|
|
borderRadius: BorderRadius.full,
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
width: 70,
|
|
height: 70,
|
|
justifyContent: 'center',
|
|
},
|
|
controlButtonActive: {
|
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
|
},
|
|
controlLabel: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.white,
|
|
marginTop: 4,
|
|
},
|
|
endCallButton: {
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 36,
|
|
backgroundColor: AppColors.error,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
transform: [{ rotate: '135deg' }],
|
|
shadowColor: AppColors.error,
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.4,
|
|
shadowRadius: 8,
|
|
elevation: 8,
|
|
},
|
|
// Logs panel styles
|
|
logsPanel: {
|
|
position: 'absolute',
|
|
top: 80,
|
|
left: Spacing.md,
|
|
right: Spacing.md,
|
|
bottom: 180,
|
|
backgroundColor: 'rgba(0,0,0,0.9)',
|
|
borderRadius: BorderRadius.lg,
|
|
padding: Spacing.sm,
|
|
zIndex: 100,
|
|
},
|
|
logsPanelMinimized: {
|
|
bottom: 'auto' as any,
|
|
height: 44,
|
|
},
|
|
logsPanelHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: Spacing.sm,
|
|
paddingBottom: Spacing.sm,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: 'rgba(255,255,255,0.2)',
|
|
},
|
|
minimizeButton: {
|
|
padding: 4,
|
|
marginRight: Spacing.sm,
|
|
},
|
|
logsPanelTitle: {
|
|
flex: 1,
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '600',
|
|
color: AppColors.white,
|
|
},
|
|
logsPanelButtons: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
copyButton: {
|
|
padding: 6,
|
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
borderRadius: BorderRadius.sm,
|
|
},
|
|
clearButton: {
|
|
padding: 6,
|
|
backgroundColor: 'rgba(255,255,255,0.15)',
|
|
borderRadius: BorderRadius.sm,
|
|
},
|
|
closeLogsButton: {
|
|
padding: 6,
|
|
},
|
|
logsScrollView: {
|
|
flex: 1,
|
|
},
|
|
logEntry: {
|
|
fontSize: 11,
|
|
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
|
color: '#4ade80',
|
|
lineHeight: 16,
|
|
marginBottom: 2,
|
|
},
|
|
logEntryError: {
|
|
color: '#f87171',
|
|
},
|
|
logEntryWarn: {
|
|
color: '#fbbf24',
|
|
},
|
|
logEntryEmpty: {
|
|
fontSize: FontSizes.xs,
|
|
color: 'rgba(255,255,255,0.5)',
|
|
fontStyle: 'italic',
|
|
textAlign: 'center',
|
|
marginTop: Spacing.lg,
|
|
},
|
|
});
|