wellnua-lite/app/voice-call.tsx
Sergei cd9dddda34 Add Chat tab with Julia AI + voice call improvements
- Enable Chat tab (replace Debug) - text chat with Julia AI
- Add voice call button in chat header and input area
- Add speaker/earpiece toggle in voice-call screen
- setAudioOutput() function for switching audio output
2026-01-18 22:00:26 -08:00

660 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';
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<ScrollView>(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 (
<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/Earpiece toggle */}
<TouchableOpacity
style={[styles.controlButton, isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
disabled={!isActive}
>
<Ionicons
name={isSpeakerOn ? 'volume-high' : 'ear'}
size={28}
color={isSpeakerOn ? AppColors.success : AppColors.white}
/>
<Text style={styles.controlLabel}>{isSpeakerOn ? 'Speaker' : 'Earpiece'}</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,
},
});