feat(CallScreen): enhance video call functionality and UI updates
- 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:
@@ -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;
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 消息列表 */}
|
{/* 消息列表 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -179,8 +179,6 @@ export interface ChatHeaderProps {
|
|||||||
onGroupInfoPress?: () => void;
|
onGroupInfoPress?: () => void;
|
||||||
/** 是否为大屏幕模式 */
|
/** 是否为大屏幕模式 */
|
||||||
isWideScreen?: boolean;
|
isWideScreen?: boolean;
|
||||||
/** 拨打电话回调(仅私聊有效) */
|
|
||||||
onCallPress?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回复预览 Props
|
// 回复预览 Props
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user