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": {
|
"expo": {
|
||||||
"name": "萝卜社区",
|
"name": "萝卜社区",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification",
|
"remote-notification",
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification"
|
"remote-notification",
|
||||||
|
"audio"
|
||||||
],
|
],
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"versionCode": 6,
|
"versionCode": 6,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
|
"RECORD_AUDIO",
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
"WAKE_LOCK",
|
"WAKE_LOCK",
|
||||||
"READ_EXTERNAL_STORAGE",
|
"READ_EXTERNAL_STORAGE",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import AppDialogHost from '../src/components/common/AppDialogHost';
|
|||||||
import { installAlertOverride } from '../src/services/alertOverride';
|
import { installAlertOverride } from '../src/services/alertOverride';
|
||||||
import { useAuthStore } from '../src/stores';
|
import { useAuthStore } from '../src/stores';
|
||||||
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
||||||
|
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||||
|
|
||||||
registerNotificationPresentationHandler();
|
registerNotificationPresentationHandler();
|
||||||
|
|
||||||
@@ -219,6 +220,9 @@ export default function RootLayout() {
|
|||||||
<ThemedStack />
|
<ThemedStack />
|
||||||
<AppPromptBar />
|
<AppPromptBar />
|
||||||
<AppDialogHost />
|
<AppDialogHost />
|
||||||
|
<IncomingCallModal />
|
||||||
|
<CallScreen />
|
||||||
|
<FloatingCallWindow />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemedProviders>
|
</ThemedProviders>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
50
package-lock.json
generated
50
package-lock.json
generated
@@ -54,6 +54,7 @@
|
|||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
@@ -9799,6 +9800,55 @@
|
|||||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-native-webview": {
|
||||||
"version": "13.16.0",
|
"version": "13.16.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -60,6 +60,7 @@
|
|||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"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 { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { messageManager } from '../../stores';
|
import { messageManager, callStore } from '../../stores';
|
||||||
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
import {
|
import {
|
||||||
useChatScreen,
|
useChatScreen,
|
||||||
@@ -423,6 +423,15 @@ export const ChatScreen: React.FC = () => {
|
|||||||
onMorePress={navigateToChatSettings}
|
onMorePress={navigateToChatSettings}
|
||||||
onGroupInfoPress={handleGroupInfoPress}
|
onGroupInfoPress={handleGroupInfoPress}
|
||||||
isWideScreen={isWideScreen}
|
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,
|
onMorePress,
|
||||||
onGroupInfoPress,
|
onGroupInfoPress,
|
||||||
isWideScreen: propIsWideScreen,
|
isWideScreen: propIsWideScreen,
|
||||||
|
onCallPress,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const baseStyles = useChatScreenStyles();
|
const baseStyles = useChatScreenStyles();
|
||||||
@@ -124,13 +125,30 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : isGroupChat ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.moreButton}
|
style={styles.moreButton}
|
||||||
onPress={onMorePress}
|
onPress={onMorePress}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
|
||||||
</TouchableOpacity>
|
</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>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -114,11 +114,22 @@ export function createChatScreenStyles(colors: AppColors) {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: 19,
|
borderRadius: 19,
|
||||||
backgroundColor: colors.chat.surfaceRaised,
|
backgroundColor: 'transparent',
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.chat.borderLight,
|
|
||||||
},
|
},
|
||||||
|
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: {
|
messageListContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ export interface ChatHeaderProps {
|
|||||||
onGroupInfoPress?: () => void;
|
onGroupInfoPress?: () => void;
|
||||||
/** 是否为大屏幕模式 */
|
/** 是否为大屏幕模式 */
|
||||||
isWideScreen?: boolean;
|
isWideScreen?: boolean;
|
||||||
|
/** 拨打电话回调(仅私聊有效) */
|
||||||
|
onCallPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回复预览 Props
|
// 回复预览 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'
|
| 'pong'
|
||||||
| 'message_sent'
|
| 'message_sent'
|
||||||
| 'message_recalled'
|
| '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 {
|
export interface WSChatMessage {
|
||||||
type: 'chat';
|
type: 'chat';
|
||||||
@@ -166,7 +265,18 @@ export type WSMessage =
|
|||||||
| WSGroupNoticeMessage
|
| WSGroupNoticeMessage
|
||||||
| WSGroupMentionMessage
|
| WSGroupMentionMessage
|
||||||
| WSGroupReadMessage
|
| WSGroupReadMessage
|
||||||
| WSGroupRecallMessage;
|
| WSGroupRecallMessage
|
||||||
|
| WSCallIncomingMessage
|
||||||
|
| WSCallAcceptedMessage
|
||||||
|
| WSCallRejectedMessage
|
||||||
|
| WSCallBusyMessage
|
||||||
|
| WSCallEndedMessage
|
||||||
|
| WSCallSDPMessage
|
||||||
|
| WSCallICEMessage
|
||||||
|
| WSCallPeerMutedMessage
|
||||||
|
| WSCallInvitedMessage
|
||||||
|
| WSCallAnsweredElsewhereMessage
|
||||||
|
| WSErrorMessage;
|
||||||
|
|
||||||
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
|
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
|
||||||
type ConnectionHandler = () => void;
|
type ConnectionHandler = () => void;
|
||||||
@@ -273,17 +383,35 @@ class WebSocketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleIncoming(data: string): void {
|
private handleIncoming(data: string): void {
|
||||||
try {
|
// 后端 writePump 会将多条消息以 \n 分隔合并发送,需要逐条解析
|
||||||
const msg: WSServerMessage = JSON.parse(data);
|
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响应
|
// 处理pong响应
|
||||||
if (msg.type === 'pong') {
|
if (msg.type === 'pong') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理错误消息
|
// 处理错误消息 - emit 到上层处理
|
||||||
if (msg.type === 'error') {
|
if (msg.type === 'error') {
|
||||||
console.error('WebSocket error:', msg.payload);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,9 +434,6 @@ class WebSocketService {
|
|||||||
|
|
||||||
// 分发事件
|
// 分发事件
|
||||||
this.dispatchServerMessage(msg);
|
this.dispatchServerMessage(msg);
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse WebSocket message:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchServerMessage(msg: WSServerMessage): void {
|
private dispatchServerMessage(msg: WSServerMessage): void {
|
||||||
@@ -333,6 +458,37 @@ class WebSocketService {
|
|||||||
case 'system_notification':
|
case 'system_notification':
|
||||||
this.handleSystemNotification(payload);
|
this.handleSystemNotification(payload);
|
||||||
break;
|
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:
|
default:
|
||||||
console.log('Unknown message type:', type);
|
console.log('Unknown message type:', type);
|
||||||
}
|
}
|
||||||
@@ -458,6 +614,152 @@ class WebSocketService {
|
|||||||
systemNotificationService.handleWSMessage(m as any).catch(() => {});
|
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 {
|
private handleMessageSent(payload: any): void {
|
||||||
// 找到对应的发送请求并resolve
|
// 找到对应的发送请求并resolve
|
||||||
const pendingMsg = this.pendingMessages.find(p =>
|
const pendingMsg = this.pendingMessages.find(p =>
|
||||||
@@ -540,6 +842,15 @@ class WebSocketService {
|
|||||||
return api.post('/messages/delete_msg', { message_id: messageId });
|
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发送
|
// 通过WebSocket发送
|
||||||
private sendViaWebSocket(type: string, payload: any): Promise<any> {
|
private sendViaWebSocket(type: string, payload: any): Promise<any> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { create } from 'zustand';
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { authService, resolveAuthApiError, LoginRequest, RegisterRequest } from '../services';
|
import { authService, resolveAuthApiError, LoginRequest, RegisterRequest } from '../services';
|
||||||
import { wsService } from '../services/wsService';
|
import { wsService } from '../services/wsService';
|
||||||
|
import { callStore } from './callStore';
|
||||||
import {
|
import {
|
||||||
initDatabase,
|
initDatabase,
|
||||||
closeDatabase,
|
closeDatabase,
|
||||||
@@ -96,6 +97,7 @@ function resolveLoginError(error: any): string {
|
|||||||
async function startRealtime(): Promise<void> {
|
async function startRealtime(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await wsService.start();
|
await wsService.start();
|
||||||
|
callStore.getState().initCall();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthStore] 启动 SSE 服务失败:', 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 {
|
export {
|
||||||
useHomeTabPressStore,
|
useHomeTabPressStore,
|
||||||
} from './homeTabPressStore';
|
} from './homeTabPressStore';
|
||||||
|
export { callStore } from './callStore';
|
||||||
export {
|
export {
|
||||||
useAppColors,
|
useAppColors,
|
||||||
useThemePreference,
|
useThemePreference,
|
||||||
|
|||||||
Reference in New Issue
Block a user