/** * 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'; import { setAudioOutput } from '@/utils/audioSession'; 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(null); // Speaker/earpiece toggle state const [isSpeakerOn, setIsSpeakerOn] = React.useState(true); // 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(); }; // Toggle speaker/earpiece const handleToggleSpeaker = async () => { const newSpeakerState = !isSpeakerOn; setIsSpeakerOn(newSpeakerState); await setAudioOutput(newSpeakerState); }; // 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 ( {/* Background gradient effect */} {/* Top bar */} LiveKit + Deepgram {roomName && ( {roomName} )} setShowLogs(!showLogs)} > {/* 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} )} {/* Debug logs panel */} {showLogs && ( setLogsMinimized(!logsMinimized)} > Logs ({logs.length}) • State: {state} setShowLogs(false)} > {!logsMinimized && ( logsScrollRef.current?.scrollToEnd()} > {logs.map((log, index) => ( [{log.timestamp}] {log.message} ))} {logs.length === 0 && ( Waiting for events... )} )} )} {/* 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', }, 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, }, });