diff --git a/app.json b/app.json index a4a4974..f04fe52 100644 --- a/app.json +++ b/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", diff --git a/app/_layout.tsx b/app/_layout.tsx index 190d219..0fb9437 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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() { + + + diff --git a/package-lock.json b/package-lock.json index 3620731..cbc15c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 519c398..62dacb5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/call/CallScreen.tsx b/src/components/call/CallScreen.tsx new file mode 100644 index 0000000..6984a71 --- /dev/null +++ b/src/components/call/CallScreen.tsx @@ -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 ( + + + + {/* Background - single consistent color */} + + + {/* Remote video preview */} + {peerStream && ( + + + + )} + + {/* Top area: minimize button */} + + + + + + + {/* Center: Peer info */} + + + {currentCall.peerAvatar ? ( + + ) : ( + + + {currentCall.peerName?.charAt(0).toUpperCase() || '?'} + + + )} + + + {currentCall.peerName || '未知用户'} + + {getStatusText()} + + + {/* Bottom controls */} + + + {/* Mute */} + + + + + + {currentCall.isMuted ? '取消静音' : '静音'} + + + + {/* Speaker */} + + + + + + {currentCall.isSpeakerOn ? '免提' : '听筒'} + + + + + {/* End call - prominent red button */} + + + + + 挂断 + + + + ); +}; + +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; diff --git a/src/components/call/FloatingCallWindow.tsx b/src/components/call/FloatingCallWindow.tsx new file mode 100644 index 0000000..cf7f795 --- /dev/null +++ b/src/components/call/FloatingCallWindow.tsx @@ -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 ( + + + {/* Avatar */} + + {currentCall.peerAvatar ? ( + + ) : ( + + + {currentCall.peerName?.charAt(0).toUpperCase() || '?'} + + + )} + + + {/* Info */} + + + {currentCall.peerName || '未知用户'} + + {getStatusText()} + + + {/* End call button */} + { + e.stopPropagation(); + endCall('hangup'); + }} + activeOpacity={0.7} + > + + + + + ); +}; + +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; diff --git a/src/components/call/IncomingCallModal.tsx b/src/components/call/IncomingCallModal.tsx new file mode 100644 index 0000000..e8feace --- /dev/null +++ b/src/components/call/IncomingCallModal.tsx @@ -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 ( + + + + + {/* Avatar with ripple effect - positioned in upper area */} + + {/* Ripple rings */} + {[0, 1, 2].map((i) => ( + + ))} + + + {incomingCall.callerAvatar ? ( + + ) : ( + + + {incomingCall.callerName?.charAt(0).toUpperCase() || '?'} + + + )} + + + + {/* Caller info */} + + + {incomingCall.callerName || '未知用户'} + + 来电通话 + + + {/* Bottom controls */} + + {/* Decline */} + + + + + 拒绝 + + + {/* Accept */} + + + + + 接听 + + + + + ); +}; + +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; diff --git a/src/components/call/index.ts b/src/components/call/index.ts new file mode 100644 index 0000000..bcfc64a --- /dev/null +++ b/src/components/call/index.ts @@ -0,0 +1,3 @@ +export { default as CallScreen } from './CallScreen'; +export { default as IncomingCallModal } from './IncomingCallModal'; +export { default as FloatingCallWindow } from './FloatingCallWindow'; diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index b4f56fd..25c3177 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -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} /> {/* 消息列表 */} diff --git a/src/screens/message/components/ChatScreen/ChatHeader.tsx b/src/screens/message/components/ChatScreen/ChatHeader.tsx index 6945dac..bc6d1ad 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -23,6 +23,7 @@ export const ChatHeader: React.FC = ({ onMorePress, onGroupInfoPress, isWideScreen: propIsWideScreen, + onCallPress, }) => { const colors = useAppColors(); const baseStyles = useChatScreenStyles(); @@ -124,13 +125,30 @@ export const ChatHeader: React.FC = ({ > - ) : ( + ) : isGroupChat ? ( + ) : ( + + {onCallPress && ( + + + + )} + + + + )} diff --git a/src/screens/message/components/ChatScreen/styles.ts b/src/screens/message/components/ChatScreen/styles.ts index 8b8766a..ab60367 100644 --- a/src/screens/message/components/ChatScreen/styles.ts +++ b/src/screens/message/components/ChatScreen/styles.ts @@ -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, diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 866f6bd..6efebe3 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -179,6 +179,8 @@ export interface ChatHeaderProps { onGroupInfoPress?: () => void; /** 是否为大屏幕模式 */ isWideScreen?: boolean; + /** 拨打电话回调(仅私聊有效) */ + onCallPress?: () => void; } // 回复预览 Props diff --git a/src/services/webrtc/WebRTCManager.ts b/src/services/webrtc/WebRTCManager.ts new file mode 100644 index 0000000..02dee76 --- /dev/null +++ b/src/services/webrtc/WebRTCManager.ts @@ -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 = new Set(); + private disposed = false; + private isInitiator = false; + + async initialize(iceServers: ICEServer[] = []): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.setRemoteDescription(offer); + return this.createAnswer(); + } + + async setRemoteDescription(description: RTCSessionDescriptionInit): Promise { + 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 { + 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 { + 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(); diff --git a/src/services/webrtc/index.ts b/src/services/webrtc/index.ts new file mode 100644 index 0000000..1c1d65d --- /dev/null +++ b/src/services/webrtc/index.ts @@ -0,0 +1,2 @@ +export { webrtcManager } from './WebRTCManager'; +export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager'; diff --git a/src/services/wsService.ts b/src/services/wsService.ts index edca371..28641f6 100644 --- a/src/services/wsService.ts +++ b/src/services/wsService.ts @@ -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 = (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 { return new Promise((resolve, reject) => { diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index d4ad174..580022d 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -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 { try { await wsService.start(); + callStore.getState().initCall(); } catch (error) { console.error('[AuthStore] 启动 SSE 服务失败:', error); } diff --git a/src/stores/callStore.ts b/src/stores/callStore.ts new file mode 100644 index 0000000..24db71c --- /dev/null +++ b/src/stores/callStore.ts @@ -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; + acceptCall: () => Promise; + rejectCall: () => void; + endCall: (reason?: string) => Promise; + toggleMute: () => void; + toggleSpeaker: () => void; + toggleMinimize: () => void; +} + +let durationTimer: ReturnType | null = null; +let callTimeoutTimer: ReturnType | 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(); // 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((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 })); + }, +})); diff --git a/src/stores/index.ts b/src/stores/index.ts index b7d21a6..d71b1b6 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -47,6 +47,7 @@ export { export { useHomeTabPressStore, } from './homeTabPressStore'; +export { callStore } from './callStore'; export { useAppColors, useThemePreference,