/** * Voice Call Screen - Fullscreen LiveKit Voice Call * * Opens as a modal from chat, returns to chat when call ends. * Beautiful phone call-like UI with Julia AI. * Uses self-hosted LiveKit Server + Deepgram STT/TTS. */ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Animated, Easing, Dimensions, ScrollView, Alert, AppState, AppStateStatus, } 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 { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; // NOTE: Room and other core classes must be imported from livekit-client, not @livekit/react-native! // @livekit/react-native only provides registerGlobals(), React hooks, and components. import type { Room as RoomType } from 'livekit-client'; import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme'; import { getToken, VOICE_NAME } from '@/services/livekitService'; import { useVoiceTranscript } from '@/contexts/VoiceTranscriptContext'; import { debugLogger } from '@/services/DebugLogger'; // Polyfill Event class for React Native (livekit-client needs it) if (typeof global.Event === 'undefined') { (global as any).Event = class Event { type: string; bubbles: boolean; cancelable: boolean; defaultPrevented: boolean; constructor(type: string, options?: { bubbles?: boolean; cancelable?: boolean }) { this.type = type; this.bubbles = options?.bubbles ?? false; this.cancelable = options?.cancelable ?? false; this.defaultPrevented = false; } preventDefault() { this.defaultPrevented = true; } stopPropagation() {} stopImmediatePropagation() {} }; } const { width: SCREEN_WIDTH } = Dimensions.get('window'); type CallState = 'connecting' | 'active' | 'ending'; export default function VoiceCallScreen() { const router = useRouter(); const { addTranscriptEntry, clearTranscript } = useVoiceTranscript(); // Call state const [callState, setCallState] = useState('connecting'); const [isMuted, setIsMuted] = useState(false); const [callDuration, setCallDuration] = useState(0); const [statusText, setStatusText] = useState('Connecting...'); const callStartTimeRef = useRef(null); // Debug logs const [logs, setLogs] = useState([]); const [showLogs, setShowLogs] = useState(false); const [logsMinimized, setLogsMinimized] = useState(false); const logsScrollRef = useRef(null); // Add log entry - both local and global const addLog = useCallback((message: string) => { const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false }); setLogs(prev => [...prev, `[${timestamp}] ${message}`]); // Also send to global debug logger so it shows on Debug tab debugLogger.info('VOICE', message); }, []); // Copy logs to clipboard const copyLogs = useCallback(async () => { const logsText = logs.join('\n'); await Clipboard.setStringAsync(logsText); Alert.alert('Copied!', `${logs.length} log entries copied to clipboard`); }, [logs]); // LiveKit room reference const roomRef = useRef(null); const isUnmountingRef = useRef(false); const connectionIdRef = useRef(0); // 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; // Background state tracking const appStateRef = useRef(AppState.currentState); // Keep screen awake during call & handle background mode useEffect(() => { // Prevent screen from sleeping during call activateKeepAwakeAsync('voiceCall').catch(() => {}); // Handle app going to background/foreground const handleAppStateChange = (nextAppState: AppStateStatus) => { const prevState = appStateRef.current; appStateRef.current = nextAppState; if (prevState.match(/inactive|background/) && nextAppState === 'active') { // App came back to foreground addLog('App returned to foreground'); } else if (prevState === 'active' && nextAppState.match(/inactive|background/)) { // App went to background - DON'T disconnect, keep call alive! addLog('App went to background - call continues'); // The UIBackgroundModes: ["audio", "voip"] in app.json keeps audio alive } }; const subscription = AppState.addEventListener('change', handleAppStateChange); return () => { subscription.remove(); deactivateKeepAwake('voiceCall'); }; }, [addLog]); // Start call on mount useEffect(() => { // Track current connection attempt const currentConnectionId = ++connectionIdRef.current; isUnmountingRef.current = false; const startCall = async () => { try { // Clear previous transcript before starting new call clearTranscript(); addLog('Starting voice call...'); // Check if unmounting if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { addLog('Aborted: screen is closing'); return; } // CRITICAL: Ensure WebRTC globals are registered BEFORE importing livekit-client // This MUST happen first, otherwise Room class won't work const { registerGlobals, AudioSession } = await import('@livekit/react-native'); // Check if globals already registered, if not - register them if (typeof global.RTCPeerConnection === 'undefined') { addLog('Registering WebRTC globals...'); registerGlobals(); } else { addLog('WebRTC globals already registered'); } // Check again if unmounting after async import if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { addLog('Aborted: screen is closing'); return; } // NOW it's safe to import livekit-client addLog('Importing livekit-client...'); const { Room, RoomEvent, ConnectionState, Track, } = await import('livekit-client'); addLog(`Room class: ${typeof Room} ${Room ? 'OK' : 'MISSING'}`); addLog('LiveKit imported successfully'); // Check if unmounting if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { addLog('Aborted: screen is closing'); return; } // Configure iOS audio session if (Platform.OS === 'ios') { addLog('Starting iOS AudioSession...'); await AudioSession.startAudioSession(); addLog('iOS AudioSession started'); } // Get token from our server addLog('Requesting token from server...'); const result = await getToken(`user-${Date.now()}`); // Check if unmounting after token request if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { addLog('Aborted: screen is closing after token request'); return; } if (!result.success || !result.data) { throw new Error(result.error || 'Failed to get token'); } const { token, wsUrl, roomName } = result.data; addLog(`Token received. Room: ${roomName}`); addLog(`WebSocket URL: ${wsUrl}`); addLog(`Connecting to room: ${roomName}`); // Create and connect to room const room = new Room(); roomRef.current = room; // Setup event listeners room.on(RoomEvent.ConnectionStateChanged, (state: typeof ConnectionState[keyof typeof ConnectionState]) => { addLog(`Connection state: ${state}`); switch (state) { case ConnectionState.Connecting: setCallState('connecting'); setStatusText('Connecting...'); break; case ConnectionState.Connected: setCallState('active'); setStatusText('Connected'); if (!callStartTimeRef.current) { callStartTimeRef.current = Date.now(); } break; case ConnectionState.Reconnecting: setStatusText('Reconnecting...'); break; case ConnectionState.Disconnected: setCallState('ending'); setStatusText('Disconnected'); // Go back when disconnected setTimeout(() => router.back(), 500); break; } }); room.on(RoomEvent.TrackSubscribed, (track: any, publication: any, participant: any) => { addLog(`Track subscribed: ${track.kind} from ${participant.identity}`); if (track.kind === Track.Kind.Audio) { addLog('Audio track received - Julia should be speaking'); setStatusText('Julia is speaking...'); } }); room.on(RoomEvent.TrackUnsubscribed, (track: any, publication: any, participant: any) => { addLog(`Track unsubscribed: ${track.kind}`); }); room.on(RoomEvent.TrackMuted, (publication: any, participant: any) => { addLog(`Track muted: ${publication.trackSid} by ${participant.identity}`); }); room.on(RoomEvent.TrackUnmuted, (publication: any, participant: any) => { addLog(`Track unmuted: ${publication.trackSid} by ${participant.identity}`); }); room.on(RoomEvent.ParticipantConnected, (participant: any) => { addLog(`Participant connected: ${participant.identity}`); }); room.on(RoomEvent.ParticipantDisconnected, (participant: any) => { addLog(`Participant disconnected: ${participant.identity}`); }); room.on(RoomEvent.ActiveSpeakersChanged, (speakers: any[]) => { if (speakers.length > 0) { addLog(`Active speakers: ${speakers.map((s: any) => s.identity).join(', ')}`); } }); room.on(RoomEvent.DataReceived, (payload: any, participant: any) => { try { const data = JSON.parse(new TextDecoder().decode(payload)); addLog(`Data received: ${JSON.stringify(data).substring(0, 100)}`); // Handle transcript data from agent if (data.type === 'transcript') { if (data.role === 'user' && data.text) { addTranscriptEntry('user', data.text); } else if (data.role === 'assistant' && data.text) { addTranscriptEntry('assistant', data.text); } } } catch (e) { // Ignore non-JSON data } }); room.on(RoomEvent.AudioPlaybackStatusChanged, () => { addLog(`Audio playback can play: ${room.canPlaybackAudio}`); }); // Check if unmounting before connecting if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { addLog('Aborted: screen is closing before connect'); return; } // Connect to room await room.connect(wsUrl, token, { autoSubscribe: true, }); // Check if unmounting after connect if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { addLog('Aborted: screen is closing after connect, disconnecting...'); await room.disconnect().catch(() => {}); return; } // Enable microphone await room.localParticipant.setMicrophoneEnabled(true); addLog('Connected and microphone enabled'); addLog(`Local participant: ${room.localParticipant.identity}`); } catch (err: any) { // Ignore errors if screen is unmounting (expected race condition) if (isUnmountingRef.current || currentConnectionId !== connectionIdRef.current) { console.log('[VoiceCall] Error ignored (screen closing):', err?.message); return; } // Detailed error logging for debugging console.error('[VoiceCall] Failed to start call:', err); console.error('[VoiceCall] Error name:', err?.name); console.error('[VoiceCall] Error message:', err?.message); console.error('[VoiceCall] Error stack:', err?.stack); const errorMsg = err?.message || String(err); setStatusText(`Error: ${errorMsg.substring(0, 50)}`); // Go back on error setTimeout(() => router.back(), 2000); } }; startCall(); // Cleanup on unmount return () => { isUnmountingRef.current = true; const cleanup = async () => { if (roomRef.current) { try { await roomRef.current.disconnect(); } catch (e) { // Ignore errors during cleanup } roomRef.current = null; } if (Platform.OS === 'ios') { try { const { AudioSession } = await import('@livekit/react-native'); await AudioSession.stopAudioSession(); } catch (e) { // Ignore errors during cleanup } } }; cleanup(); }; }, []); // Call duration timer useEffect(() => { if (callState !== 'active') return; const interval = setInterval(() => { if (callStartTimeRef.current) { const elapsed = Math.floor((Date.now() - callStartTimeRef.current) / 1000); setCallDuration(elapsed); } }, 1000); return () => clearInterval(interval); }, [callState]); // Pulse animation for active call useEffect(() => { if (callState === 'active') { 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(); } }, [callState]); // Rotate animation for connecting useEffect(() => { if (callState === 'connecting') { 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); } }, [callState]); // End call const endCall = useCallback(async () => { setCallState('ending'); setStatusText('Ending call...'); try { if (roomRef.current) { await roomRef.current.disconnect(); roomRef.current = null; } } catch (err) { console.error('[VoiceCall] Error ending call:', err); } if (Platform.OS === 'ios') { try { const { AudioSession } = await import('@livekit/react-native'); await AudioSession.stopAudioSession(); await new Promise(resolve => setTimeout(resolve, 100)); } catch (err) { console.error('[VoiceCall] Error stopping audio:', err); } } router.back(); }, [router]); // Toggle mute const toggleMute = useCallback(async () => { if (roomRef.current) { const newMuted = !isMuted; await roomRef.current.localParticipant.setMicrophoneEnabled(!newMuted); setIsMuted(newMuted); } }, [isMuted]); // Format duration const formatDuration = (seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; }; const spin = rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'], }); return ( {/* Background gradient effect */} {/* Top bar with back button */} LiveKit + Deepgram setShowLogs(!showLogs)} > {/* Main content */} {/* Avatar */} J {callState === 'active' && ( )} {/* Name and status */} Julia AI {VOICE_NAME} voice {callState === 'active' ? ( {formatDuration(callDuration)} ) : ( {statusText} )} {/* Status indicator */} {callState === 'active' && ( {statusText} )} {/* Debug logs panel */} {showLogs && ( setLogsMinimized(!logsMinimized)} > Logs ({logs.length}) setShowLogs(false)} > {!logsMinimized && ( logsScrollRef.current?.scrollToEnd()} > {logs.map((log, index) => ( {log} ))} {logs.length === 0 && ( Waiting for events... )} )} )} {/* Bottom controls */} {/* Mute button */} {isMuted ? 'Unmute' : 'Mute'} {/* End call button */} {/* Speaker button (placeholder) */} Speaker ); } 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)', }, 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', }, 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, }, closeLogsButton: { padding: 6, }, logsScrollView: { flex: 1, }, logEntry: { fontSize: 11, fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', color: '#4ade80', lineHeight: 16, marginBottom: 2, }, logEntryEmpty: { fontSize: FontSizes.xs, color: 'rgba(255,255,255,0.5)', fontStyle: 'italic', textAlign: 'center', marginTop: Spacing.lg, }, });