feat(CallFeature): implement call functionality and integrate WebSocket signaling
- Updated app.json to include microphone permissions and background audio support. - Added call-related dependencies in package.json and package-lock.json. - Enhanced ChatScreen to initiate calls and handle call actions. - Introduced call components in the layout for incoming call handling. - Expanded WebSocket service to manage call signaling messages and events. - Refactored authStore to initialize call state on user authentication.
This commit is contained in:
7
app.json
7
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "萝卜社区",
|
||||
"slug": "qojo",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.12",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -15,11 +15,13 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
"remote-notification",
|
||||
"audio"
|
||||
],
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
@@ -37,6 +39,7 @@
|
||||
"versionCode": 6,
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECORD_AUDIO",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
|
||||
@@ -22,6 +22,7 @@ import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '../src/services/alertOverride';
|
||||
import { useAuthStore } from '../src/stores';
|
||||
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
||||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
@@ -219,6 +220,9 @@ export default function RootLayout() {
|
||||
<ThemedStack />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
<IncomingCallModal />
|
||||
<CallScreen />
|
||||
<FloatingCallWindow />
|
||||
</QueryClientProvider>
|
||||
</ThemedProviders>
|
||||
</SafeAreaProvider>
|
||||
|
||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -54,6 +54,7 @@
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webrtc": "^124.0.7",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
@@ -9799,6 +9800,55 @@
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-webrtc": {
|
||||
"version": "124.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
|
||||
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "1.5.1",
|
||||
"debug": "4.3.4",
|
||||
"event-target-shim": "6.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-webrtc/node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
|
||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
|
||||
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/mysticatea"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-webrtc/node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-webview": {
|
||||
"version": "13.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -60,6 +60,7 @@
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webrtc": "^124.0.7",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
|
||||
309
src/components/call/CallScreen.tsx
Normal file
309
src/components/call/CallScreen.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
import { RTCView } from 'react-native-webrtc';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const peerStream = callStore((s) => s.peerStream);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const toggleMute = callStore((s) => s.toggleMute);
|
||||
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'ringing':
|
||||
return '正在等待对方接听...';
|
||||
case 'connecting':
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'ending':
|
||||
return '通话已结束';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall('hangup');
|
||||
};
|
||||
|
||||
const handleToggleMute = () => {
|
||||
toggleMute();
|
||||
};
|
||||
|
||||
const handleToggleSpeaker = () => {
|
||||
toggleSpeaker();
|
||||
};
|
||||
|
||||
const handleMinimize = () => {
|
||||
toggleMinimize();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
|
||||
{/* Background - single consistent color */}
|
||||
<View style={styles.background} />
|
||||
|
||||
{/* Remote video preview */}
|
||||
{peerStream && (
|
||||
<View style={styles.remoteVideoContainer}>
|
||||
<RTCView
|
||||
streamURL={peerStream?.toURL()}
|
||||
style={styles.remoteVideo}
|
||||
objectFit="cover"
|
||||
mirror
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Top area: minimize button */}
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.topButton}
|
||||
onPress={handleMinimize}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Center: Peer info */}
|
||||
<View style={styles.centerArea}>
|
||||
<View style={styles.avatarOuter}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.peerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
<View style={styles.controlRow}>
|
||||
{/* Mute */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
||||
onPress={handleToggleMute}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
|
||||
size={26}
|
||||
color={currentCall.isMuted ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
|
||||
{currentCall.isMuted ? '取消静音' : '静音'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Speaker */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
||||
onPress={handleToggleSpeaker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
|
||||
size={26}
|
||||
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
|
||||
{currentCall.isSpeakerOn ? '免提' : '听筒'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* End call - prominent red button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.endCallCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.endCallLabel}>挂断</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const CONTROL_CIRCLE_SIZE = 56;
|
||||
const END_CALL_SIZE = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
zIndex: 9999,
|
||||
},
|
||||
background: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 44,
|
||||
},
|
||||
topButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
centerArea: {
|
||||
position: 'absolute',
|
||||
top: '28%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
avatarOuter: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 40,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
peerName: {
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 6,
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
status: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
remoteVideoContainer: {
|
||||
position: 'absolute',
|
||||
top: 70,
|
||||
right: 20,
|
||||
width: 100,
|
||||
height: 140,
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
remoteVideo: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 36,
|
||||
marginBottom: 24,
|
||||
},
|
||||
controlButton: {
|
||||
alignItems: 'center',
|
||||
width: 70,
|
||||
},
|
||||
controlCircle: {
|
||||
width: CONTROL_CIRCLE_SIZE,
|
||||
height: CONTROL_CIRCLE_SIZE,
|
||||
borderRadius: CONTROL_CIRCLE_SIZE / 2,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.12)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlCircleActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
controlButtonActive: {},
|
||||
controlLabel: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
marginTop: 8,
|
||||
},
|
||||
controlLabelActive: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
endCallButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallCircle: {
|
||||
width: END_CALL_SIZE,
|
||||
height: END_CALL_SIZE,
|
||||
borderRadius: END_CALL_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallLabel: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default CallScreen;
|
||||
153
src/components/call/FloatingCallWindow.tsx
Normal file
153
src/components/call/FloatingCallWindow.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const FloatingCallWindow: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
|
||||
// Only show when there's an active call and it's minimized
|
||||
if (!currentCall || !isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'ringing':
|
||||
return '等待接听...';
|
||||
case 'connecting':
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'ending':
|
||||
return '已结束';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
style={styles.window}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<View style={styles.avatarContainer}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image
|
||||
source={{ uri: currentCall.peerAvatar }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
|
||||
{/* End call button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
endCall('hangup');
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
left: 16,
|
||||
right: 16,
|
||||
zIndex: 10000,
|
||||
},
|
||||
window: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2A2A4E',
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 18,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 2,
|
||||
},
|
||||
status: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
endButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default FloatingCallWindow;
|
||||
274
src/components/call/IncomingCallModal.tsx
Normal file
274
src/components/call/IncomingCallModal.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
Animated,
|
||||
Modal,
|
||||
StatusBar,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
const IncomingCallModal: React.FC = () => {
|
||||
const incomingCall = callStore((s) => s.incomingCall);
|
||||
const acceptCall = callStore((s) => s.acceptCall);
|
||||
const rejectCall = callStore((s) => s.rejectCall);
|
||||
|
||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||
const rippleAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (incomingCall) {
|
||||
const pulse = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1.06,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(pulseAnim, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const ripple = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(rippleAnim, {
|
||||
toValue: 1,
|
||||
duration: 2000,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(rippleAnim, {
|
||||
toValue: 0,
|
||||
duration: 0,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
Animated.parallel([pulse, ripple]).start();
|
||||
return () => {
|
||||
pulse.stop();
|
||||
ripple.stop();
|
||||
};
|
||||
}
|
||||
}, [incomingCall, pulseAnim, rippleAnim]);
|
||||
|
||||
const handleAccept = () => {
|
||||
acceptCall();
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
rejectCall();
|
||||
};
|
||||
|
||||
if (!incomingCall) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!incomingCall}
|
||||
transparent
|
||||
animationType="fade"
|
||||
statusBarTranslucent
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
|
||||
{/* Avatar with ripple effect - positioned in upper area */}
|
||||
<View style={styles.avatarSection}>
|
||||
{/* Ripple rings */}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Animated.View
|
||||
key={i}
|
||||
style={[
|
||||
styles.rippleRing,
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale: rippleAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [1, 1.5 + i * 0.2],
|
||||
}),
|
||||
},
|
||||
],
|
||||
opacity: rippleAnim.interpolate({
|
||||
inputRange: [0, 0.4, 1],
|
||||
outputRange: [0.25, 0.12, 0],
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Animated.View
|
||||
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
|
||||
>
|
||||
{incomingCall.callerAvatar ? (
|
||||
<Image
|
||||
source={{ uri: incomingCall.callerAvatar }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Caller info */}
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.callerName} numberOfLines={1}>
|
||||
{incomingCall.callerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.callType}>来电通话</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
{/* Decline */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={handleReject}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.declineCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Accept */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={handleAccept}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.acceptCircle}>
|
||||
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.actionLabel}>接听</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const AVATAR_SIZE = 100;
|
||||
const ACTION_CIRCLE_SIZE = 60;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0D0D0D',
|
||||
},
|
||||
avatarSection: {
|
||||
position: 'absolute',
|
||||
top: '18%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: AVATAR_SIZE + 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rippleRing: {
|
||||
position: 'absolute',
|
||||
width: AVATAR_SIZE + 16,
|
||||
height: AVATAR_SIZE + 16,
|
||||
borderRadius: (AVATAR_SIZE + 16) / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: '#4CD964',
|
||||
},
|
||||
avatarContainer: {
|
||||
width: AVATAR_SIZE + 8,
|
||||
height: AVATAR_SIZE + 8,
|
||||
borderRadius: (AVATAR_SIZE + 8) / 2,
|
||||
backgroundColor: '#4CD964',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatar: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 36,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
infoSection: {
|
||||
position: 'absolute',
|
||||
top: '42%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
callerName: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 6,
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
callType: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: '12%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 60,
|
||||
},
|
||||
actionButton: {
|
||||
alignItems: 'center',
|
||||
width: 80,
|
||||
},
|
||||
declineCircle: {
|
||||
width: ACTION_CIRCLE_SIZE,
|
||||
height: ACTION_CIRCLE_SIZE,
|
||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
acceptCircle: {
|
||||
width: ACTION_CIRCLE_SIZE,
|
||||
height: ACTION_CIRCLE_SIZE,
|
||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||
backgroundColor: '#4CD964',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionLabel: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
marginTop: 10,
|
||||
},
|
||||
});
|
||||
|
||||
export default IncomingCallModal;
|
||||
3
src/components/call/index.ts
Normal file
3
src/components/call/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CallScreen } from './CallScreen';
|
||||
export { default as IncomingCallModal } from './IncomingCallModal';
|
||||
export { default as FloatingCallWindow } from './FloatingCallWindow';
|
||||
@@ -35,7 +35,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||
import { useAppColors } from '../../theme';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { messageManager } from '../../stores';
|
||||
import { messageManager, callStore } from '../../stores';
|
||||
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import {
|
||||
useChatScreen,
|
||||
@@ -423,6 +423,15 @@ export const ChatScreen: React.FC = () => {
|
||||
onMorePress={navigateToChatSettings}
|
||||
onGroupInfoPress={handleGroupInfoPress}
|
||||
isWideScreen={isWideScreen}
|
||||
onCallPress={!isGroupChat ? () => {
|
||||
const otherUserId = otherUser?.id;
|
||||
if (otherUserId && conversationId) {
|
||||
callStore.getState().startCall(conversationId, otherUserId, {
|
||||
nickname: otherUser?.nickname,
|
||||
avatar: otherUser?.avatar,
|
||||
});
|
||||
}
|
||||
} : undefined}
|
||||
/>
|
||||
|
||||
{/* 消息列表 */}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
onMorePress,
|
||||
onGroupInfoPress,
|
||||
isWideScreen: propIsWideScreen,
|
||||
onCallPress,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const baseStyles = useChatScreenStyles();
|
||||
@@ -124,13 +125,30 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
) : isGroupChat ? (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onMorePress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.headerActions}>
|
||||
{onCallPress && (
|
||||
<TouchableOpacity
|
||||
style={styles.callButton}
|
||||
onPress={onCallPress}
|
||||
>
|
||||
<MaterialCommunityIcons name="phone" size={22} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onMorePress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -114,11 +114,22 @@ export function createChatScreenStyles(colors: AppColors) {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 19,
|
||||
backgroundColor: colors.chat.surfaceRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.chat.borderLight,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
|
||||
headerActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
callButton: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 19,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
},
|
||||
|
||||
// 消息列表
|
||||
messageListContainer: {
|
||||
flex: 1,
|
||||
|
||||
@@ -179,6 +179,8 @@ export interface ChatHeaderProps {
|
||||
onGroupInfoPress?: () => void;
|
||||
/** 是否为大屏幕模式 */
|
||||
isWideScreen?: boolean;
|
||||
/** 拨打电话回调(仅私聊有效) */
|
||||
onCallPress?: () => void;
|
||||
}
|
||||
|
||||
// 回复预览 Props
|
||||
|
||||
281
src/services/webrtc/WebRTCManager.ts
Normal file
281
src/services/webrtc/WebRTCManager.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
RTCPeerConnection,
|
||||
RTCSessionDescription,
|
||||
RTCIceCandidate,
|
||||
mediaDevices,
|
||||
MediaStream,
|
||||
MediaStreamTrack,
|
||||
} from 'react-native-webrtc';
|
||||
|
||||
export interface ICEServer {
|
||||
urls: string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||
|
||||
export type WebRTCManagerEvent =
|
||||
| { type: 'icecandidate'; candidate: RTCIceCandidate | null }
|
||||
| { type: 'connectionstatechange'; state: ConnectionState }
|
||||
| { type: 'remotestream'; stream: MediaStream }
|
||||
| { type: 'error'; error: Error };
|
||||
|
||||
type EventHandler = (event: WebRTCManagerEvent) => void;
|
||||
|
||||
class WebRTCManager {
|
||||
private peerConnection: RTCPeerConnection | null = null;
|
||||
private localStream: MediaStream | null = null;
|
||||
private remoteStream: MediaStream | null = null;
|
||||
private pendingCandidates: RTCIceCandidateInit[] = [];
|
||||
private iceServers: ICEServer[] = [];
|
||||
private eventHandlers: Set<EventHandler> = new Set();
|
||||
private disposed = false;
|
||||
private isInitiator = false;
|
||||
|
||||
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||
if (this.peerConnection) {
|
||||
this.dispose();
|
||||
}
|
||||
this.disposed = false;
|
||||
this.iceServers = iceServers.length > 0
|
||||
? iceServers
|
||||
: [{ urls: ['stun:stun.l.google.com:19302'] }];
|
||||
}
|
||||
|
||||
private buildPeerConnectionConfig(): RTCConfiguration {
|
||||
return {
|
||||
iceServers: this.iceServers.map((server) => ({
|
||||
urls: server.urls,
|
||||
...(server.username ? { username: server.username } : {}),
|
||||
...(server.credential ? { credential: server.credential } : {}),
|
||||
})),
|
||||
iceCandidatePoolSize: 10,
|
||||
};
|
||||
}
|
||||
|
||||
private createPeerConnection(): RTCPeerConnection {
|
||||
const config = this.buildPeerConnectionConfig();
|
||||
const pc = new RTCPeerConnection(config);
|
||||
|
||||
// @ts-ignore - react-native-webrtc uses on* handlers instead of addEventListener
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.emit({ type: 'icecandidate', candidate: event.candidate });
|
||||
} else {
|
||||
this.emit({ type: 'icecandidate', candidate: null });
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const state = pc.iceConnectionState as ConnectionState;
|
||||
this.emit({ type: 'connectionstatechange', state });
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState as ConnectionState;
|
||||
this.emit({ type: 'connectionstatechange', state });
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.ontrack = (event) => {
|
||||
if (event.streams && event.streams[0]) {
|
||||
this.remoteStream = event.streams[0];
|
||||
this.emit({ type: 'remotestream', stream: event.streams[0] });
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onsignalingstatechange = () => {
|
||||
// Could emit state change here if needed
|
||||
};
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
|
||||
const constraints = voiceOnly
|
||||
? { audio: true, video: false }
|
||||
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
|
||||
|
||||
try {
|
||||
// @ts-ignore - react-native-webrtc has different constraint types
|
||||
this.localStream = await mediaDevices.getUserMedia(constraints);
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to get local media stream:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
|
||||
if (this.disposed) throw new Error('WebRTCManager has been disposed');
|
||||
if (!this.localStream) throw new Error('Local stream not initialized');
|
||||
|
||||
this.isInitiator = isInitiator;
|
||||
this.peerConnection = this.createPeerConnection();
|
||||
|
||||
// Add local tracks to peer connection
|
||||
for (const track of this.localStream.getTracks()) {
|
||||
this.peerConnection.addTrack(track, this.localStream);
|
||||
}
|
||||
|
||||
if (isInitiator) {
|
||||
const offer = await this.createOffer();
|
||||
return offer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
const offerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: false,
|
||||
};
|
||||
|
||||
const offer = await this.peerConnection.createOffer(offerOptions);
|
||||
await this.peerConnection.setLocalDescription(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
const answer = await this.peerConnection.createAnswer();
|
||||
await this.peerConnection.setLocalDescription(answer);
|
||||
// Process pending candidates after local description is set
|
||||
await this.processPendingCandidates();
|
||||
return answer;
|
||||
}
|
||||
|
||||
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
||||
await this.setRemoteDescription(offer);
|
||||
return this.createAnswer();
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
if (!description.sdp) {
|
||||
throw new Error('setRemoteDescription: sdp is required');
|
||||
}
|
||||
const desc = new RTCSessionDescription({
|
||||
type: description.type,
|
||||
sdp: description.sdp,
|
||||
});
|
||||
await this.peerConnection.setRemoteDescription(desc);
|
||||
|
||||
// Process pending candidates after remote description is set
|
||||
await this.processPendingCandidates();
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (!this.peerConnection) {
|
||||
this.pendingCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.peerConnection.remoteDescription) {
|
||||
this.pendingCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const iceCandidate = new RTCIceCandidate(candidate);
|
||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to add ICE candidate:', error);
|
||||
// Still push to pending in case order matters
|
||||
this.pendingCandidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private async processPendingCandidates(): Promise<void> {
|
||||
if (this.pendingCandidates.length === 0) return;
|
||||
if (!this.peerConnection) return;
|
||||
|
||||
const candidates = [...this.pendingCandidates];
|
||||
this.pendingCandidates = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const iceCandidate = new RTCIceCandidate(candidate);
|
||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to add pending ICE candidate:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (!this.localStream) return;
|
||||
const audioTracks = this.localStream.getAudioTracks();
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
if (!this.localStream) return false;
|
||||
const audioTracks = this.localStream.getAudioTracks();
|
||||
return audioTracks.some((track) => !track.enabled);
|
||||
}
|
||||
|
||||
getRemoteStream(): MediaStream | null {
|
||||
return this.remoteStream;
|
||||
}
|
||||
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.localStream;
|
||||
}
|
||||
|
||||
getPeerConnection(): RTCPeerConnection | null {
|
||||
return this.peerConnection;
|
||||
}
|
||||
|
||||
onEvent(handler: EventHandler): () => void {
|
||||
this.eventHandlers.add(handler);
|
||||
return () => {
|
||||
this.eventHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(event: WebRTCManagerEvent): void {
|
||||
this.eventHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Event handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.eventHandlers.clear();
|
||||
this.pendingCandidates = [];
|
||||
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach((track) => track.stop());
|
||||
this.localStream.release();
|
||||
this.localStream = null;
|
||||
}
|
||||
|
||||
if (this.remoteStream) {
|
||||
this.remoteStream.getTracks().forEach((track) => track.stop());
|
||||
this.remoteStream.release();
|
||||
this.remoteStream = null;
|
||||
}
|
||||
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const webrtcManager = new WebRTCManager();
|
||||
2
src/services/webrtc/index.ts
Normal file
2
src/services/webrtc/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { webrtcManager } from './WebRTCManager';
|
||||
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';
|
||||
@@ -30,7 +30,106 @@ export type WSMessageType =
|
||||
| 'pong'
|
||||
| 'message_sent'
|
||||
| 'message_recalled'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'call_incoming'
|
||||
| 'call_accepted'
|
||||
| 'call_rejected'
|
||||
| 'call_busy'
|
||||
| 'call_sdp'
|
||||
| 'call_ice'
|
||||
| 'call_ended'
|
||||
| 'call_peer_muted'
|
||||
| 'call_invited'
|
||||
| 'call_answered_elsewhere';
|
||||
|
||||
export interface WSCallIncomingMessage {
|
||||
type: 'call_incoming';
|
||||
call_id: string;
|
||||
conversation_id: string;
|
||||
caller_id: string;
|
||||
call_type: string;
|
||||
created_at: number;
|
||||
lifetime?: number;
|
||||
ice_servers?: ICEServerConfig[];
|
||||
}
|
||||
|
||||
export interface ICEServerConfig {
|
||||
urls: string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export interface WSCallAcceptedMessage {
|
||||
type: 'call_accepted';
|
||||
call_id: string;
|
||||
started_at: number;
|
||||
ice_servers?: ICEServerConfig[];
|
||||
}
|
||||
|
||||
export interface WSCallRejectedMessage {
|
||||
type: 'call_rejected';
|
||||
call_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSCallBusyMessage {
|
||||
type: 'call_busy';
|
||||
call_id: string;
|
||||
}
|
||||
|
||||
export interface WSCallEndedMessage {
|
||||
type: 'call_ended';
|
||||
call_id: string;
|
||||
ended_by?: string;
|
||||
reason?: string;
|
||||
duration?: number;
|
||||
ended_at?: number;
|
||||
}
|
||||
|
||||
export interface WSCallSDPMessage {
|
||||
type: 'call_sdp';
|
||||
call_id: string;
|
||||
from_id: string;
|
||||
payload: {
|
||||
sdp_type: string;
|
||||
sdp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WSCallICEMessage {
|
||||
type: 'call_ice';
|
||||
call_id: string;
|
||||
from_id: string;
|
||||
payload: {
|
||||
candidate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WSCallPeerMutedMessage {
|
||||
type: 'call_peer_muted';
|
||||
call_id: string;
|
||||
user_id: string;
|
||||
muted: boolean;
|
||||
}
|
||||
|
||||
export interface WSCallInvitedMessage {
|
||||
type: 'call_invited';
|
||||
call_id: string;
|
||||
conversation_id: string;
|
||||
callee_id: string;
|
||||
}
|
||||
|
||||
export interface WSCallAnsweredElsewhereMessage {
|
||||
type: 'call_answered_elsewhere';
|
||||
call_id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WSErrorMessage {
|
||||
type: 'error';
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WSChatMessage {
|
||||
type: 'chat';
|
||||
@@ -166,7 +265,18 @@ export type WSMessage =
|
||||
| WSGroupNoticeMessage
|
||||
| WSGroupMentionMessage
|
||||
| WSGroupReadMessage
|
||||
| WSGroupRecallMessage;
|
||||
| WSGroupRecallMessage
|
||||
| WSCallIncomingMessage
|
||||
| WSCallAcceptedMessage
|
||||
| WSCallRejectedMessage
|
||||
| WSCallBusyMessage
|
||||
| WSCallEndedMessage
|
||||
| WSCallSDPMessage
|
||||
| WSCallICEMessage
|
||||
| WSCallPeerMutedMessage
|
||||
| WSCallInvitedMessage
|
||||
| WSCallAnsweredElsewhereMessage
|
||||
| WSErrorMessage;
|
||||
|
||||
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
|
||||
type ConnectionHandler = () => void;
|
||||
@@ -273,17 +383,35 @@ class WebSocketService {
|
||||
}
|
||||
|
||||
private handleIncoming(data: string): void {
|
||||
try {
|
||||
const msg: WSServerMessage = JSON.parse(data);
|
||||
|
||||
// 后端 writePump 会将多条消息以 \n 分隔合并发送,需要逐条解析
|
||||
const lines = data.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const msg: WSServerMessage = JSON.parse(trimmed);
|
||||
this.processMessage(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error, 'raw:', trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private processMessage(msg: WSServerMessage): void {
|
||||
// 处理pong响应
|
||||
if (msg.type === 'pong') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理错误消息
|
||||
// 处理错误消息 - emit 到上层处理
|
||||
if (msg.type === 'error') {
|
||||
console.error('WebSocket error:', msg.payload);
|
||||
const err: WSErrorMessage = {
|
||||
type: 'error',
|
||||
code: msg.payload?.code || 'unknown',
|
||||
message: msg.payload?.message || 'Unknown error',
|
||||
};
|
||||
this.emit('error', err);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -306,9 +434,6 @@ class WebSocketService {
|
||||
|
||||
// 分发事件
|
||||
this.dispatchServerMessage(msg);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchServerMessage(msg: WSServerMessage): void {
|
||||
@@ -333,6 +458,37 @@ class WebSocketService {
|
||||
case 'system_notification':
|
||||
this.handleSystemNotification(payload);
|
||||
break;
|
||||
// Call signaling messages
|
||||
case 'call_incoming':
|
||||
this.handleCallIncoming(payload);
|
||||
break;
|
||||
case 'call_accepted':
|
||||
this.handleCallAccepted(payload);
|
||||
break;
|
||||
case 'call_rejected':
|
||||
this.handleCallRejected(payload);
|
||||
break;
|
||||
case 'call_busy':
|
||||
this.handleCallBusy(payload);
|
||||
break;
|
||||
case 'call_sdp':
|
||||
this.handleCallSDP(payload);
|
||||
break;
|
||||
case 'call_ice':
|
||||
this.handleCallICE(payload);
|
||||
break;
|
||||
case 'call_ended':
|
||||
this.handleCallEnded(payload);
|
||||
break;
|
||||
case 'call_peer_muted':
|
||||
this.handleCallPeerMuted(payload);
|
||||
break;
|
||||
case 'call_invited':
|
||||
this.handleCallInvited(payload);
|
||||
break;
|
||||
case 'call_answered_elsewhere':
|
||||
this.handleCallAnsweredElsewhere(payload);
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown message type:', type);
|
||||
}
|
||||
@@ -458,6 +614,152 @@ class WebSocketService {
|
||||
systemNotificationService.handleWSMessage(m as any).catch(() => {});
|
||||
}
|
||||
|
||||
// Call signaling handlers
|
||||
private handleCallIncoming(payload: any): void {
|
||||
const m: WSCallIncomingMessage = {
|
||||
type: 'call_incoming',
|
||||
call_id: payload.call_id,
|
||||
conversation_id: payload.conversation_id,
|
||||
caller_id: payload.caller_id,
|
||||
call_type: payload.call_type,
|
||||
created_at: payload.created_at,
|
||||
lifetime: payload.lifetime,
|
||||
ice_servers: payload.ice_servers,
|
||||
};
|
||||
this.emit('call_incoming', m);
|
||||
}
|
||||
|
||||
private handleCallAccepted(payload: any): void {
|
||||
const m: WSCallAcceptedMessage = {
|
||||
type: 'call_accepted',
|
||||
call_id: payload.call_id,
|
||||
started_at: payload.started_at,
|
||||
ice_servers: payload.ice_servers,
|
||||
};
|
||||
this.emit('call_accepted', m);
|
||||
}
|
||||
|
||||
private handleCallRejected(payload: any): void {
|
||||
const m: WSCallRejectedMessage = {
|
||||
type: 'call_rejected',
|
||||
call_id: payload.call_id,
|
||||
reason: payload.reason,
|
||||
};
|
||||
this.emit('call_rejected', m);
|
||||
}
|
||||
|
||||
private handleCallBusy(payload: any): void {
|
||||
const m: WSCallBusyMessage = {
|
||||
type: 'call_busy',
|
||||
call_id: payload.call_id,
|
||||
};
|
||||
this.emit('call_busy', m);
|
||||
}
|
||||
|
||||
private handleCallSDP(payload: any): void {
|
||||
const m: WSCallSDPMessage = {
|
||||
type: 'call_sdp',
|
||||
call_id: payload.call_id,
|
||||
from_id: payload.from_id,
|
||||
payload: payload.payload,
|
||||
};
|
||||
this.emit('call_sdp', m);
|
||||
}
|
||||
|
||||
private handleCallICE(payload: any): void {
|
||||
const m: WSCallICEMessage = {
|
||||
type: 'call_ice',
|
||||
call_id: payload.call_id,
|
||||
from_id: payload.from_id,
|
||||
payload: payload.payload,
|
||||
};
|
||||
this.emit('call_ice', m);
|
||||
}
|
||||
|
||||
private handleCallEnded(payload: any): void {
|
||||
const m: WSCallEndedMessage = {
|
||||
type: 'call_ended',
|
||||
call_id: payload.call_id,
|
||||
ended_by: payload.ended_by,
|
||||
reason: payload.reason,
|
||||
duration: payload.duration,
|
||||
ended_at: payload.ended_at,
|
||||
};
|
||||
this.emit('call_ended', m);
|
||||
}
|
||||
|
||||
private handleCallPeerMuted(payload: any): void {
|
||||
const m: WSCallPeerMutedMessage = {
|
||||
type: 'call_peer_muted',
|
||||
call_id: payload.call_id,
|
||||
user_id: payload.user_id,
|
||||
muted: payload.muted,
|
||||
};
|
||||
this.emit('call_peer_muted', m);
|
||||
}
|
||||
|
||||
private handleCallInvited(payload: any): void {
|
||||
const m: WSCallInvitedMessage = {
|
||||
type: 'call_invited',
|
||||
call_id: payload.call_id,
|
||||
conversation_id: payload.conversation_id,
|
||||
callee_id: payload.callee_id,
|
||||
};
|
||||
this.emit('call_invited', m);
|
||||
}
|
||||
|
||||
private handleCallAnsweredElsewhere(payload: any): void {
|
||||
const m: WSCallAnsweredElsewhereMessage = {
|
||||
type: 'call_answered_elsewhere',
|
||||
call_id: payload.call_id,
|
||||
reason: payload.reason,
|
||||
};
|
||||
this.emit('call_answered_elsewhere', m);
|
||||
}
|
||||
|
||||
// Call signaling send methods
|
||||
sendCallInvite(conversationId: string, calleeId: string): void {
|
||||
this.sendFireAndForget('call_invite', {
|
||||
conversation_id: conversationId,
|
||||
callee_id: calleeId,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallAnswer(callId: string): void {
|
||||
this.sendFireAndForget('call_answer', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallReject(callId: string): void {
|
||||
this.sendFireAndForget('call_reject', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallBusy(callId: string): void {
|
||||
this.sendFireAndForget('call_busy', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallSDP(callId: string, sdpType: string, sdp: string): void {
|
||||
this.sendFireAndForget('call_sdp', {
|
||||
call_id: callId,
|
||||
sdp_type: sdpType,
|
||||
sdp,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallICE(callId: string, candidate: string): void {
|
||||
this.sendFireAndForget('call_ice', {
|
||||
call_id: callId,
|
||||
candidate,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallEnd(callId: string, reason?: string): void {
|
||||
this.sendFireAndForget('call_end', { call_id: callId, reason });
|
||||
}
|
||||
|
||||
sendCallMute(callId: string, muted: boolean): void {
|
||||
this.sendFireAndForget('call_mute', { call_id: callId, muted });
|
||||
}
|
||||
|
||||
private handleMessageSent(payload: any): void {
|
||||
// 找到对应的发送请求并resolve
|
||||
const pendingMsg = this.pendingMessages.find(p =>
|
||||
@@ -540,6 +842,15 @@ class WebSocketService {
|
||||
return api.post('/messages/delete_msg', { message_id: messageId });
|
||||
}
|
||||
|
||||
// 通过WebSocket发送(fire-and-forget,不等待确认)
|
||||
private sendFireAndForget(type: string, payload: any): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
const message = { type, payload };
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
// 通过WebSocket发送
|
||||
private sendViaWebSocket(type: string, payload: any): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { create } from 'zustand';
|
||||
import { User } from '../types';
|
||||
import { authService, resolveAuthApiError, LoginRequest, RegisterRequest } from '../services';
|
||||
import { wsService } from '../services/wsService';
|
||||
import { callStore } from './callStore';
|
||||
import {
|
||||
initDatabase,
|
||||
closeDatabase,
|
||||
@@ -96,6 +97,7 @@ function resolveLoginError(error: any): string {
|
||||
async function startRealtime(): Promise<void> {
|
||||
try {
|
||||
await wsService.start();
|
||||
callStore.getState().initCall();
|
||||
} catch (error) {
|
||||
console.error('[AuthStore] 启动 SSE 服务失败:', error);
|
||||
}
|
||||
|
||||
634
src/stores/callStore.ts
Normal file
634
src/stores/callStore.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
import { create } from 'zustand';
|
||||
import { MediaStream } from 'react-native-webrtc';
|
||||
import {
|
||||
wsService,
|
||||
WSCallIncomingMessage,
|
||||
WSCallSDPMessage,
|
||||
WSCallICEMessage,
|
||||
WSErrorMessage,
|
||||
} from '../services/wsService';
|
||||
import { webrtcManager, ICEServer } from '../services/webrtc';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { useUserStore } from './userStore';
|
||||
import { userManager } from './userManager';
|
||||
|
||||
export type CallStatus = 'idle' | 'ringing' | 'connecting' | 'connected' | 'ending';
|
||||
|
||||
export interface CallSession {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
peerId: string;
|
||||
peerName?: string;
|
||||
peerAvatar?: string | null;
|
||||
status: CallStatus;
|
||||
startedAt?: number;
|
||||
duration: number;
|
||||
isMuted: boolean;
|
||||
isSpeakerOn: boolean;
|
||||
isInitiator: boolean;
|
||||
}
|
||||
|
||||
export interface IncomingCallInfo {
|
||||
callId: string;
|
||||
conversationId: string;
|
||||
callerId: string;
|
||||
callerName?: string;
|
||||
callerAvatar?: string | null;
|
||||
callType: string;
|
||||
iceServers: ICEServer[];
|
||||
receivedAt: number;
|
||||
lifetime?: number;
|
||||
}
|
||||
|
||||
interface CallState {
|
||||
currentCall: CallSession | null;
|
||||
incomingCall: IncomingCallInfo | null;
|
||||
callDuration: number;
|
||||
peerStream: MediaStream | null;
|
||||
isMinimized: boolean;
|
||||
|
||||
initCall: () => () => void;
|
||||
startCall: (
|
||||
conversationId: string,
|
||||
calleeId: string,
|
||||
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }
|
||||
) => Promise<void>;
|
||||
acceptCall: () => Promise<void>;
|
||||
rejectCall: () => void;
|
||||
endCall: (reason?: string) => Promise<void>;
|
||||
toggleMute: () => void;
|
||||
toggleSpeaker: () => void;
|
||||
toggleMinimize: () => void;
|
||||
}
|
||||
|
||||
let durationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let initCallUnsub: (() => void) | null = null;
|
||||
let unsubInvited: (() => void) | null = null;
|
||||
let pendingOffer: { callId: string; sdp: string } | null = null;
|
||||
|
||||
// === Element + Telegram 结合: 常量 ===
|
||||
const CALL_LIFETIME_MS = 55000; // 55秒 (略小于后端60秒)
|
||||
const CALL_TIMEOUT_MS = 115000; // 115秒 (拨打方等待超时,略小于后端120秒)
|
||||
const IGNORE_CALL_ID_TTL = 30000; // 已处理callId保留30秒
|
||||
|
||||
// === Element: 已处理的 callId 集合 (防重复) ===
|
||||
const processedCallIds = new Map<string, number>(); // callId -> timestamp
|
||||
|
||||
// 清理过期的 callId
|
||||
function cleanupProcessedCallIds() {
|
||||
const now = Date.now();
|
||||
for (const [callId, timestamp] of processedCallIds) {
|
||||
if (now - timestamp > IGNORE_CALL_ID_TTL) {
|
||||
processedCallIds.delete(callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const callStore = create<CallState>((set, get) => ({
|
||||
currentCall: null,
|
||||
incomingCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
isMinimized: false,
|
||||
|
||||
initCall: () => {
|
||||
// Prevent duplicate handler registration
|
||||
if (initCallUnsub) {
|
||||
initCallUnsub();
|
||||
initCallUnsub = null;
|
||||
}
|
||||
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_incoming', async (msg) => {
|
||||
// === Element: 清理过期的 callId ===
|
||||
cleanupProcessedCallIds();
|
||||
|
||||
// === Element: 检查是否已处理过该 callId ===
|
||||
if (processedCallIds.has(msg.call_id)) {
|
||||
console.log('[CallStore] Ignoring already processed call:', msg.call_id);
|
||||
return;
|
||||
}
|
||||
|
||||
const { currentCall, incomingCall } = get();
|
||||
if (incomingCall) {
|
||||
wsService.sendCallBusy(msg.call_id);
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
return;
|
||||
}
|
||||
if (currentCall && currentCall.status !== 'idle') {
|
||||
wsService.sendCallBusy(msg.call_id);
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
// === Element: 检查 lifetime 过期 ===
|
||||
const callAge = Date.now() - msg.created_at;
|
||||
const lifetime = msg.lifetime || 60000; // 默认60秒
|
||||
if (callAge > lifetime - 5000) { // 留5秒余量
|
||||
console.log('[CallStore] Ignoring stale incoming call, age:', callAge, 'ms, lifetime:', lifetime);
|
||||
wsService.sendCallReject(msg.call_id);
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get caller info from cache first, then fetch from API
|
||||
let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
|
||||
useUserStore.getState().userCache[msg.caller_id];
|
||||
if (!caller) {
|
||||
try {
|
||||
const fetchedCaller = await userManager.getUserById(msg.caller_id);
|
||||
if (fetchedCaller) {
|
||||
caller = {
|
||||
nickname: fetchedCaller.nickname,
|
||||
username: fetchedCaller.username,
|
||||
avatar: fetchedCaller.avatar,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[CallStore] Failed to fetch caller info:', err);
|
||||
}
|
||||
}
|
||||
const callerName = caller?.nickname || caller?.username || msg.caller_id;
|
||||
|
||||
set({
|
||||
incomingCall: {
|
||||
callId: msg.call_id,
|
||||
conversationId: msg.conversation_id,
|
||||
callerId: msg.caller_id,
|
||||
callerName,
|
||||
callerAvatar: caller?.avatar,
|
||||
callType: msg.call_type,
|
||||
iceServers: msg.ice_servers || [],
|
||||
receivedAt: Date.now(),
|
||||
lifetime: msg.lifetime,
|
||||
},
|
||||
});
|
||||
|
||||
// 标记为已处理
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// 设置超时 (使用 lifetime)
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
const remainingTime = Math.max(lifetime - callAge - 1000, 5000); // 剩余时间,最少5秒
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { incomingCall: ic } = get();
|
||||
if (ic?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Incoming call timeout');
|
||||
wsService.sendCallReject(msg.call_id);
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
set({ incomingCall: null });
|
||||
}
|
||||
}, remainingTime);
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_accepted', async (msg) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
try {
|
||||
await webrtcManager.initialize(msg.ice_servers || []);
|
||||
await webrtcManager.createLocalStream(true);
|
||||
|
||||
// Listen for WebRTC events
|
||||
const unsubRTC = webrtcManager.onEvent((event) => {
|
||||
if (event.type === 'icecandidate' && event.candidate) {
|
||||
wsService.sendCallICE(currentCall.id, JSON.stringify(event.candidate.toJSON()));
|
||||
}
|
||||
if (event.type === 'remotestream') {
|
||||
set({ peerStream: event.stream });
|
||||
}
|
||||
if (event.type === 'connectionstatechange') {
|
||||
if (event.state === 'connected') {
|
||||
const now = Date.now();
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
: null,
|
||||
}));
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
set((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
if (event.state === 'disconnected' || event.state === 'failed') {
|
||||
get().endCall('connection_lost');
|
||||
}
|
||||
}
|
||||
});
|
||||
void unsubRTC;
|
||||
|
||||
// startCall creates PeerConnection, adds tracks, and creates offer for initiator
|
||||
const offer = await webrtcManager.startCall(true);
|
||||
if (offer) {
|
||||
wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_accepted error:', err);
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_rejected', (msg) => {
|
||||
const { currentCall } = get();
|
||||
if (currentCall?.id !== msg.call_id) return;
|
||||
console.log('[CallStore] Call rejected');
|
||||
get().endCall('rejected');
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_busy', (msg) => {
|
||||
const { currentCall } = get();
|
||||
if (currentCall?.id !== msg.call_id) return;
|
||||
console.log('[CallStore] Call busy');
|
||||
get().endCall('busy');
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_ended', (msg) => {
|
||||
const { currentCall, incomingCall } = get();
|
||||
|
||||
// 标记为已处理
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// If this is an active call, end it
|
||||
if (currentCall?.id === msg.call_id) {
|
||||
console.log('[CallStore] Call ended, duration:', msg.duration);
|
||||
get().endCall('ended');
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is an incoming call that was cancelled by caller, clear it
|
||||
if (incomingCall?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Incoming call cancelled by caller');
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
set({ incomingCall: null });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// === Telegram: 其他设备已接听 ===
|
||||
unsubs.push(
|
||||
wsService.on('call_answered_elsewhere', (msg) => {
|
||||
const { incomingCall } = get();
|
||||
if (incomingCall?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Call answered on another device');
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
set({ incomingCall: null });
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// === Telegram: 处理服务端错误 ===
|
||||
unsubs.push(
|
||||
wsService.on('error', (msg: WSErrorMessage) => {
|
||||
const { currentCall, incomingCall } = get();
|
||||
console.log('[CallStore] Server error:', msg.code, msg.message);
|
||||
|
||||
// 处理 callee_offline 错误
|
||||
if (msg.code === 'callee_offline') {
|
||||
if (currentCall && currentCall.status === 'ringing') {
|
||||
console.log('[CallStore] Callee is offline');
|
||||
get().endCall('callee_offline');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 call_already_answered 错误
|
||||
if (msg.code === 'call_already_answered') {
|
||||
if (incomingCall) {
|
||||
console.log('[CallStore] Call already answered on another device');
|
||||
processedCallIds.set(incomingCall.callId, Date.now());
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
set({ incomingCall: null });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_sdp', async (msg: WSCallSDPMessage) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
// Check that we are not the sender of this SDP message
|
||||
const myUserId = useAuthStore.getState().currentUser?.id;
|
||||
if (myUserId && msg.from_id === myUserId) return;
|
||||
|
||||
try {
|
||||
const pc = webrtcManager.getPeerConnection();
|
||||
|
||||
if (msg.payload.sdp_type === 'offer') {
|
||||
if (!pc) {
|
||||
// Cache offer for later processing when PeerConnection is ready
|
||||
pendingOffer = { callId: msg.call_id, sdp: msg.payload.sdp };
|
||||
console.log('[CallStore] Caching pending offer, PeerConnection not ready yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// We are the callee - only process if in 'stable' state
|
||||
const signalingState = pc.signalingState;
|
||||
if (signalingState !== 'stable') {
|
||||
console.warn('[CallStore] Ignoring offer, signaling state is', signalingState);
|
||||
return;
|
||||
}
|
||||
pendingOffer = null;
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type,
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
const answer = await webrtcManager.createAnswer();
|
||||
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
|
||||
} else if (msg.payload.sdp_type === 'answer') {
|
||||
if (!pc) return;
|
||||
|
||||
// We are the initiator - only process if in 'have-local-offer' state
|
||||
const signalingState = pc.signalingState;
|
||||
if (signalingState !== 'have-local-offer') {
|
||||
console.warn('[CallStore] Ignoring answer, signaling state is', signalingState);
|
||||
return;
|
||||
}
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type,
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_sdp error:', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_ice', (msg: WSCallICEMessage) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
// Ignore ICE candidates from ourselves
|
||||
const myUserId = useAuthStore.getState().currentUser?.id;
|
||||
if (myUserId && msg.from_id === myUserId) return;
|
||||
|
||||
try {
|
||||
const candidate = typeof msg.payload.candidate === 'string'
|
||||
? JSON.parse(msg.payload.candidate)
|
||||
: msg.payload.candidate;
|
||||
webrtcManager.addIceCandidate(candidate);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_ice error:', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_peer_muted', (msg) => {
|
||||
console.log('[CallStore] Peer muted:', msg.user_id, msg.muted);
|
||||
})
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
initCallUnsub = null;
|
||||
};
|
||||
initCallUnsub = cleanup;
|
||||
return cleanup;
|
||||
},
|
||||
|
||||
startCall: async (
|
||||
conversationId: string,
|
||||
calleeId: string,
|
||||
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }
|
||||
) => {
|
||||
const { currentCall } = get();
|
||||
if (currentCall && currentCall.status !== 'idle') {
|
||||
console.warn('[CallStore] Already in a call');
|
||||
return;
|
||||
}
|
||||
|
||||
const myUserId = useAuthStore.getState().currentUser?.id;
|
||||
if (!myUserId) {
|
||||
console.error('[CallStore] Not logged in');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided callee info first, then fall back to userCache
|
||||
const cachedCallee = useUserStore.getState().userCache[calleeId];
|
||||
const callee = calleeInfo || cachedCallee;
|
||||
const calleeName = callee?.nickname || callee?.username || calleeId;
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: '', // Will be filled when call_invited response comes
|
||||
conversationId,
|
||||
peerId: calleeId,
|
||||
peerName: calleeName,
|
||||
peerAvatar: callee?.avatar,
|
||||
status: 'ringing',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: false,
|
||||
isInitiator: true,
|
||||
},
|
||||
});
|
||||
|
||||
wsService.sendCallInvite(conversationId, calleeId);
|
||||
|
||||
// Listen for call_invited to get the call_id
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
}
|
||||
unsubInvited = wsService.on('call_invited', (msg) => {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, id: msg.call_id }
|
||||
: null,
|
||||
}));
|
||||
});
|
||||
|
||||
// Timeout - 使用 CALL_TIMEOUT_MS (115秒,略小于后端 120秒)
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { currentCall: cc } = get();
|
||||
if (cc && cc.status === 'ringing') {
|
||||
console.warn('[CallStore] Call timeout');
|
||||
get().endCall('timeout');
|
||||
}
|
||||
}, CALL_TIMEOUT_MS);
|
||||
},
|
||||
|
||||
acceptCall: async () => {
|
||||
const { incomingCall } = get();
|
||||
if (!incomingCall) return;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: incomingCall.callId,
|
||||
conversationId: incomingCall.conversationId,
|
||||
peerId: incomingCall.callerId,
|
||||
peerName: incomingCall.callerName,
|
||||
peerAvatar: incomingCall.callerAvatar,
|
||||
status: 'connecting',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: false,
|
||||
isInitiator: false,
|
||||
},
|
||||
incomingCall: null,
|
||||
});
|
||||
|
||||
wsService.sendCallAnswer(incomingCall.callId);
|
||||
|
||||
try {
|
||||
await webrtcManager.initialize(incomingCall.iceServers);
|
||||
await webrtcManager.createLocalStream(true);
|
||||
|
||||
// Listen for WebRTC events
|
||||
const unsubRTC = webrtcManager.onEvent((event) => {
|
||||
if (event.type === 'icecandidate' && event.candidate) {
|
||||
wsService.sendCallICE(incomingCall.callId, JSON.stringify(event.candidate.toJSON()));
|
||||
}
|
||||
if (event.type === 'remotestream') {
|
||||
set({ peerStream: event.stream });
|
||||
}
|
||||
if (event.type === 'connectionstatechange') {
|
||||
if (event.state === 'connected') {
|
||||
const now = Date.now();
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
: null,
|
||||
}));
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
set((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
if (event.state === 'disconnected' || event.state === 'failed') {
|
||||
get().endCall('connection_lost');
|
||||
}
|
||||
}
|
||||
});
|
||||
void unsubRTC;
|
||||
|
||||
// startCall creates PeerConnection and adds tracks (non-initiator, no offer)
|
||||
await webrtcManager.startCall(false);
|
||||
|
||||
// Process any pending offer that arrived before PeerConnection was ready
|
||||
if (pendingOffer && pendingOffer.callId === incomingCall.callId) {
|
||||
const offerMsg = pendingOffer;
|
||||
pendingOffer = null;
|
||||
console.log('[CallStore] Processing pending offer after PeerConnection ready');
|
||||
try {
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: 'offer',
|
||||
sdp: offerMsg.sdp,
|
||||
});
|
||||
const answer = await webrtcManager.createAnswer();
|
||||
wsService.sendCallSDP(offerMsg.callId, 'answer', answer.sdp || '');
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to process pending offer:', err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to accept call:', err);
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
},
|
||||
|
||||
rejectCall: () => {
|
||||
const { incomingCall } = get();
|
||||
if (!incomingCall) return;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
wsService.sendCallReject(incomingCall.callId);
|
||||
set({ incomingCall: null });
|
||||
},
|
||||
|
||||
endCall: async (reason = 'ended') => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
if (durationTimer) {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
unsubInvited = null;
|
||||
}
|
||||
pendingOffer = null;
|
||||
|
||||
const callId = currentCall.id;
|
||||
|
||||
set({
|
||||
currentCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
});
|
||||
|
||||
webrtcManager.dispose();
|
||||
|
||||
if (callId && reason !== 'ended') {
|
||||
wsService.sendCallEnd(callId, reason);
|
||||
}
|
||||
},
|
||||
|
||||
toggleMute: () => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
const newMuted = !currentCall.isMuted;
|
||||
webrtcManager.setMuted(newMuted);
|
||||
if (currentCall.id) {
|
||||
wsService.sendCallMute(currentCall.id, newMuted);
|
||||
}
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall ? { ...s.currentCall, isMuted: newMuted } : null,
|
||||
}));
|
||||
},
|
||||
|
||||
toggleSpeaker: () => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isSpeakerOn: !s.currentCall.isSpeakerOn }
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
toggleMinimize: () => {
|
||||
set((s) => ({ isMinimized: !s.isMinimized }));
|
||||
},
|
||||
}));
|
||||
@@ -47,6 +47,7 @@ export {
|
||||
export {
|
||||
useHomeTabPressStore,
|
||||
} from './homeTabPressStore';
|
||||
export { callStore } from './callStore';
|
||||
export {
|
||||
useAppColors,
|
||||
useThemePreference,
|
||||
|
||||
Reference in New Issue
Block a user