diff --git a/src/components/call/CallScreen.tsx b/src/components/call/CallScreen.tsx index 6984a71..62b24c2 100644 --- a/src/components/call/CallScreen.tsx +++ b/src/components/call/CallScreen.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { - View, - Text, - StyleSheet, - TouchableOpacity, - Image, - StatusBar, + View, + Text, + StyleSheet, + TouchableOpacity, + Image, + StatusBar, } from 'react-native'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { callStore } from '../../stores/callStore'; @@ -17,19 +17,46 @@ const formatDuration = (seconds: number): string => { 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 localStream = callStore((s) => s.localStream); const endCall = callStore((s) => s.endCall); const toggleMute = callStore((s) => s.toggleMute); const toggleSpeaker = callStore((s) => s.toggleSpeaker); + const toggleVideo = callStore((s) => s.toggleVideo); const isMinimized = callStore((s) => s.isMinimized); const toggleMinimize = callStore((s) => s.toggleMinimize); + // Track whether peer has video + const [hasPeerVideo, setHasPeerVideo] = useState(false); + // Track whether local has video + const [hasLocalVideo, setHasLocalVideo] = useState(false); + + // Check peer stream for video tracks + useEffect(() => { + if (peerStream) { + const videoTracks = peerStream.getVideoTracks(); + const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled); + setHasPeerVideo(hasVideo); + } else { + setHasPeerVideo(false); + } + }, [peerStream]); + // Check local stream for video tracks + useEffect(() => { + if (localStream) { + const videoTracks = localStream.getVideoTracks(); + const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled); + setHasLocalVideo(hasVideo); + } else { + setHasLocalVideo(false); + } + }, [localStream]); // Don't render full screen when minimized or no call if (!currentCall || isMinimized) return null; - const getStatusText = (): string => { switch (currentCall.status) { case 'ringing': @@ -44,42 +71,50 @@ const CallScreen: React.FC = () => { return ''; } }; - const handleEndCall = () => { endCall('hangup'); }; - const handleToggleMute = () => { toggleMute(); }; - const handleToggleSpeaker = () => { toggleSpeaker(); }; - + const handleToggleVideo = () => { + toggleVideo(); + }; const handleMinimize = () => { toggleMinimize(); }; - + // Determine if we should show video UI + const showRemoteVideo = hasPeerVideo; + const showLocalVideo = hasLocalVideo; + const isVideoCallActive = showRemoteVideo || showLocalVideo; return ( - - {/* Background - single consistent color */} + {/* Background */} - - {/* Remote video preview */} - {peerStream && ( - + {/* Remote video - full screen when peer has video */} + {showRemoteVideo && peerStream && ( + + )} + {/* Local video - picture in picture */} + {showLocalVideo && localStream && ( + )} - {/* Top area: minimize button */} { - - {/* Center: Peer info */} - - - {currentCall.peerAvatar ? ( - - ) : ( - - - {currentCall.peerName?.charAt(0).toUpperCase() || '?'} - - - )} + {/* Center: Peer info - only show when no video */} + {!isVideoCallActive && ( + + + {currentCall.peerAvatar ? ( + + ) : ( + + + {currentCall.peerName?.charAt(0).toUpperCase() || '?'} + + + )} + + + {currentCall.peerName || '未知用户'} + + {getStatusText()} - - {currentCall.peerName || '未知用户'} - - {getStatusText()} - - + )} + {/* Peer name overlay when video is active */} + {isVideoCallActive && ( + + + {currentCall.peerName || '未知用户'} + + {getStatusText()} + + )} {/* Bottom controls */} @@ -130,7 +174,23 @@ const CallScreen: React.FC = () => { {currentCall.isMuted ? '取消静音' : '静音'} - + {/* Video toggle */} + + + + + + {hasLocalVideo ? '关闭摄像头' : '打开摄像头'} + + {/* Speaker */} { - {/* End call - prominent red button */} { {incomingCall.callerName || '未知用户'} - 来电通话 + + {incomingCall.callType === 'video' ? '视频通话' : '语音通话'} + {/* Bottom controls */} diff --git a/src/screens/message/ChatScreen.tsx b/src/screens/message/ChatScreen.tsx index 25c3177..09047a0 100644 --- a/src/screens/message/ChatScreen.tsx +++ b/src/screens/message/ChatScreen.tsx @@ -423,15 +423,6 @@ 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 bc6d1ad..6945dac 100644 --- a/src/screens/message/components/ChatScreen/ChatHeader.tsx +++ b/src/screens/message/components/ChatScreen/ChatHeader.tsx @@ -23,7 +23,6 @@ export const ChatHeader: React.FC = ({ onMorePress, onGroupInfoPress, isWideScreen: propIsWideScreen, - onCallPress, }) => { const colors = useAppColors(); const baseStyles = useChatScreenStyles(); @@ -125,30 +124,13 @@ export const ChatHeader: React.FC = ({ > - ) : isGroupChat ? ( + ) : ( - ) : ( - - {onCallPress && ( - - - - )} - - - - )} diff --git a/src/screens/message/components/ChatScreen/constants.ts b/src/screens/message/components/ChatScreen/constants.ts index 1d57c2d..be77c7e 100644 --- a/src/screens/message/components/ChatScreen/constants.ts +++ b/src/screens/message/components/ChatScreen/constants.ts @@ -143,6 +143,8 @@ export const EMOJIS = [ // 更多功能项 export const MORE_ACTIONS: MoreAction[] = [ + { id: 'voice_call', icon: 'phone', name: '语音通话', color: '#4CAF50' }, + { id: 'video_call', icon: 'video', name: '视频通话', color: '#2196F3' }, { id: 'image', icon: 'image', name: '图片', color: '#FF6B6B' }, { id: 'camera', icon: 'camera', name: '拍摄', color: '#4ECDC4' }, { id: 'file', icon: 'file-document', name: '文件', color: '#45B7D1' }, diff --git a/src/screens/message/components/ChatScreen/types.ts b/src/screens/message/components/ChatScreen/types.ts index 6efebe3..866f6bd 100644 --- a/src/screens/message/components/ChatScreen/types.ts +++ b/src/screens/message/components/ChatScreen/types.ts @@ -179,8 +179,6 @@ export interface ChatHeaderProps { onGroupInfoPress?: () => void; /** 是否为大屏幕模式 */ isWideScreen?: boolean; - /** 拨打电话回调(仅私聊有效) */ - onCallPress?: () => void; } // 回复预览 Props diff --git a/src/screens/message/components/ChatScreen/useChatScreen.ts b/src/screens/message/components/ChatScreen/useChatScreen.ts index 225f410..58e0deb 100644 --- a/src/screens/message/components/ChatScreen/useChatScreen.ts +++ b/src/screens/message/components/ChatScreen/useChatScreen.ts @@ -26,7 +26,7 @@ import { messageService } from '../../../../services/messageService'; import { uploadService } from '../../../../services/uploadService'; import { ApiError } from '../../../../services/api'; // 【新架构】使用 MessageManager -import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../../../stores'; +import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores'; import { groupService } from '../../../../services/groupService'; import { userManager } from '../../../../stores/userManager'; import { groupManager } from '../../../../stores/groupManager'; @@ -1060,6 +1060,24 @@ export const useChatScreen = () => { // 处理更多功能 const handleMoreAction = useCallback((actionId: string) => { switch (actionId) { + case 'voice_call': + if (!isGroupChat && otherUser?.id && conversationId) { + callStore.getState().startCall(conversationId, otherUser.id, { + nickname: otherUser.nickname, + avatar: otherUser.avatar, + }, 'voice'); + } + setActivePanel('none'); + break; + case 'video_call': + if (!isGroupChat && otherUser?.id && conversationId) { + callStore.getState().startCall(conversationId, otherUser.id, { + nickname: otherUser.nickname, + avatar: otherUser.avatar, + }, 'video'); + } + setActivePanel('none'); + break; case 'image': handlePickImage(); break; @@ -1077,7 +1095,7 @@ export const useChatScreen = () => { default: setActivePanel('none'); } - }, [handlePickImage, handleTakePhoto]); + }, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]); // 插入表情 const handleInsertEmoji = useCallback((emoji: string) => { diff --git a/src/services/webrtc/WebRTCManager.ts b/src/services/webrtc/WebRTCManager.ts index 02dee76..c812df0 100644 --- a/src/services/webrtc/WebRTCManager.ts +++ b/src/services/webrtc/WebRTCManager.ts @@ -5,6 +5,7 @@ import { mediaDevices, MediaStream, MediaStreamTrack, + RTCRtpTransceiver, } from 'react-native-webrtc'; export interface ICEServer { @@ -15,10 +16,13 @@ export interface ICEServer { export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed'; +export type CallType = 'voice' | 'video'; + export type WebRTCManagerEvent = | { type: 'icecandidate'; candidate: RTCIceCandidate | null } | { type: 'connectionstatechange'; state: ConnectionState } | { type: 'remotestream'; stream: MediaStream } + | { type: 'negotiationneeded'; offer: RTCSessionDescriptionInit } | { type: 'error'; error: Error }; type EventHandler = (event: WebRTCManagerEvent) => void; @@ -32,6 +36,9 @@ class WebRTCManager { private eventHandlers: Set = new Set(); private disposed = false; private isInitiator = false; + private callType: CallType = 'voice'; + private isNegotiating = false; + private initialOfferCreated = false; // Flag to prevent duplicate offer creation async initialize(iceServers: ICEServer[] = []): Promise { if (this.peerConnection) { @@ -81,6 +88,7 @@ class WebRTCManager { // @ts-ignore pc.ontrack = (event) => { + console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams.length); if (event.streams && event.streams[0]) { this.remoteStream = event.streams[0]; this.emit({ type: 'remotestream', stream: event.streams[0] }); @@ -89,12 +97,83 @@ class WebRTCManager { // @ts-ignore pc.onsignalingstatechange = () => { - // Could emit state change here if needed + console.log('[WebRTC] Signaling state changed:', pc.signalingState); + }; + + // @ts-ignore + pc.onnegotiationneeded = async () => { + console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState, 'isNegotiating:', this.isNegotiating); + + // Check if peer connection is still valid + if (!this.peerConnection || this.peerConnection !== pc) { + console.log('[WebRTC] PeerConnection changed or disposed, skipping negotiation'); + return; + } + + // Only start negotiation if: + // 1. We're in stable state + // 2. Not already negotiating + // 3. We are the initiator + // 4. Initial offer has NOT been created yet (prevent duplicate during startCall) + if (pc.signalingState === 'stable' && !this.isNegotiating && this.isInitiator && !this.initialOfferCreated) { + try { + this.isNegotiating = true; + const offer = await this.createOffer(); + // Check again after async operation + if (this.peerConnection && !this.disposed) { + this.emit({ type: 'negotiationneeded', offer }); + } + } catch (err) { + console.error('[WebRTC] Failed to create offer for renegotiation:', err); + } finally { + // Reset after a short delay to allow the offer to be processed + setTimeout(() => { + this.isNegotiating = false; + }, 500); + } + } else { + console.log('[WebRTC] Skipping negotiation: state=', pc.signalingState, 'isNegotiating=', this.isNegotiating, 'isInitiator=', this.isInitiator, 'initialOfferCreated=', this.initialOfferCreated); + } }; return pc; } + /** + * Setup transceivers with predefined m-line order + * This ensures m-line order is always: audio -> video + * Even for voice calls, we pre-allocate video transceiver as 'inactive' + */ + private setupTransceivers(callType: CallType): void { + if (!this.peerConnection) return; + + console.log('[WebRTC] Setting up transceivers for callType:', callType); + + // Always add audio transceiver first + this.peerConnection.addTransceiver('audio', { direction: 'sendrecv' }); + + // Add video transceiver - for voice calls it's inactive, for video calls it's sendrecv + const videoDirection = callType === 'video' ? 'sendrecv' : 'inactive'; + this.peerConnection.addTransceiver('video', { direction: videoDirection }); + + console.log('[WebRTC] Transceivers setup complete, video direction:', videoDirection); + } + + /** + * Update transceiver directions based on current state + */ + private updateTransceiverDirections(videoDirection: 'sendrecv' | 'recvonly' | 'inactive'): void { + if (!this.peerConnection) return; + + const transceivers = this.peerConnection.getTransceivers(); + const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); + + if (videoTransceiver) { + console.log('[WebRTC] Updating video transceiver direction to:', videoDirection); + videoTransceiver.direction = videoDirection; + } + } + async createLocalStream(voiceOnly = true): Promise { const constraints = voiceOnly ? { audio: true, video: false } @@ -110,19 +189,43 @@ class WebRTCManager { } } - async startCall(isInitiator: boolean): Promise { + /** + * Start a call with transceiver-based m-line allocation + * This replaces the old addTrack approach + */ + async startCall(isInitiator: boolean, callType: CallType = 'voice'): 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.callType = callType; + this.initialOfferCreated = true; // Set flag BEFORE creating connection to prevent onnegotiationneeded this.peerConnection = this.createPeerConnection(); - // Add local tracks to peer connection - for (const track of this.localStream.getTracks()) { - this.peerConnection.addTrack(track, this.localStream); + // Setup transceivers FIRST - this ensures consistent m-line order + this.setupTransceivers(callType); + + // Now add local tracks to transceivers + const transceivers = this.peerConnection.getTransceivers(); + + // Add audio track to audio transceiver + const audioTrack = this.localStream.getAudioTracks()[0]; + const audioTransceiver = transceivers.find(t => t.receiver.track?.kind === 'audio'); + if (audioTransceiver && audioTrack) { + await audioTransceiver.sender.replaceTrack(audioTrack); + } + + // Add video track if this is a video call + if (callType === 'video') { + const videoTrack = this.localStream.getVideoTracks()[0]; + const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); + if (videoTransceiver && videoTrack) { + await videoTransceiver.sender.replaceTrack(videoTrack); + } } if (isInitiator) { + // For initiator, create offer directly here const offer = await this.createOffer(); return offer; } @@ -132,21 +235,59 @@ class WebRTCManager { async createOffer(): Promise { if (!this.peerConnection) throw new Error('PeerConnection not initialized'); + console.log('[WebRTC] Creating offer...'); + + // Check if we have video tracks + const hasLocalVideo = (this.localStream?.getVideoTracks().length ?? 0) > 0; const offerOptions = { offerToReceiveAudio: true, - offerToReceiveVideo: false, + offerToReceiveVideo: true, // Always offer to receive video (transceiver will handle direction) }; + console.log('[WebRTC] Offer options:', offerOptions, 'hasLocalVideo:', hasLocalVideo); + const offer = await this.peerConnection.createOffer(offerOptions); + + // Check again after async operation + if (!this.peerConnection || this.disposed) { + throw new Error('PeerConnection was disposed during offer creation'); + } + await this.peerConnection.setLocalDescription(offer); return offer; } async createAnswer(): Promise { - if (!this.peerConnection) throw new Error('PeerConnection not initialized'); + console.log('[WebRTC] createAnswer called, peerConnection exists:', !!this.peerConnection, 'disposed:', this.disposed); + + if (!this.peerConnection) { + console.error('[WebRTC] createAnswer: PeerConnection is null'); + throw new Error('PeerConnection not initialized'); + } - const answer = await this.peerConnection.createAnswer(); + console.log('[WebRTC] Creating answer...'); + + const answerOptions = { + offerToReceiveAudio: true, + offerToReceiveVideo: true, // Always offer to receive video + }; + + console.log('[WebRTC] Answer options:', answerOptions); + + // @ts-ignore - react-native-webrtc types + const answer = await this.peerConnection.createAnswer(answerOptions); + + console.log('[WebRTC] Answer created, setting local description...'); + + // Check again after async operation + if (!this.peerConnection || this.disposed) { + console.error('[WebRTC] PeerConnection was disposed after createAnswer'); + throw new Error('PeerConnection was disposed during answer creation'); + } + await this.peerConnection.setLocalDescription(answer); + console.log('[WebRTC] Local description set successfully'); + // Process pending candidates after local description is set await this.processPendingCandidates(); return answer; @@ -158,19 +299,40 @@ class WebRTCManager { } async setRemoteDescription(description: RTCSessionDescriptionInit): Promise { - if (!this.peerConnection) throw new Error('PeerConnection not initialized'); + if (!this.peerConnection) { + console.error('[WebRTC] setRemoteDescription: PeerConnection is null'); + throw new Error('PeerConnection not initialized'); + } if (!description.sdp) { throw new Error('setRemoteDescription: sdp is required'); } + + console.log('[WebRTC] Setting remote description, type:', description.type); + const desc = new RTCSessionDescription({ type: description.type, sdp: description.sdp, }); await this.peerConnection.setRemoteDescription(desc); + + console.log('[WebRTC] Remote description set successfully'); // Process pending candidates after remote description is set await this.processPendingCandidates(); + + console.log('[WebRTC] Pending candidates processed, connection state:', this.peerConnection?.signalingState); + } + + /** + * Rollback to stable state (for Glare handling) + */ + async rollback(): Promise { + if (!this.peerConnection) return; + + console.log('[WebRTC] Rolling back to stable state...'); + // @ts-ignore - react-native-webrtc supports rollback + await this.peerConnection.setLocalDescription({ type: 'rollback', sdp: '' }); } async addIceCandidate(candidate: RTCIceCandidateInit): Promise { @@ -202,6 +364,12 @@ class WebRTCManager { this.pendingCandidates = []; for (const candidate of candidates) { + // Check connection state before each candidate + if (!this.peerConnection) { + console.log('[WebRTC] PeerConnection lost during processing pending candidates'); + return; + } + try { const iceCandidate = new RTCIceCandidate(candidate); await this.peerConnection.addIceCandidate(iceCandidate); @@ -225,6 +393,118 @@ class WebRTCManager { return audioTracks.some((track) => !track.enabled); } + /** + * Enable video using transceiver direction + * This preserves m-line order and triggers renegotiation + */ + async enableVideo(): Promise { + if (!this.peerConnection) throw new Error('PeerConnection not initialized'); + + console.log('[WebRTC] Enabling video...'); + + try { + // Get video stream + const videoStream = await mediaDevices.getUserMedia({ + video: { facingMode: 'user', frameRate: 30 }, + audio: false, + }); + + const videoTrack = videoStream.getVideoTracks()[0]; + + // Find the video transceiver and update it + const transceivers = this.peerConnection.getTransceivers(); + const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); + + if (videoTransceiver) { + // Replace the track + await videoTransceiver.sender.replaceTrack(videoTrack); + // Update direction to sendrecv + videoTransceiver.direction = 'sendrecv'; + console.log('[WebRTC] Video transceiver updated, direction:', videoTransceiver.direction); + } else { + console.error('[WebRTC] No video transceiver found!'); + } + + // Update local stream + const newStream = new MediaStream(); + + if (this.localStream) { + // Add existing audio tracks + this.localStream.getAudioTracks().forEach(track => { + newStream.addTrack(track); + }); + // Stop old video tracks + this.localStream.getVideoTracks().forEach(track => { + track.stop(); + }); + } + + // Add new video track + newStream.addTrack(videoTrack); + this.localStream = newStream; + + console.log('[WebRTC] Video enabled successfully'); + + // onnegotiationneeded will be triggered automatically + return newStream; + } catch (error) { + console.error('[WebRTC] Failed to enable video:', error); + throw error; + } + } + + /** + * Disable video using transceiver direction + */ + async disableVideo(): Promise { + if (!this.peerConnection) { + console.log('[WebRTC] disableVideo: No peer connection'); + return null; + } + if (!this.localStream) { + console.log('[WebRTC] disableVideo: No local stream'); + return null; + } + + console.log('[WebRTC] Disabling video...'); + + // Stop video tracks + const videoTracks = this.localStream.getVideoTracks(); + videoTracks.forEach((track) => { + track.stop(); + }); + + // Find video transceiver and update direction + const transceivers = this.peerConnection.getTransceivers(); + const videoTransceiver = transceivers.find(t => t.receiver.track?.kind === 'video'); + + if (videoTransceiver) { + // Remove the track + await videoTransceiver.sender.replaceTrack(null); + // Set direction to inactive + videoTransceiver.direction = 'inactive'; + console.log('[WebRTC] Video transceiver direction set to inactive'); + } + + // Create new stream with only audio + const newStream = new MediaStream(); + this.localStream.getAudioTracks().forEach(track => { + newStream.addTrack(track); + }); + + this.localStream = newStream; + console.log('[WebRTC] Video disabled successfully'); + + // onnegotiationneeded will be triggered automatically + return newStream; + } + + isVideoEnabled(): boolean { + if (!this.localStream) return false; + const videoTracks = this.localStream.getVideoTracks(); + return videoTracks.length > 0 && videoTracks.some((track) => track.enabled); + } + getRemoteStream(): MediaStream | null { return this.remoteStream; } @@ -237,6 +517,14 @@ class WebRTCManager { return this.peerConnection; } + getSignalingState(): RTCSignalingState | null { + return this.peerConnection?.signalingState || null; + } + + getIsInitiator(): boolean { + return this.isInitiator; + } + onEvent(handler: EventHandler): () => void { this.eventHandlers.add(handler); return () => { @@ -258,6 +546,8 @@ class WebRTCManager { this.disposed = true; this.eventHandlers.clear(); this.pendingCandidates = []; + this.isNegotiating = false; + this.initialOfferCreated = false; if (this.localStream) { this.localStream.getTracks().forEach((track) => track.stop()); diff --git a/src/services/wsService.ts b/src/services/wsService.ts index 28641f6..4c68dac 100644 --- a/src/services/wsService.ts +++ b/src/services/wsService.ts @@ -718,10 +718,11 @@ class WebSocketService { } // Call signaling send methods - sendCallInvite(conversationId: string, calleeId: string): void { + sendCallInvite(conversationId: string, calleeId: string, callType: 'voice' | 'video' = 'voice'): void { this.sendFireAndForget('call_invite', { conversation_id: conversationId, callee_id: calleeId, + call_type: callType, }); } diff --git a/src/stores/callStore.ts b/src/stores/callStore.ts index 24db71c..69acb25 100644 --- a/src/stores/callStore.ts +++ b/src/stores/callStore.ts @@ -12,7 +12,9 @@ import { useAuthStore } from './authStore'; import { useUserStore } from './userStore'; import { userManager } from './userManager'; -export type CallStatus = 'idle' | 'ringing' | 'connecting' | 'connected' | 'ending'; +export type CallStatus = 'idle' | 'ringing' | 'connecting' | 'connected' | 'renegotiating' | 'ending'; + +export type CallType = 'voice' | 'video'; export interface CallSession { id: string; @@ -21,10 +23,13 @@ export interface CallSession { peerName?: string; peerAvatar?: string | null; status: CallStatus; + callType: CallType; startedAt?: number; duration: number; isMuted: boolean; isSpeakerOn: boolean; + isVideoEnabled: boolean; + isPeerVideoEnabled: boolean; isInitiator: boolean; } @@ -45,13 +50,15 @@ interface CallState { incomingCall: IncomingCallInfo | null; callDuration: number; peerStream: MediaStream | null; + localStream: MediaStream | null; isMinimized: boolean; initCall: () => () => void; startCall: ( conversationId: string, calleeId: string, - calleeInfo?: { nickname?: string; username?: string; avatar?: string | null } + calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }, + callType?: CallType ) => Promise; acceptCall: () => Promise; rejectCall: () => void; @@ -59,23 +66,26 @@ interface CallState { toggleMute: () => void; toggleSpeaker: () => void; toggleMinimize: () => void; + toggleVideo: () => Promise; + setVideoEnabled: (enabled: boolean) => Promise; } +// Module-level variables for timers 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; +let rtcUnsubscribe: (() => void) | null = null; // WebRTC event subscription -// === Element + Telegram 结合: 常量 === -const CALL_LIFETIME_MS = 55000; // 55秒 (略小于后端60秒) -const CALL_TIMEOUT_MS = 115000; // 115秒 (拨打方等待超时,略小于后端120秒) -const IGNORE_CALL_ID_TTL = 30000; // 已处理callId保留30秒 +// Constants +const CALL_LIFETIME_MS = 55000; +const CALL_TIMEOUT_MS = 115000; +const IGNORE_CALL_ID_TTL = 30000; -// === Element: 已处理的 callId 集合 (防重复) === -const processedCallIds = new Map(); // callId -> timestamp +// Track processed call IDs to prevent duplicates +const processedCallIds = new Map(); -// 清理过期的 callId function cleanupProcessedCallIds() { const now = Date.now(); for (const [callId, timestamp] of processedCallIds) { @@ -85,11 +95,204 @@ function cleanupProcessedCallIds() { } } +/** + * Unified WebRTC event handler + * This is called from both initiator and receiver paths + */ +function setupWebRTCEvents(callId: string, myUserId: string): void { + // Clean up any existing subscription + if (rtcUnsubscribe) { + rtcUnsubscribe(); + rtcUnsubscribe = null; + } + + rtcUnsubscribe = webrtcManager.onEvent((event) => { + switch (event.type) { + case 'icecandidate': + if (event.candidate) { + wsService.sendCallICE(callId, JSON.stringify(event.candidate.toJSON())); + } + break; + + case 'remotestream': + handleRemoteStream(event.stream); + break; + + case 'negotiationneeded': + handleNegotiationNeeded(callId, event.offer); + break; + + case 'connectionstatechange': + handleConnectionStateChange(event.state); + break; + + case 'error': + console.error('[CallStore] WebRTC error:', event.error); + break; + } + }); +} + +/** + * Handle remote stream with video track detection + */ +function handleRemoteStream(stream: MediaStream): void { + callStore.setState({ peerStream: stream }); + + // Detect video tracks in remote stream + const videoTracks = stream.getVideoTracks(); + const hasPeerVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled); + + console.log('[CallStore] Remote stream received, hasVideo:', hasPeerVideo, 'videoTracks:', videoTracks.length); + + callStore.setState((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, isPeerVideoEnabled: hasPeerVideo } + : null, + })); +} + +/** + * Handle negotiation needed event - send offer to peer + */ +function handleNegotiationNeeded(callId: string, offer: RTCSessionDescriptionInit): void { + console.log('[CallStore] Negotiation needed, sending offer'); + wsService.sendCallSDP(callId, 'offer', offer.sdp || ''); +} + +/** + * Handle connection state change + */ +function handleConnectionStateChange(state: string): void { + console.log('[CallStore] Connection state changed:', state); + + if (state === 'connected') { + const now = Date.now(); + callStore.setState((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, status: 'connected', startedAt: now } + : null, + })); + + if (durationTimer) clearInterval(durationTimer); + durationTimer = setInterval(() => { + callStore.setState((s) => ({ callDuration: s.callDuration + 1 })); + }, 1000); + } + + // Only end call if connection failed after being connected + // Don't end call during initial connection setup + if (state === 'failed') { + const { currentCall } = callStore.getState(); + if (currentCall && currentCall.status === 'connected') { + callStore.getState().endCall('connection_lost'); + } + } +} + +/** + * Handle incoming SDP offer with Glare handling + */ +async function handleIncomingOffer(msg: WSCallSDPMessage, myUserId: string): Promise { + let pc = webrtcManager.getPeerConnection(); + if (!pc) { + // Cache offer for later processing + pendingOffer = { callId: msg.call_id, sdp: msg.payload.sdp }; + console.log('[CallStore] Caching pending offer, PeerConnection not ready yet'); + return; + } + + const signalingState = pc.signalingState; + console.log('[CallStore] Received offer, signalingState:', signalingState); + + // Glare handling: if we're not in stable state, we have a conflict + if (signalingState !== 'stable') { + const remoteUserId = msg.from_id; + + // Compare user IDs to determine who wins + // Higher user ID wins the negotiation + if (myUserId > remoteUserId) { + console.log('[CallStore] Glare: I win (my ID > remote ID), ignoring incoming offer'); + return; + } + + console.log('[CallStore] Glare: Remote wins (remote ID > my ID), rolling back'); + + // Rollback to stable state + try { + await webrtcManager.rollback(); + } catch (err) { + console.error('[CallStore] Rollback failed:', err); + return; + } + } + + pendingOffer = null; + + // Set remote description and create answer + try { + await webrtcManager.setRemoteDescription({ + type: msg.payload.sdp_type as 'offer' | 'answer', + sdp: msg.payload.sdp, + }); + + const answer = await webrtcManager.createAnswer(); + wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || ''); + } catch (err) { + console.error('[CallStore] Failed to handle incoming offer:', err); + } +} + +/** + * Handle incoming SDP answer + */ +async function handleIncomingAnswer(msg: WSCallSDPMessage): Promise { + const pc = webrtcManager.getPeerConnection(); + if (!pc) return; + + const signalingState = pc.signalingState; + console.log('[CallStore] Received answer, signalingState:', signalingState); + + if (signalingState !== 'have-local-offer') { + console.warn('[CallStore] Ignoring answer, signaling state is', signalingState); + return; + } + + try { + await webrtcManager.setRemoteDescription({ + type: msg.payload.sdp_type as 'offer' | 'answer', + sdp: msg.payload.sdp, + }); + } catch (err) { + console.error('[CallStore] Failed to set remote description from answer:', err); + } +} + +/** + * Clean up all resources + */ +function cleanupResources(): void { + if (callTimeoutTimer) { + clearTimeout(callTimeoutTimer); + callTimeoutTimer = null; + } + if (durationTimer) { + clearInterval(durationTimer); + durationTimer = null; + } + if (rtcUnsubscribe) { + rtcUnsubscribe(); + rtcUnsubscribe = null; + } + pendingOffer = null; +} + export const callStore = create((set, get) => ({ currentCall: null, incomingCall: null, callDuration: 0, peerStream: null, + localStream: null, isMinimized: false, initCall: () => { @@ -103,10 +306,8 @@ export const callStore = create((set, get) => ({ 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; @@ -124,17 +325,17 @@ export const callStore = create((set, get) => ({ return; } - // === Element: 检查 lifetime 过期 === + // Check lifetime expiry 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); + const lifetime = msg.lifetime || 60000; + if (callAge > lifetime - 5000) { + console.log('[CallStore] Ignoring stale incoming call, age:', callAge); 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 + // Fetch caller info let caller: { nickname?: string; username?: string; avatar?: string | null } | null = useUserStore.getState().userCache[msg.caller_id]; if (!caller) { @@ -167,12 +368,11 @@ export const callStore = create((set, get) => ({ }, }); - // 标记为已处理 processedCallIds.set(msg.call_id, Date.now()); - // 设置超时 (使用 lifetime) + // Set timeout based on lifetime if (callTimeoutTimer) clearTimeout(callTimeoutTimer); - const remainingTime = Math.max(lifetime - callAge - 1000, 5000); // 剩余时间,最少5秒 + const remainingTime = Math.max(lifetime - callAge - 1000, 5000); callTimeoutTimer = setTimeout(() => { const { incomingCall: ic } = get(); if (ic?.callId === msg.call_id) { @@ -190,40 +390,30 @@ export const callStore = create((set, get) => ({ const { currentCall } = get(); if (!currentCall || currentCall.id !== msg.call_id) return; + if (!currentCall.isInitiator) { + console.log('[CallStore] Ignoring call_accepted, we are not the initiator'); + return; + } + + if (callTimeoutTimer) { + clearTimeout(callTimeoutTimer); + callTimeoutTimer = null; + } + + const myUserId = useAuthStore.getState().currentUser?.id || ''; + try { + const isVideoCall = currentCall.callType === 'video'; await webrtcManager.initialize(msg.ice_servers || []); - await webrtcManager.createLocalStream(true); + const newStream = await webrtcManager.createLocalStream(!isVideoCall); - // 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; + set({ localStream: newStream }); - // startCall creates PeerConnection, adds tracks, and creates offer for initiator - const offer = await webrtcManager.startCall(true); + // Setup unified WebRTC event handler + setupWebRTCEvents(currentCall.id, myUserId); + + // Start call with transceiver-based approach + const offer = await webrtcManager.startCall(true, currentCall.callType); if (offer) { wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || ''); } @@ -256,17 +446,14 @@ export const callStore = create((set, get) => ({ 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) { @@ -278,7 +465,6 @@ export const callStore = create((set, get) => ({ }) ); - // === Telegram: 其他设备已接听 === unsubs.push( wsService.on('call_answered_elsewhere', (msg) => { const { incomingCall } = get(); @@ -294,13 +480,11 @@ export const callStore = create((set, get) => ({ }) ); - // === 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'); @@ -308,7 +492,6 @@ export const callStore = create((set, get) => ({ } } - // 处理 call_already_answered 错误 if (msg.code === 'call_already_answered') { if (incomingCall) { console.log('[CallStore] Call already answered on another device'); @@ -328,50 +511,13 @@ export const callStore = create((set, get) => ({ 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; + const myUserId = useAuthStore.getState().currentUser?.id || ''; + if (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); + if (msg.payload.sdp_type === 'offer') { + await handleIncomingOffer(msg, myUserId); + } else if (msg.payload.sdp_type === 'answer') { + await handleIncomingAnswer(msg); } }) ); @@ -381,9 +527,8 @@ export const callStore = create((set, get) => ({ 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; + const myUserId = useAuthStore.getState().currentUser?.id || ''; + if (msg.from_id === myUserId) return; try { const candidate = typeof msg.payload.candidate === 'string' @@ -403,8 +548,11 @@ export const callStore = create((set, get) => ({ ); const cleanup = () => { - if (callTimeoutTimer) clearTimeout(callTimeoutTimer); - if (durationTimer) clearInterval(durationTimer); + cleanupResources(); + if (unsubInvited) { + unsubInvited(); + unsubInvited = null; + } unsubs.forEach((unsub) => unsub()); initCallUnsub = null; }; @@ -415,7 +563,8 @@ export const callStore = create((set, get) => ({ startCall: async ( conversationId: string, calleeId: string, - calleeInfo?: { nickname?: string; username?: string; avatar?: string | null } + calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }, + callType: CallType = 'voice' ) => { const { currentCall } = get(); if (currentCall && currentCall.status !== 'idle') { @@ -429,29 +578,30 @@ export const callStore = create((set, get) => ({ 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 + id: '', conversationId, peerId: calleeId, peerName: calleeName, peerAvatar: callee?.avatar, + callType, status: 'ringing', duration: 0, isMuted: false, isSpeakerOn: false, + isVideoEnabled: callType === 'video', + isPeerVideoEnabled: false, isInitiator: true, }, }); - wsService.sendCallInvite(conversationId, calleeId); + wsService.sendCallInvite(conversationId, calleeId, callType); - // Listen for call_invited to get the call_id if (unsubInvited) { unsubInvited(); } @@ -463,7 +613,6 @@ export const callStore = create((set, get) => ({ })); }); - // Timeout - 使用 CALL_TIMEOUT_MS (115秒,略小于后端 120秒) if (callTimeoutTimer) clearTimeout(callTimeoutTimer); callTimeoutTimer = setTimeout(() => { const { currentCall: cc } = get(); @@ -483,6 +632,9 @@ export const callStore = create((set, get) => ({ callTimeoutTimer = null; } + const isVideoCall = incomingCall.callType === 'video'; + const myUserId = useAuthStore.getState().currentUser?.id || ''; + set({ currentCall: { id: incomingCall.callId, @@ -490,10 +642,13 @@ export const callStore = create((set, get) => ({ peerId: incomingCall.callerId, peerName: incomingCall.callerName, peerAvatar: incomingCall.callerAvatar, + callType: incomingCall.callType as CallType, status: 'connecting', duration: 0, isMuted: false, isSpeakerOn: false, + isVideoEnabled: isVideoCall, + isPeerVideoEnabled: isVideoCall, isInitiator: false, }, incomingCall: null, @@ -503,55 +658,18 @@ export const callStore = create((set, get) => ({ try { await webrtcManager.initialize(incomingCall.iceServers); - await webrtcManager.createLocalStream(true); + const newStream = await webrtcManager.createLocalStream(!isVideoCall); - // 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; + set({ localStream: newStream }); - // startCall creates PeerConnection and adds tracks (non-initiator, no offer) - await webrtcManager.startCall(false); + // Setup unified WebRTC event handler + setupWebRTCEvents(incomingCall.callId, myUserId); - // 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); - } - } + // Start call (non-initiator, will wait for offer) + await webrtcManager.startCall(false, incomingCall.callType as CallType); + + // Note: For non-initiator, we don't create an offer here. + // We wait for the initiator's offer via handleIncomingOffer. } catch (err) { console.error('[CallStore] Failed to accept call:', err); get().endCall('connection_failed'); @@ -575,19 +693,12 @@ export const callStore = create((set, get) => ({ const { currentCall } = get(); if (!currentCall) return; - if (callTimeoutTimer) { - clearTimeout(callTimeoutTimer); - callTimeoutTimer = null; - } - if (durationTimer) { - clearInterval(durationTimer); - durationTimer = null; - } + cleanupResources(); + if (unsubInvited) { unsubInvited(); unsubInvited = null; } - pendingOffer = null; const callId = currentCall.id; @@ -595,6 +706,7 @@ export const callStore = create((set, get) => ({ currentCall: null, callDuration: 0, peerStream: null, + localStream: null, }); webrtcManager.dispose(); @@ -631,4 +743,35 @@ export const callStore = create((set, get) => ({ toggleMinimize: () => { set((s) => ({ isMinimized: !s.isMinimized })); }, + + toggleVideo: async () => { + const { currentCall } = get(); + if (!currentCall) return; + + const newVideoEnabled = !currentCall.isVideoEnabled; + await get().setVideoEnabled(newVideoEnabled); + }, + + setVideoEnabled: async (enabled: boolean) => { + const { currentCall } = get(); + if (!currentCall) return; + + try { + if (enabled) { + const newStream = await webrtcManager.enableVideo(); + set({ localStream: newStream }); + } else { + const newStream = await webrtcManager.disableVideo(); + set({ localStream: newStream }); + } + + set((s) => ({ + currentCall: s.currentCall + ? { ...s.currentCall, isVideoEnabled: enabled, callType: enabled ? 'video' : 'voice' } + : null, + })); + } catch (err) { + console.error('[CallStore] Failed to toggle video:', err); + } + }, })); diff --git a/src/stores/index.ts b/src/stores/index.ts index d71b1b6..e5d5a30 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -48,6 +48,7 @@ export { useHomeTabPressStore, } from './homeTabPressStore'; export { callStore } from './callStore'; +export type { CallType, CallSession, CallStatus } from './callStore'; export { useAppColors, useThemePreference,