feat(CallScreen): enhance video call functionality and UI updates
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m13s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled

- Integrated video call support by adding local and remote video stream handling.
- Implemented state management for video track detection in both local and peer streams.
- Updated UI to conditionally render video components based on the presence of video tracks.
- Added video toggle functionality to enable or disable local video during calls.
- Enhanced incoming call modal to display call type (voice or video).
- Refactored call store to manage call types and video state more effectively.
This commit is contained in:
lafay
2026-03-28 00:56:52 +08:00
parent b19a2ced6f
commit f6176c945b
11 changed files with 790 additions and 268 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, View,
Text, Text,
@@ -17,19 +17,46 @@ const formatDuration = (seconds: number): string => {
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}; };
const CallScreen: React.FC = () => { const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall); const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration); const callDuration = callStore((s) => s.callDuration);
const peerStream = callStore((s) => s.peerStream); const peerStream = callStore((s) => s.peerStream);
const localStream = callStore((s) => s.localStream);
const endCall = callStore((s) => s.endCall); const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute); const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker); const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const toggleVideo = callStore((s) => s.toggleVideo);
const isMinimized = callStore((s) => s.isMinimized); const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize); 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 // Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null; if (!currentCall || isMinimized) return null;
const getStatusText = (): string => { const getStatusText = (): string => {
switch (currentCall.status) { switch (currentCall.status) {
case 'ringing': case 'ringing':
@@ -44,42 +71,50 @@ const CallScreen: React.FC = () => {
return ''; return '';
} }
}; };
const handleEndCall = () => { const handleEndCall = () => {
endCall('hangup'); endCall('hangup');
}; };
const handleToggleMute = () => { const handleToggleMute = () => {
toggleMute(); toggleMute();
}; };
const handleToggleSpeaker = () => { const handleToggleSpeaker = () => {
toggleSpeaker(); toggleSpeaker();
}; };
const handleToggleVideo = () => {
toggleVideo();
};
const handleMinimize = () => { const handleMinimize = () => {
toggleMinimize(); toggleMinimize();
}; };
// Determine if we should show video UI
const showRemoteVideo = hasPeerVideo;
const showLocalVideo = hasLocalVideo;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent /> <StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background */}
{/* Background - single consistent color */}
<View style={styles.background} /> <View style={styles.background} />
{/* Remote video - full screen when peer has video */}
{/* Remote video preview */} {showRemoteVideo && peerStream && (
{peerStream && (
<View style={styles.remoteVideoContainer}>
<RTCView <RTCView
streamURL={peerStream?.toURL()} streamURL={peerStream.toURL()}
style={styles.remoteVideo} style={styles.fullScreenVideo}
objectFit="cover" objectFit="cover"
mirror mirror={false}
/>
)}
{/* Local video - picture in picture */}
{showLocalVideo && localStream && (
<View style={styles.localVideoContainer}>
<RTCView
streamURL={localStream.toURL()}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/> />
</View> </View>
)} )}
{/* Top area: minimize button */} {/* Top area: minimize button */}
<View style={styles.topBar}> <View style={styles.topBar}>
<TouchableOpacity <TouchableOpacity
@@ -90,8 +125,8 @@ const CallScreen: React.FC = () => {
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" /> <MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Center: Peer info - only show when no video */}
{/* Center: Peer info */} {!isVideoCallActive && (
<View style={styles.centerArea}> <View style={styles.centerArea}>
<View style={styles.avatarOuter}> <View style={styles.avatarOuter}>
{currentCall.peerAvatar ? ( {currentCall.peerAvatar ? (
@@ -109,7 +144,16 @@ const CallScreen: React.FC = () => {
</Text> </Text>
<Text style={styles.status}>{getStatusText()}</Text> <Text style={styles.status}>{getStatusText()}</Text>
</View> </View>
)}
{/* Peer name overlay when video is active */}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
<Text style={styles.videoPeerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
{/* Bottom controls */} {/* Bottom controls */}
<View style={styles.controls}> <View style={styles.controls}>
<View style={styles.controlRow}> <View style={styles.controlRow}>
@@ -130,7 +174,23 @@ const CallScreen: React.FC = () => {
{currentCall.isMuted ? '取消静音' : '静音'} {currentCall.isMuted ? '取消静音' : '静音'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* Video toggle */}
<TouchableOpacity
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
onPress={handleToggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={hasLocalVideo ? 'video' : 'video-off'}
size={26}
color={hasLocalVideo ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
{/* Speaker */} {/* Speaker */}
<TouchableOpacity <TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]} style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
@@ -149,7 +209,6 @@ const CallScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* End call - prominent red button */} {/* End call - prominent red button */}
<TouchableOpacity <TouchableOpacity
style={styles.endCallButton} style={styles.endCallButton}
@@ -188,6 +247,7 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: 44, height: 44,
zIndex: 100,
}, },
topButton: { topButton: {
width: 44, width: 44,
@@ -236,31 +296,63 @@ const styles = StyleSheet.create({
color: 'rgba(255, 255, 255, 0.5)', color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3, letterSpacing: 0.3,
}, },
remoteVideoContainer: { fullScreenVideo: {
position: 'absolute', ...StyleSheet.absoluteFillObject,
top: 70,
right: 20,
width: 100,
height: 140,
borderRadius: 14,
overflow: 'hidden',
backgroundColor: '#1A1A2E', backgroundColor: '#1A1A2E',
}, },
remoteVideo: { localVideoContainer: {
position: 'absolute',
top: 100,
right: 20,
width: 120,
height: 160,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#2A2A4E',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
zIndex: 50,
},
localVideo: {
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
videoOverlayInfo: {
position: 'absolute',
top: 100,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 50,
},
videoPeerName: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
videoStatus: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 4,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
controls: { controls: {
position: 'absolute', position: 'absolute',
bottom: 60, bottom: 60,
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', alignItems: 'center',
zIndex: 100,
}, },
controlRow: { controlRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
gap: 36, gap: 24,
marginBottom: 24, marginBottom: 24,
}, },
controlButton: { controlButton: {
@@ -268,9 +360,9 @@ const styles = StyleSheet.create({
width: 70, width: 70,
}, },
controlCircle: { controlCircle: {
width: CONTROL_CIRCLE_SIZE, width: 56,
height: CONTROL_CIRCLE_SIZE, height: 56,
borderRadius: CONTROL_CIRCLE_SIZE / 2, borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.12)', backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -283,6 +375,7 @@ const styles = StyleSheet.create({
fontSize: 11, fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)', color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8, marginTop: 8,
textAlign: 'center',
}, },
controlLabelActive: { controlLabelActive: {
color: '#FFFFFF', color: '#FFFFFF',
@@ -292,9 +385,9 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
endCallCircle: { endCallCircle: {
width: END_CALL_SIZE, width: 64,
height: END_CALL_SIZE, height: 64,
borderRadius: END_CALL_SIZE / 2, borderRadius: 32,
backgroundColor: '#E54D42', backgroundColor: '#E54D42',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -306,4 +399,5 @@ const styles = StyleSheet.create({
}, },
}); });
export default CallScreen; export default CallScreen;

View File

@@ -132,7 +132,9 @@ const IncomingCallModal: React.FC = () => {
<Text style={styles.callerName} numberOfLines={1}> <Text style={styles.callerName} numberOfLines={1}>
{incomingCall.callerName || '未知用户'} {incomingCall.callerName || '未知用户'}
</Text> </Text>
<Text style={styles.callType}></Text> <Text style={styles.callType}>
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
</Text>
</View> </View>
{/* Bottom controls */} {/* Bottom controls */}

View File

@@ -423,15 +423,6 @@ export const ChatScreen: React.FC = () => {
onMorePress={navigateToChatSettings} onMorePress={navigateToChatSettings}
onGroupInfoPress={handleGroupInfoPress} onGroupInfoPress={handleGroupInfoPress}
isWideScreen={isWideScreen} isWideScreen={isWideScreen}
onCallPress={!isGroupChat ? () => {
const otherUserId = otherUser?.id;
if (otherUserId && conversationId) {
callStore.getState().startCall(conversationId, otherUserId, {
nickname: otherUser?.nickname,
avatar: otherUser?.avatar,
});
}
} : undefined}
/> />
{/* 消息列表 */} {/* 消息列表 */}

View File

@@ -23,7 +23,6 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onMorePress, onMorePress,
onGroupInfoPress, onGroupInfoPress,
isWideScreen: propIsWideScreen, isWideScreen: propIsWideScreen,
onCallPress,
}) => { }) => {
const colors = useAppColors(); const colors = useAppColors();
const baseStyles = useChatScreenStyles(); const baseStyles = useChatScreenStyles();
@@ -125,30 +124,13 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
> >
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} /> <MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
</TouchableOpacity> </TouchableOpacity>
) : isGroupChat ? (
<TouchableOpacity
style={styles.moreButton}
onPress={onMorePress}
>
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
</TouchableOpacity>
) : ( ) : (
<View style={styles.headerActions}>
{onCallPress && (
<TouchableOpacity
style={styles.callButton}
onPress={onCallPress}
>
<MaterialCommunityIcons name="phone" size={22} color={colors.primary.main} />
</TouchableOpacity>
)}
<TouchableOpacity <TouchableOpacity
style={styles.moreButton} style={styles.moreButton}
onPress={onMorePress} onPress={onMorePress}
> >
<MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} /> <MaterialCommunityIcons name="dots-horizontal" size={22} color={colors.text.primary} />
</TouchableOpacity> </TouchableOpacity>
</View>
)} )}
</View> </View>
</View> </View>

View File

@@ -143,6 +143,8 @@ export const EMOJIS = [
// 更多功能项 // 更多功能项
export const MORE_ACTIONS: MoreAction[] = [ 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: 'image', icon: 'image', name: '图片', color: '#FF6B6B' },
{ id: 'camera', icon: 'camera', name: '拍摄', color: '#4ECDC4' }, { id: 'camera', icon: 'camera', name: '拍摄', color: '#4ECDC4' },
{ id: 'file', icon: 'file-document', name: '文件', color: '#45B7D1' }, { id: 'file', icon: 'file-document', name: '文件', color: '#45B7D1' },

View File

@@ -179,8 +179,6 @@ export interface ChatHeaderProps {
onGroupInfoPress?: () => void; onGroupInfoPress?: () => void;
/** 是否为大屏幕模式 */ /** 是否为大屏幕模式 */
isWideScreen?: boolean; isWideScreen?: boolean;
/** 拨打电话回调(仅私聊有效) */
onCallPress?: () => void;
} }
// 回复预览 Props // 回复预览 Props

View File

@@ -26,7 +26,7 @@ import { messageService } from '../../../../services/messageService';
import { uploadService } from '../../../../services/uploadService'; import { uploadService } from '../../../../services/uploadService';
import { ApiError } from '../../../../services/api'; import { ApiError } from '../../../../services/api';
// 【新架构】使用 MessageManager // 【新架构】使用 MessageManager
import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../../../stores'; import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores';
import { groupService } from '../../../../services/groupService'; import { groupService } from '../../../../services/groupService';
import { userManager } from '../../../../stores/userManager'; import { userManager } from '../../../../stores/userManager';
import { groupManager } from '../../../../stores/groupManager'; import { groupManager } from '../../../../stores/groupManager';
@@ -1060,6 +1060,24 @@ export const useChatScreen = () => {
// 处理更多功能 // 处理更多功能
const handleMoreAction = useCallback((actionId: string) => { const handleMoreAction = useCallback((actionId: string) => {
switch (actionId) { 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': case 'image':
handlePickImage(); handlePickImage();
break; break;
@@ -1077,7 +1095,7 @@ export const useChatScreen = () => {
default: default:
setActivePanel('none'); setActivePanel('none');
} }
}, [handlePickImage, handleTakePhoto]); }, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
// 插入表情 // 插入表情
const handleInsertEmoji = useCallback((emoji: string) => { const handleInsertEmoji = useCallback((emoji: string) => {

View File

@@ -5,6 +5,7 @@ import {
mediaDevices, mediaDevices,
MediaStream, MediaStream,
MediaStreamTrack, MediaStreamTrack,
RTCRtpTransceiver,
} from 'react-native-webrtc'; } from 'react-native-webrtc';
export interface ICEServer { export interface ICEServer {
@@ -15,10 +16,13 @@ export interface ICEServer {
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed'; export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
export type CallType = 'voice' | 'video';
export type WebRTCManagerEvent = export type WebRTCManagerEvent =
| { type: 'icecandidate'; candidate: RTCIceCandidate | null } | { type: 'icecandidate'; candidate: RTCIceCandidate | null }
| { type: 'connectionstatechange'; state: ConnectionState } | { type: 'connectionstatechange'; state: ConnectionState }
| { type: 'remotestream'; stream: MediaStream } | { type: 'remotestream'; stream: MediaStream }
| { type: 'negotiationneeded'; offer: RTCSessionDescriptionInit }
| { type: 'error'; error: Error }; | { type: 'error'; error: Error };
type EventHandler = (event: WebRTCManagerEvent) => void; type EventHandler = (event: WebRTCManagerEvent) => void;
@@ -32,6 +36,9 @@ class WebRTCManager {
private eventHandlers: Set<EventHandler> = new Set(); private eventHandlers: Set<EventHandler> = new Set();
private disposed = false; private disposed = false;
private isInitiator = 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<void> { async initialize(iceServers: ICEServer[] = []): Promise<void> {
if (this.peerConnection) { if (this.peerConnection) {
@@ -81,6 +88,7 @@ class WebRTCManager {
// @ts-ignore // @ts-ignore
pc.ontrack = (event) => { pc.ontrack = (event) => {
console.log('[WebRTC] ontrack event, kind:', event.track.kind, 'streams:', event.streams.length);
if (event.streams && event.streams[0]) { if (event.streams && event.streams[0]) {
this.remoteStream = event.streams[0]; this.remoteStream = event.streams[0];
this.emit({ type: 'remotestream', stream: event.streams[0] }); this.emit({ type: 'remotestream', stream: event.streams[0] });
@@ -89,12 +97,83 @@ class WebRTCManager {
// @ts-ignore // @ts-ignore
pc.onsignalingstatechange = () => { 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; 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<MediaStream> { async createLocalStream(voiceOnly = true): Promise<MediaStream> {
const constraints = voiceOnly const constraints = voiceOnly
? { audio: true, video: false } ? { audio: true, video: false }
@@ -110,19 +189,43 @@ class WebRTCManager {
} }
} }
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> { /**
* Start a call with transceiver-based m-line allocation
* This replaces the old addTrack approach
*/
async startCall(isInitiator: boolean, callType: CallType = 'voice'): Promise<RTCSessionDescriptionInit | null> {
if (this.disposed) throw new Error('WebRTCManager has been disposed'); if (this.disposed) throw new Error('WebRTCManager has been disposed');
if (!this.localStream) throw new Error('Local stream not initialized'); if (!this.localStream) throw new Error('Local stream not initialized');
this.isInitiator = isInitiator; this.isInitiator = isInitiator;
this.callType = callType;
this.initialOfferCreated = true; // Set flag BEFORE creating connection to prevent onnegotiationneeded
this.peerConnection = this.createPeerConnection(); this.peerConnection = this.createPeerConnection();
// Add local tracks to peer connection // Setup transceivers FIRST - this ensures consistent m-line order
for (const track of this.localStream.getTracks()) { this.setupTransceivers(callType);
this.peerConnection.addTrack(track, this.localStream);
// 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) { if (isInitiator) {
// For initiator, create offer directly here
const offer = await this.createOffer(); const offer = await this.createOffer();
return offer; return offer;
} }
@@ -132,21 +235,59 @@ class WebRTCManager {
async createOffer(): Promise<RTCSessionDescriptionInit> { async createOffer(): Promise<RTCSessionDescriptionInit> {
if (!this.peerConnection) throw new Error('PeerConnection not initialized'); 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 = { const offerOptions = {
offerToReceiveAudio: true, 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); 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); await this.peerConnection.setLocalDescription(offer);
return offer; return offer;
} }
async createAnswer(): Promise<RTCSessionDescriptionInit> { async createAnswer(): Promise<RTCSessionDescriptionInit> {
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');
}
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');
}
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer); await this.peerConnection.setLocalDescription(answer);
console.log('[WebRTC] Local description set successfully');
// Process pending candidates after local description is set // Process pending candidates after local description is set
await this.processPendingCandidates(); await this.processPendingCandidates();
return answer; return answer;
@@ -158,19 +299,40 @@ class WebRTCManager {
} }
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> { async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
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) { if (!description.sdp) {
throw new Error('setRemoteDescription: sdp is required'); throw new Error('setRemoteDescription: sdp is required');
} }
console.log('[WebRTC] Setting remote description, type:', description.type);
const desc = new RTCSessionDescription({ const desc = new RTCSessionDescription({
type: description.type, type: description.type,
sdp: description.sdp, sdp: description.sdp,
}); });
await this.peerConnection.setRemoteDescription(desc); await this.peerConnection.setRemoteDescription(desc);
console.log('[WebRTC] Remote description set successfully');
// Process pending candidates after remote description is set // Process pending candidates after remote description is set
await this.processPendingCandidates(); await this.processPendingCandidates();
console.log('[WebRTC] Pending candidates processed, connection state:', this.peerConnection?.signalingState);
}
/**
* Rollback to stable state (for Glare handling)
*/
async rollback(): Promise<void> {
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<void> { async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
@@ -202,6 +364,12 @@ class WebRTCManager {
this.pendingCandidates = []; this.pendingCandidates = [];
for (const candidate of candidates) { 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 { try {
const iceCandidate = new RTCIceCandidate(candidate); const iceCandidate = new RTCIceCandidate(candidate);
await this.peerConnection.addIceCandidate(iceCandidate); await this.peerConnection.addIceCandidate(iceCandidate);
@@ -225,6 +393,118 @@ class WebRTCManager {
return audioTracks.some((track) => !track.enabled); return audioTracks.some((track) => !track.enabled);
} }
/**
* Enable video using transceiver direction
* This preserves m-line order and triggers renegotiation
*/
async enableVideo(): Promise<MediaStream> {
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<MediaStream | null> {
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 { getRemoteStream(): MediaStream | null {
return this.remoteStream; return this.remoteStream;
} }
@@ -237,6 +517,14 @@ class WebRTCManager {
return this.peerConnection; return this.peerConnection;
} }
getSignalingState(): RTCSignalingState | null {
return this.peerConnection?.signalingState || null;
}
getIsInitiator(): boolean {
return this.isInitiator;
}
onEvent(handler: EventHandler): () => void { onEvent(handler: EventHandler): () => void {
this.eventHandlers.add(handler); this.eventHandlers.add(handler);
return () => { return () => {
@@ -258,6 +546,8 @@ class WebRTCManager {
this.disposed = true; this.disposed = true;
this.eventHandlers.clear(); this.eventHandlers.clear();
this.pendingCandidates = []; this.pendingCandidates = [];
this.isNegotiating = false;
this.initialOfferCreated = false;
if (this.localStream) { if (this.localStream) {
this.localStream.getTracks().forEach((track) => track.stop()); this.localStream.getTracks().forEach((track) => track.stop());

View File

@@ -718,10 +718,11 @@ class WebSocketService {
} }
// Call signaling send methods // Call signaling send methods
sendCallInvite(conversationId: string, calleeId: string): void { sendCallInvite(conversationId: string, calleeId: string, callType: 'voice' | 'video' = 'voice'): void {
this.sendFireAndForget('call_invite', { this.sendFireAndForget('call_invite', {
conversation_id: conversationId, conversation_id: conversationId,
callee_id: calleeId, callee_id: calleeId,
call_type: callType,
}); });
} }

View File

@@ -12,7 +12,9 @@ import { useAuthStore } from './authStore';
import { useUserStore } from './userStore'; import { useUserStore } from './userStore';
import { userManager } from './userManager'; 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 { export interface CallSession {
id: string; id: string;
@@ -21,10 +23,13 @@ export interface CallSession {
peerName?: string; peerName?: string;
peerAvatar?: string | null; peerAvatar?: string | null;
status: CallStatus; status: CallStatus;
callType: CallType;
startedAt?: number; startedAt?: number;
duration: number; duration: number;
isMuted: boolean; isMuted: boolean;
isSpeakerOn: boolean; isSpeakerOn: boolean;
isVideoEnabled: boolean;
isPeerVideoEnabled: boolean;
isInitiator: boolean; isInitiator: boolean;
} }
@@ -45,13 +50,15 @@ interface CallState {
incomingCall: IncomingCallInfo | null; incomingCall: IncomingCallInfo | null;
callDuration: number; callDuration: number;
peerStream: MediaStream | null; peerStream: MediaStream | null;
localStream: MediaStream | null;
isMinimized: boolean; isMinimized: boolean;
initCall: () => () => void; initCall: () => () => void;
startCall: ( startCall: (
conversationId: string, conversationId: string,
calleeId: string, calleeId: string,
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null } calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
callType?: CallType
) => Promise<void>; ) => Promise<void>;
acceptCall: () => Promise<void>; acceptCall: () => Promise<void>;
rejectCall: () => void; rejectCall: () => void;
@@ -59,23 +66,26 @@ interface CallState {
toggleMute: () => void; toggleMute: () => void;
toggleSpeaker: () => void; toggleSpeaker: () => void;
toggleMinimize: () => void; toggleMinimize: () => void;
toggleVideo: () => Promise<void>;
setVideoEnabled: (enabled: boolean) => Promise<void>;
} }
// Module-level variables for timers
let durationTimer: ReturnType<typeof setInterval> | null = null; let durationTimer: ReturnType<typeof setInterval> | null = null;
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null; let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
let initCallUnsub: (() => void) | null = null; let initCallUnsub: (() => void) | null = null;
let unsubInvited: (() => void) | null = null; let unsubInvited: (() => void) | null = null;
let pendingOffer: { callId: string; sdp: string } | null = null; let pendingOffer: { callId: string; sdp: string } | null = null;
let rtcUnsubscribe: (() => void) | null = null; // WebRTC event subscription
// === Element + Telegram 结合: 常量 === // Constants
const CALL_LIFETIME_MS = 55000; // 55秒 (略小于后端60秒) const CALL_LIFETIME_MS = 55000;
const CALL_TIMEOUT_MS = 115000; // 115秒 (拨打方等待超时略小于后端120秒) const CALL_TIMEOUT_MS = 115000;
const IGNORE_CALL_ID_TTL = 30000; // 已处理callId保留30秒 const IGNORE_CALL_ID_TTL = 30000;
// === Element: 已处理的 callId 集合 (防重复) === // Track processed call IDs to prevent duplicates
const processedCallIds = new Map<string, number>(); // callId -> timestamp const processedCallIds = new Map<string, number>();
// 清理过期的 callId
function cleanupProcessedCallIds() { function cleanupProcessedCallIds() {
const now = Date.now(); const now = Date.now();
for (const [callId, timestamp] of processedCallIds) { 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<void> {
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<void> {
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<CallState>((set, get) => ({ export const callStore = create<CallState>((set, get) => ({
currentCall: null, currentCall: null,
incomingCall: null, incomingCall: null,
callDuration: 0, callDuration: 0,
peerStream: null, peerStream: null,
localStream: null,
isMinimized: false, isMinimized: false,
initCall: () => { initCall: () => {
@@ -103,10 +306,8 @@ export const callStore = create<CallState>((set, get) => ({
unsubs.push( unsubs.push(
wsService.on('call_incoming', async (msg) => { wsService.on('call_incoming', async (msg) => {
// === Element: 清理过期的 callId ===
cleanupProcessedCallIds(); cleanupProcessedCallIds();
// === Element: 检查是否已处理过该 callId ===
if (processedCallIds.has(msg.call_id)) { if (processedCallIds.has(msg.call_id)) {
console.log('[CallStore] Ignoring already processed call:', msg.call_id); console.log('[CallStore] Ignoring already processed call:', msg.call_id);
return; return;
@@ -124,17 +325,17 @@ export const callStore = create<CallState>((set, get) => ({
return; return;
} }
// === Element: 检查 lifetime 过期 === // Check lifetime expiry
const callAge = Date.now() - msg.created_at; const callAge = Date.now() - msg.created_at;
const lifetime = msg.lifetime || 60000; // 默认60秒 const lifetime = msg.lifetime || 60000;
if (callAge > lifetime - 5000) { // 留5秒余量 if (callAge > lifetime - 5000) {
console.log('[CallStore] Ignoring stale incoming call, age:', callAge, 'ms, lifetime:', lifetime); console.log('[CallStore] Ignoring stale incoming call, age:', callAge);
wsService.sendCallReject(msg.call_id); wsService.sendCallReject(msg.call_id);
processedCallIds.set(msg.call_id, Date.now()); processedCallIds.set(msg.call_id, Date.now());
return; 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 = let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
useUserStore.getState().userCache[msg.caller_id]; useUserStore.getState().userCache[msg.caller_id];
if (!caller) { if (!caller) {
@@ -167,12 +368,11 @@ export const callStore = create<CallState>((set, get) => ({
}, },
}); });
// 标记为已处理
processedCallIds.set(msg.call_id, Date.now()); processedCallIds.set(msg.call_id, Date.now());
// 设置超时 (使用 lifetime) // Set timeout based on lifetime
if (callTimeoutTimer) clearTimeout(callTimeoutTimer); if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
const remainingTime = Math.max(lifetime - callAge - 1000, 5000); // 剩余时间最少5秒 const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
callTimeoutTimer = setTimeout(() => { callTimeoutTimer = setTimeout(() => {
const { incomingCall: ic } = get(); const { incomingCall: ic } = get();
if (ic?.callId === msg.call_id) { if (ic?.callId === msg.call_id) {
@@ -190,40 +390,30 @@ export const callStore = create<CallState>((set, get) => ({
const { currentCall } = get(); const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return; 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 { try {
const isVideoCall = currentCall.callType === 'video';
await webrtcManager.initialize(msg.ice_servers || []); await webrtcManager.initialize(msg.ice_servers || []);
await webrtcManager.createLocalStream(true); const newStream = await webrtcManager.createLocalStream(!isVideoCall);
// Listen for WebRTC events set({ localStream: newStream });
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 // Setup unified WebRTC event handler
const offer = await webrtcManager.startCall(true); setupWebRTCEvents(currentCall.id, myUserId);
// Start call with transceiver-based approach
const offer = await webrtcManager.startCall(true, currentCall.callType);
if (offer) { if (offer) {
wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || ''); wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || '');
} }
@@ -256,17 +446,14 @@ export const callStore = create<CallState>((set, get) => ({
wsService.on('call_ended', (msg) => { wsService.on('call_ended', (msg) => {
const { currentCall, incomingCall } = get(); const { currentCall, incomingCall } = get();
// 标记为已处理
processedCallIds.set(msg.call_id, Date.now()); processedCallIds.set(msg.call_id, Date.now());
// If this is an active call, end it
if (currentCall?.id === msg.call_id) { if (currentCall?.id === msg.call_id) {
console.log('[CallStore] Call ended, duration:', msg.duration); console.log('[CallStore] Call ended, duration:', msg.duration);
get().endCall('ended'); get().endCall('ended');
return; return;
} }
// If this is an incoming call that was cancelled by caller, clear it
if (incomingCall?.callId === msg.call_id) { if (incomingCall?.callId === msg.call_id) {
console.log('[CallStore] Incoming call cancelled by caller'); console.log('[CallStore] Incoming call cancelled by caller');
if (callTimeoutTimer) { if (callTimeoutTimer) {
@@ -278,7 +465,6 @@ export const callStore = create<CallState>((set, get) => ({
}) })
); );
// === Telegram: 其他设备已接听 ===
unsubs.push( unsubs.push(
wsService.on('call_answered_elsewhere', (msg) => { wsService.on('call_answered_elsewhere', (msg) => {
const { incomingCall } = get(); const { incomingCall } = get();
@@ -294,13 +480,11 @@ export const callStore = create<CallState>((set, get) => ({
}) })
); );
// === Telegram: 处理服务端错误 ===
unsubs.push( unsubs.push(
wsService.on('error', (msg: WSErrorMessage) => { wsService.on('error', (msg: WSErrorMessage) => {
const { currentCall, incomingCall } = get(); const { currentCall, incomingCall } = get();
console.log('[CallStore] Server error:', msg.code, msg.message); console.log('[CallStore] Server error:', msg.code, msg.message);
// 处理 callee_offline 错误
if (msg.code === 'callee_offline') { if (msg.code === 'callee_offline') {
if (currentCall && currentCall.status === 'ringing') { if (currentCall && currentCall.status === 'ringing') {
console.log('[CallStore] Callee is offline'); console.log('[CallStore] Callee is offline');
@@ -308,7 +492,6 @@ export const callStore = create<CallState>((set, get) => ({
} }
} }
// 处理 call_already_answered 错误
if (msg.code === 'call_already_answered') { if (msg.code === 'call_already_answered') {
if (incomingCall) { if (incomingCall) {
console.log('[CallStore] Call already answered on another device'); console.log('[CallStore] Call already answered on another device');
@@ -328,50 +511,13 @@ export const callStore = create<CallState>((set, get) => ({
const { currentCall } = get(); const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return; 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 || '';
const myUserId = useAuthStore.getState().currentUser?.id; if (msg.from_id === myUserId) return;
if (myUserId && msg.from_id === myUserId) return;
try {
const pc = webrtcManager.getPeerConnection();
if (msg.payload.sdp_type === 'offer') { if (msg.payload.sdp_type === 'offer') {
if (!pc) { await handleIncomingOffer(msg, myUserId);
// 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') { } else if (msg.payload.sdp_type === 'answer') {
if (!pc) return; await handleIncomingAnswer(msg);
// 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);
} }
}) })
); );
@@ -381,9 +527,8 @@ export const callStore = create<CallState>((set, get) => ({
const { currentCall } = get(); const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return; if (!currentCall || currentCall.id !== msg.call_id) return;
// Ignore ICE candidates from ourselves const myUserId = useAuthStore.getState().currentUser?.id || '';
const myUserId = useAuthStore.getState().currentUser?.id; if (msg.from_id === myUserId) return;
if (myUserId && msg.from_id === myUserId) return;
try { try {
const candidate = typeof msg.payload.candidate === 'string' const candidate = typeof msg.payload.candidate === 'string'
@@ -403,8 +548,11 @@ export const callStore = create<CallState>((set, get) => ({
); );
const cleanup = () => { const cleanup = () => {
if (callTimeoutTimer) clearTimeout(callTimeoutTimer); cleanupResources();
if (durationTimer) clearInterval(durationTimer); if (unsubInvited) {
unsubInvited();
unsubInvited = null;
}
unsubs.forEach((unsub) => unsub()); unsubs.forEach((unsub) => unsub());
initCallUnsub = null; initCallUnsub = null;
}; };
@@ -415,7 +563,8 @@ export const callStore = create<CallState>((set, get) => ({
startCall: async ( startCall: async (
conversationId: string, conversationId: string,
calleeId: 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(); const { currentCall } = get();
if (currentCall && currentCall.status !== 'idle') { if (currentCall && currentCall.status !== 'idle') {
@@ -429,29 +578,30 @@ export const callStore = create<CallState>((set, get) => ({
return; return;
} }
// Use provided callee info first, then fall back to userCache
const cachedCallee = useUserStore.getState().userCache[calleeId]; const cachedCallee = useUserStore.getState().userCache[calleeId];
const callee = calleeInfo || cachedCallee; const callee = calleeInfo || cachedCallee;
const calleeName = callee?.nickname || callee?.username || calleeId; const calleeName = callee?.nickname || callee?.username || calleeId;
set({ set({
currentCall: { currentCall: {
id: '', // Will be filled when call_invited response comes id: '',
conversationId, conversationId,
peerId: calleeId, peerId: calleeId,
peerName: calleeName, peerName: calleeName,
peerAvatar: callee?.avatar, peerAvatar: callee?.avatar,
callType,
status: 'ringing', status: 'ringing',
duration: 0, duration: 0,
isMuted: false, isMuted: false,
isSpeakerOn: false, isSpeakerOn: false,
isVideoEnabled: callType === 'video',
isPeerVideoEnabled: false,
isInitiator: true, isInitiator: true,
}, },
}); });
wsService.sendCallInvite(conversationId, calleeId); wsService.sendCallInvite(conversationId, calleeId, callType);
// Listen for call_invited to get the call_id
if (unsubInvited) { if (unsubInvited) {
unsubInvited(); unsubInvited();
} }
@@ -463,7 +613,6 @@ export const callStore = create<CallState>((set, get) => ({
})); }));
}); });
// Timeout - 使用 CALL_TIMEOUT_MS (115秒略小于后端 120秒)
if (callTimeoutTimer) clearTimeout(callTimeoutTimer); if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
callTimeoutTimer = setTimeout(() => { callTimeoutTimer = setTimeout(() => {
const { currentCall: cc } = get(); const { currentCall: cc } = get();
@@ -483,6 +632,9 @@ export const callStore = create<CallState>((set, get) => ({
callTimeoutTimer = null; callTimeoutTimer = null;
} }
const isVideoCall = incomingCall.callType === 'video';
const myUserId = useAuthStore.getState().currentUser?.id || '';
set({ set({
currentCall: { currentCall: {
id: incomingCall.callId, id: incomingCall.callId,
@@ -490,10 +642,13 @@ export const callStore = create<CallState>((set, get) => ({
peerId: incomingCall.callerId, peerId: incomingCall.callerId,
peerName: incomingCall.callerName, peerName: incomingCall.callerName,
peerAvatar: incomingCall.callerAvatar, peerAvatar: incomingCall.callerAvatar,
callType: incomingCall.callType as CallType,
status: 'connecting', status: 'connecting',
duration: 0, duration: 0,
isMuted: false, isMuted: false,
isSpeakerOn: false, isSpeakerOn: false,
isVideoEnabled: isVideoCall,
isPeerVideoEnabled: isVideoCall,
isInitiator: false, isInitiator: false,
}, },
incomingCall: null, incomingCall: null,
@@ -503,55 +658,18 @@ export const callStore = create<CallState>((set, get) => ({
try { try {
await webrtcManager.initialize(incomingCall.iceServers); await webrtcManager.initialize(incomingCall.iceServers);
await webrtcManager.createLocalStream(true); const newStream = await webrtcManager.createLocalStream(!isVideoCall);
// Listen for WebRTC events set({ localStream: newStream });
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) // Setup unified WebRTC event handler
await webrtcManager.startCall(false); setupWebRTCEvents(incomingCall.callId, myUserId);
// Process any pending offer that arrived before PeerConnection was ready // Start call (non-initiator, will wait for offer)
if (pendingOffer && pendingOffer.callId === incomingCall.callId) { await webrtcManager.startCall(false, incomingCall.callType as CallType);
const offerMsg = pendingOffer;
pendingOffer = null; // Note: For non-initiator, we don't create an offer here.
console.log('[CallStore] Processing pending offer after PeerConnection ready'); // We wait for the initiator's offer via handleIncomingOffer.
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) { } catch (err) {
console.error('[CallStore] Failed to accept call:', err); console.error('[CallStore] Failed to accept call:', err);
get().endCall('connection_failed'); get().endCall('connection_failed');
@@ -575,19 +693,12 @@ export const callStore = create<CallState>((set, get) => ({
const { currentCall } = get(); const { currentCall } = get();
if (!currentCall) return; if (!currentCall) return;
if (callTimeoutTimer) { cleanupResources();
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
if (unsubInvited) { if (unsubInvited) {
unsubInvited(); unsubInvited();
unsubInvited = null; unsubInvited = null;
} }
pendingOffer = null;
const callId = currentCall.id; const callId = currentCall.id;
@@ -595,6 +706,7 @@ export const callStore = create<CallState>((set, get) => ({
currentCall: null, currentCall: null,
callDuration: 0, callDuration: 0,
peerStream: null, peerStream: null,
localStream: null,
}); });
webrtcManager.dispose(); webrtcManager.dispose();
@@ -631,4 +743,35 @@ export const callStore = create<CallState>((set, get) => ({
toggleMinimize: () => { toggleMinimize: () => {
set((s) => ({ isMinimized: !s.isMinimized })); 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);
}
},
})); }));

View File

@@ -48,6 +48,7 @@ export {
useHomeTabPressStore, useHomeTabPressStore,
} from './homeTabPressStore'; } from './homeTabPressStore';
export { callStore } from './callStore'; export { callStore } from './callStore';
export type { CallType, CallSession, CallStatus } from './callStore';
export { export {
useAppColors, useAppColors,
useThemePreference, useThemePreference,