/** * 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 and speaker toggle * - Proper cleanup on unmount */ import React, { useEffect, useRef } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Animated, Easing, Dimensions } from 'react-native'; 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'; import { setAudioOutput } from '@/utils/audioSession'; const { width: SCREEN_WIDTH } = Dimensions.get('window'); export default function VoiceCallScreen() { const router = useRouter(); const { clearTranscript, addTranscriptEntry } = useVoiceTranscript(); // Speaker/earpiece toggle state const [isSpeakerOn, setIsSpeakerOn] = React.useState(true); // LiveKit hook - ALL logic is here const { state, error, callDuration, isMuted, isAgentSpeaking, canPlayAudio, participantCount, connect, disconnect, toggleMute, } = 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(); }; // Toggle speaker/earpiece const handleToggleSpeaker = async () => { const newSpeakerState = !isSpeakerOn; setIsSpeakerOn(newSpeakerState); await setAudioOutput(newSpeakerState); }; // 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 ( {/* Background gradient effect */} {/* Top bar - minimal */} {/* Main content */} {/* Avatar */} J {isActive && } {/* Name and status */} Julia AI {VOICE_NAME} voice {isActive ? ( {formatDuration(callDuration)} ) : ( {getStatusText()} )} {/* Additional status info */} {isActive && ( {getStatusText()} {participantCount > 1 && ` • ${participantCount} participants`} )} {/* Error display */} {state === 'error' && error && ( {error} )} {/* Bottom controls */} {/* Mute button */} {isMuted ? 'Unmute' : 'Mute'} {/* End call button */} {/* Speaker/Earpiece toggle */} {isSpeakerOn ? 'Speaker' : 'Earpiece'} ); } 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', }, 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, }, });