feat(call): migrate from WebRTC to LiveKit
Replace the custom WebRTC implementation with LiveKit for improved stability and feature support. - Remove `react-native-webrtc` and custom `WebRTCManager` - Implement `LiveKitService` for room and track management - Update `callStore` to handle LiveKit events and connection states - Refactor `CallScreen` (mobile and web) to use `@livekit/react-native` and `VideoView` - Update WebSocket protocol to use `call_ready` instead of manual SDP/ICE exchanges - Add necessary camera and microphone permissions to `app.json` - Update Metro and Babel configurations for LiveKit compatibility
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
|
||||
import { VideoView } from '@livekit/react-native';
|
||||
|
||||
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
|
||||
import { callStore } from '../../stores/call';
|
||||
import { RTCView } from 'react-native-webrtc';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -17,12 +21,9 @@ const formatDuration = (seconds: number): string => {
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
|
||||
const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const peerStream = callStore((s) => s.peerStream);
|
||||
const localStream = callStore((s) => s.localStream);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const toggleMute = callStore((s) => s.toggleMute);
|
||||
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
||||
@@ -30,33 +31,66 @@ const CallScreen: React.FC = () => {
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
|
||||
// Track whether peer has video
|
||||
const [hasPeerVideo, setHasPeerVideo] = useState(false);
|
||||
// Track whether local has video
|
||||
const [hasLocalVideo, setHasLocalVideo] = useState(false);
|
||||
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
|
||||
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
|
||||
|
||||
// Check peer stream for video tracks
|
||||
// Subscribe to LiveKit track events
|
||||
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]);
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackSubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnsubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackMuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnmuted', ({ publication, participant }) => {
|
||||
if (publication.source === Track.Source.Camera && publication.track) {
|
||||
setRemoteVideoTrack(publication.track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Poll local video track from localParticipant
|
||||
const localPollInterval = setInterval(() => {
|
||||
const lp = liveKitService.localParticipant;
|
||||
if (lp) {
|
||||
const videoPub = lp.getTrackPublication(Track.Source.Camera);
|
||||
const track = (videoPub?.track ?? null) as VideoTrack | null;
|
||||
setLocalVideoTrack((prev) => (prev === track ? prev : track));
|
||||
} else {
|
||||
setLocalVideoTrack((prev) => (prev === null ? prev : null));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
clearInterval(localPollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
@@ -77,63 +111,52 @@ const CallScreen: React.FC = () => {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall('hangup');
|
||||
};
|
||||
const handleToggleMute = () => {
|
||||
toggleMute();
|
||||
};
|
||||
const handleToggleSpeaker = () => {
|
||||
toggleSpeaker();
|
||||
};
|
||||
const handleToggleVideo = () => {
|
||||
toggleVideo();
|
||||
};
|
||||
const handleMinimize = () => {
|
||||
toggleMinimize();
|
||||
};
|
||||
// Determine if we should show video UI
|
||||
const showRemoteVideo = hasPeerVideo;
|
||||
// Use currentCall.isVideoEnabled directly for local video
|
||||
// This is more reliable than checking localStream.getVideoTracks()
|
||||
// because the stream object reference may not trigger useEffect properly
|
||||
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
|
||||
|
||||
const showRemoteVideo = !!remoteVideoTrack;
|
||||
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
|
||||
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
{/* Background */}
|
||||
<View style={styles.background} />
|
||||
{/* Remote video - full screen when peer has video */}
|
||||
{showRemoteVideo && peerStream && (
|
||||
<RTCView
|
||||
streamURL={peerStream.toURL()}
|
||||
|
||||
{/* Remote video - full screen */}
|
||||
{showRemoteVideo && remoteVideoTrack && (
|
||||
<VideoView
|
||||
videoTrack={remoteVideoTrack}
|
||||
style={styles.fullScreenVideo}
|
||||
objectFit="cover"
|
||||
mirror={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Local video - picture in picture */}
|
||||
{showLocalVideo && localStream && (
|
||||
{showLocalVideo && localVideoTrack && (
|
||||
<View style={styles.localVideoContainer}>
|
||||
<RTCView
|
||||
streamURL={localStream.toURL()}
|
||||
<VideoView
|
||||
videoTrack={localVideoTrack}
|
||||
style={styles.localVideo}
|
||||
objectFit="cover"
|
||||
mirror={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Top area: minimize button */}
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.topButton}
|
||||
onPress={handleMinimize}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Center: Peer info - only show when no video */}
|
||||
{!isVideoCallActive && (
|
||||
<View style={styles.centerArea}>
|
||||
@@ -154,6 +177,7 @@ const CallScreen: React.FC = () => {
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Peer name overlay when video is active */}
|
||||
{isVideoCallActive && (
|
||||
<View style={styles.videoOverlayInfo}>
|
||||
@@ -163,13 +187,14 @@ const CallScreen: React.FC = () => {
|
||||
<Text style={styles.videoStatus}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
<View style={styles.controlRow}>
|
||||
{/* Mute */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
||||
onPress={handleToggleMute}
|
||||
onPress={toggleMute}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
||||
@@ -185,25 +210,25 @@ const CallScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
{/* Video toggle */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
|
||||
onPress={handleToggleVideo}
|
||||
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
|
||||
onPress={toggleVideo}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
|
||||
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={hasLocalVideo ? 'video' : 'video-off'}
|
||||
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
|
||||
size={26}
|
||||
color={hasLocalVideo ? '#333' : '#fff'}
|
||||
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
|
||||
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
|
||||
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
|
||||
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Speaker */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
||||
onPress={handleToggleSpeaker}
|
||||
onPress={toggleSpeaker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
||||
@@ -218,7 +243,7 @@ const CallScreen: React.FC = () => {
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* End call - prominent red button */}
|
||||
{/* End call */}
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
@@ -234,9 +259,6 @@ const CallScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const CONTROL_CIRCLE_SIZE = 56;
|
||||
const END_CALL_SIZE = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -408,5 +430,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export default CallScreen;
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Track, LocalVideoTrack, RemoteVideoTrack } from 'livekit-client';
|
||||
import { VideoView } from '@livekit/react-native';
|
||||
import { callStore } from '../../stores/call';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
|
||||
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
@@ -19,70 +25,224 @@ const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const toggleMute = callStore((s) => s.toggleMute);
|
||||
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
||||
const toggleVideo = callStore((s) => s.toggleVideo);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
|
||||
const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
|
||||
const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
|
||||
|
||||
// Subscribe to LiveKit track events
|
||||
useEffect(() => {
|
||||
const unsubs: (() => void)[] = [];
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackSubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnsubscribed', ({ track }) => {
|
||||
if (track.kind === Track.Kind.Video) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackMuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera) {
|
||||
setRemoteVideoTrack(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
liveKitService.on('trackUnmuted', ({ publication }) => {
|
||||
if (publication.source === Track.Source.Camera && publication.track) {
|
||||
setRemoteVideoTrack(publication.track as VideoTrack);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const localPollInterval = setInterval(() => {
|
||||
const lp = liveKitService.localParticipant;
|
||||
if (lp) {
|
||||
const videoPub = lp.getTrackPublication(Track.Source.Camera);
|
||||
const track = (videoPub?.track ?? null) as VideoTrack | null;
|
||||
setLocalVideoTrack((prev) => (prev === track ? prev : track));
|
||||
} else {
|
||||
setLocalVideoTrack((prev) => (prev === null ? prev : null));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
clearInterval(localPollInterval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
return '正在等待对方接听...';
|
||||
case 'ringing':
|
||||
return '通话功能暂不支持网页端';
|
||||
return '来电响铃中...';
|
||||
case 'connecting':
|
||||
return '通话功能暂不支持网页端';
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return '通话功能暂不支持网页端';
|
||||
return formatDuration(callDuration);
|
||||
case 'reconnecting':
|
||||
return '网络重连中...';
|
||||
case 'ended':
|
||||
return '通话已结束';
|
||||
case 'failed':
|
||||
return '连接失败';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall('hangup');
|
||||
};
|
||||
const showRemoteVideo = !!remoteVideoTrack;
|
||||
const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
|
||||
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
|
||||
{/* Background - single consistent color */}
|
||||
<View style={styles.background} />
|
||||
|
||||
{/* Center: Peer info */}
|
||||
<View style={styles.centerArea}>
|
||||
<View style={styles.avatarOuter}>
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
|
||||
</View>
|
||||
{showRemoteVideo && remoteVideoTrack && (
|
||||
<VideoView
|
||||
videoTrack={remoteVideoTrack}
|
||||
style={styles.fullScreenVideo}
|
||||
objectFit="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLocalVideo && localVideoTrack && (
|
||||
<View style={styles.localVideoContainer}>
|
||||
<VideoView
|
||||
videoTrack={localVideoTrack}
|
||||
style={styles.localVideo}
|
||||
objectFit="cover"
|
||||
mirror={true}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.peerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
)}
|
||||
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.topButton}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Bottom controls */}
|
||||
{!isVideoCallActive && (
|
||||
<View style={styles.centerArea}>
|
||||
<View style={styles.avatarOuter}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.peerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isVideoCallActive && (
|
||||
<View style={styles.videoOverlayInfo}>
|
||||
<Text style={styles.videoPeerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.videoStatus}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.controls}>
|
||||
{/* End call - prominent red button */}
|
||||
<View style={styles.controlRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
||||
onPress={toggleMute}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
|
||||
size={26}
|
||||
color={currentCall.isMuted ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
|
||||
{currentCall.isMuted ? '取消静音' : '静音'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
|
||||
onPress={toggleVideo}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
|
||||
size={26}
|
||||
color={currentCall.isVideoEnabled ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
|
||||
{currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
||||
onPress={toggleSpeaker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
|
||||
size={26}
|
||||
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
|
||||
{currentCall.isSpeakerOn ? '免提' : '听筒'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
onPress={() => endCall('hangup')}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.endCallCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.endCallLabel}>关闭</Text>
|
||||
<Text style={styles.endCallLabel}>挂断</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const END_CALL_SIZE = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
@@ -93,6 +253,25 @@ const styles = StyleSheet.create({
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 44,
|
||||
zIndex: 100,
|
||||
},
|
||||
topButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
centerArea: {
|
||||
position: 'absolute',
|
||||
top: '28%',
|
||||
@@ -132,20 +311,98 @@ const styles = StyleSheet.create({
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
fullScreenVideo: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
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%',
|
||||
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: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
},
|
||||
controlRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
controlButton: {
|
||||
alignItems: 'center',
|
||||
width: 70,
|
||||
},
|
||||
controlCircle: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.12)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlCircleActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
controlButtonActive: {},
|
||||
controlLabel: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
controlLabelActive: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
endCallButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallCircle: {
|
||||
width: END_CALL_SIZE,
|
||||
height: END_CALL_SIZE,
|
||||
borderRadius: END_CALL_SIZE / 2,
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -24,14 +24,11 @@ export type {
|
||||
WSCallRejectedMessage,
|
||||
WSCallBusyMessage,
|
||||
WSCallEndedMessage,
|
||||
WSCallSDPMessage,
|
||||
WSCallICEMessage,
|
||||
WSCallPeerMutedMessage,
|
||||
WSCallInvitedMessage,
|
||||
WSCallAnsweredElsewhereMessage,
|
||||
WSErrorMessage,
|
||||
WSServerMessage,
|
||||
ICEServerConfig,
|
||||
} from './wsService';
|
||||
|
||||
export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler';
|
||||
|
||||
@@ -39,8 +39,7 @@ export type WSMessageType =
|
||||
| 'call_accepted'
|
||||
| 'call_rejected'
|
||||
| 'call_busy'
|
||||
| 'call_sdp'
|
||||
| 'call_ice'
|
||||
| 'call_ready'
|
||||
| 'call_ended'
|
||||
| 'call_peer_muted'
|
||||
| 'call_invited'
|
||||
@@ -56,20 +55,12 @@ export interface WSCallIncomingMessage {
|
||||
media_type?: string;
|
||||
created_at: number;
|
||||
lifetime?: number;
|
||||
ice_servers?: ICEServerConfig[];
|
||||
}
|
||||
|
||||
export interface ICEServerConfig {
|
||||
urls: string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export interface WSCallAcceptedMessage {
|
||||
type: 'call_accepted';
|
||||
call_id: string;
|
||||
started_at: number;
|
||||
ice_servers?: ICEServerConfig[];
|
||||
}
|
||||
|
||||
export interface WSCallRejectedMessage {
|
||||
@@ -92,25 +83,6 @@ export interface WSCallEndedMessage {
|
||||
ended_at?: number;
|
||||
}
|
||||
|
||||
export interface WSCallSDPMessage {
|
||||
type: 'call_sdp';
|
||||
call_id: string;
|
||||
from_id: string;
|
||||
payload: {
|
||||
sdp_type: string;
|
||||
sdp: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WSCallICEMessage {
|
||||
type: 'call_ice';
|
||||
call_id: string;
|
||||
from_id: string;
|
||||
payload: {
|
||||
candidate: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WSCallPeerMutedMessage {
|
||||
type: 'call_peer_muted';
|
||||
call_id: string;
|
||||
@@ -282,8 +254,6 @@ export type WSMessage =
|
||||
| WSCallRejectedMessage
|
||||
| WSCallBusyMessage
|
||||
| WSCallEndedMessage
|
||||
| WSCallSDPMessage
|
||||
| WSCallICEMessage
|
||||
| WSCallPeerMutedMessage
|
||||
| WSCallInvitedMessage
|
||||
| WSCallAnsweredElsewhereMessage
|
||||
@@ -496,12 +466,6 @@ class WebSocketService {
|
||||
case 'call_busy':
|
||||
this.handleCallBusy(payload);
|
||||
break;
|
||||
case 'call_sdp':
|
||||
this.handleCallSDP(payload);
|
||||
break;
|
||||
case 'call_ice':
|
||||
this.handleCallICE(payload);
|
||||
break;
|
||||
case 'call_ended':
|
||||
this.handleCallEnded(payload);
|
||||
break;
|
||||
@@ -664,7 +628,6 @@ class WebSocketService {
|
||||
call_type: payload.media_type || payload.call_type,
|
||||
created_at: payload.created_at,
|
||||
lifetime: payload.lifetime,
|
||||
ice_servers: payload.ice_servers,
|
||||
};
|
||||
this.emit('call_incoming', m);
|
||||
}
|
||||
@@ -674,7 +637,6 @@ class WebSocketService {
|
||||
type: 'call_accepted',
|
||||
call_id: payload.call_id,
|
||||
started_at: payload.started_at,
|
||||
ice_servers: payload.ice_servers,
|
||||
};
|
||||
this.emit('call_accepted', m);
|
||||
}
|
||||
@@ -696,26 +658,6 @@ class WebSocketService {
|
||||
this.emit('call_busy', m);
|
||||
}
|
||||
|
||||
private handleCallSDP(payload: any): void {
|
||||
const m: WSCallSDPMessage = {
|
||||
type: 'call_sdp',
|
||||
call_id: payload.call_id,
|
||||
from_id: payload.from_id,
|
||||
payload: payload.payload,
|
||||
};
|
||||
this.emit('call_sdp', m);
|
||||
}
|
||||
|
||||
private handleCallICE(payload: any): void {
|
||||
const m: WSCallICEMessage = {
|
||||
type: 'call_ice',
|
||||
call_id: payload.call_id,
|
||||
from_id: payload.from_id,
|
||||
payload: payload.payload,
|
||||
};
|
||||
this.emit('call_ice', m);
|
||||
}
|
||||
|
||||
private handleCallEnded(payload: any): void {
|
||||
const m: WSCallEndedMessage = {
|
||||
type: 'call_ended',
|
||||
@@ -793,21 +735,6 @@ class WebSocketService {
|
||||
this.sendFireAndForget('call_busy', { call_id: callId });
|
||||
}
|
||||
|
||||
sendCallSDP(callId: string, sdpType: string, sdp: string): void {
|
||||
this.sendFireAndForget('call_sdp', {
|
||||
call_id: callId,
|
||||
sdp_type: sdpType,
|
||||
sdp,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallICE(callId: string, candidate: string): void {
|
||||
this.sendFireAndForget('call_ice', {
|
||||
call_id: callId,
|
||||
candidate,
|
||||
});
|
||||
}
|
||||
|
||||
sendCallEnd(callId: string, reason?: string): void {
|
||||
this.sendFireAndForget('call_end', { call_id: callId, reason });
|
||||
}
|
||||
@@ -816,6 +743,10 @@ class WebSocketService {
|
||||
this.sendFireAndForget('call_mute', { call_id: callId, muted });
|
||||
}
|
||||
|
||||
sendCallReady(callId: string): void {
|
||||
this.sendFireAndForget('call_ready', { call_id: callId });
|
||||
}
|
||||
|
||||
private handleMessageSent(payload: any): void {
|
||||
// 找到对应的发送请求并resolve
|
||||
const pendingMsg = this.pendingMessages.find(p =>
|
||||
|
||||
220
src/services/livekit/LiveKitService.ts
Normal file
220
src/services/livekit/LiveKitService.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Room, RoomEvent, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackPublication, Participant as LKParticipant } from 'livekit-client';
|
||||
|
||||
export interface LiveKitServiceConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'failed';
|
||||
|
||||
export interface LiveKitEventMap {
|
||||
connected: undefined;
|
||||
disconnected: undefined;
|
||||
reconnecting: undefined;
|
||||
reconnected: undefined;
|
||||
trackSubscribed: { participant: RemoteParticipant; publication: RemoteTrackPublication; track: Track };
|
||||
trackUnsubscribed: { participant: RemoteParticipant; publication: RemoteTrackPublication; track: Track };
|
||||
trackMuted: { participant: LKParticipant; publication: TrackPublication };
|
||||
trackUnmuted: { participant: LKParticipant; publication: TrackPublication };
|
||||
connectionStatusChanged: ConnectionStatus;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
type EventHandler<K extends keyof LiveKitEventMap> = (data: LiveKitEventMap[K]) => void;
|
||||
|
||||
class LiveKitServiceImpl {
|
||||
private room: Room | null = null;
|
||||
private handlers: Map<string, Set<EventHandler<any>>> = new Map();
|
||||
private _connectionStatus: ConnectionStatus = 'disconnected';
|
||||
|
||||
get connectionStatus(): ConnectionStatus {
|
||||
return this._connectionStatus;
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connectionStatus === 'connected';
|
||||
}
|
||||
|
||||
get localParticipant(): LocalParticipant | null {
|
||||
return this.room?.localParticipant ?? null;
|
||||
}
|
||||
|
||||
get remoteParticipants(): Map<string, RemoteParticipant> {
|
||||
return this.room?.remoteParticipants ?? new Map();
|
||||
}
|
||||
|
||||
async connect(url: string, token: string): Promise<void> {
|
||||
if (this.room) {
|
||||
await this.disconnect();
|
||||
}
|
||||
|
||||
this.room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
});
|
||||
|
||||
this.setupRoomListeners();
|
||||
|
||||
this._connectionStatus = 'connecting';
|
||||
this.emit('connectionStatusChanged', 'connecting');
|
||||
|
||||
try {
|
||||
await this.room.connect(url, token);
|
||||
this._connectionStatus = 'connected';
|
||||
this.emit('connectionStatusChanged', 'connected');
|
||||
this.emit('connected', undefined);
|
||||
} catch (err) {
|
||||
this._connectionStatus = 'failed';
|
||||
this.emit('connectionStatusChanged', 'failed');
|
||||
this.emit('error', err instanceof Error ? err : new Error(String(err)));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.room) return;
|
||||
|
||||
this.room.off(RoomEvent.TrackSubscribed, this.onTrackSubscribed);
|
||||
this.room.off(RoomEvent.TrackUnsubscribed, this.onTrackUnsubscribed);
|
||||
this.room.off(RoomEvent.TrackMuted, this.onTrackMuted);
|
||||
this.room.off(RoomEvent.TrackUnmuted, this.onTrackUnmuted);
|
||||
this.room.off(RoomEvent.Disconnected, this.onDisconnected);
|
||||
this.room.off(RoomEvent.Reconnecting, this.onReconnecting);
|
||||
this.room.off(RoomEvent.Reconnected, this.onReconnected);
|
||||
this.room.off(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
|
||||
|
||||
await this.room.disconnect();
|
||||
this.room = null;
|
||||
this._connectionStatus = 'disconnected';
|
||||
this.emit('connectionStatusChanged', 'disconnected');
|
||||
this.emit('disconnected', undefined);
|
||||
}
|
||||
|
||||
async setMuted(muted: boolean): Promise<void> {
|
||||
if (!this.room?.localParticipant) return;
|
||||
await this.room.localParticipant.setMicrophoneEnabled(!muted);
|
||||
}
|
||||
|
||||
async setVideoEnabled(enabled: boolean): Promise<void> {
|
||||
if (!this.room?.localParticipant) return;
|
||||
await this.room.localParticipant.setCameraEnabled(enabled);
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
if (!this.room?.localParticipant) return true;
|
||||
const audioTrack = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
|
||||
return audioTrack?.isMuted ?? true;
|
||||
}
|
||||
|
||||
isVideoEnabled(): boolean {
|
||||
if (!this.room?.localParticipant) return false;
|
||||
const videoTrack = this.room.localParticipant.getTrackPublication(Track.Source.Camera);
|
||||
return videoTrack?.isSubscribed ?? false;
|
||||
}
|
||||
|
||||
getRemoteAudioTrack(): Track | null {
|
||||
for (const [, participant] of this.remoteParticipants) {
|
||||
const audioPub = participant.getTrackPublication(Track.Source.Microphone);
|
||||
if (audioPub?.track) return audioPub.track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getRemoteVideoTrack(): Track | null {
|
||||
for (const [, participant] of this.remoteParticipants) {
|
||||
const videoPub = participant.getTrackPublication(Track.Source.Camera);
|
||||
if (videoPub?.track) return videoPub.track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
on<K extends keyof LiveKitEventMap>(event: K, handler: EventHandler<K>): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
}
|
||||
this.handlers.get(event)!.add(handler);
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disconnect();
|
||||
this.handlers.clear();
|
||||
}
|
||||
|
||||
private setupRoomListeners(): void {
|
||||
if (!this.room) return;
|
||||
|
||||
this.room.on(RoomEvent.TrackSubscribed, this.onTrackSubscribed);
|
||||
this.room.on(RoomEvent.TrackUnsubscribed, this.onTrackUnsubscribed);
|
||||
this.room.on(RoomEvent.TrackMuted, this.onTrackMuted);
|
||||
this.room.on(RoomEvent.TrackUnmuted, this.onTrackUnmuted);
|
||||
this.room.on(RoomEvent.Disconnected, this.onDisconnected);
|
||||
this.room.on(RoomEvent.Reconnecting, this.onReconnecting);
|
||||
this.room.on(RoomEvent.Reconnected, this.onReconnected);
|
||||
this.room.on(RoomEvent.ConnectionStateChanged, this.onConnectionStateChanged);
|
||||
}
|
||||
|
||||
private onTrackSubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => {
|
||||
this.emit('trackSubscribed', { participant, publication, track });
|
||||
};
|
||||
|
||||
private onTrackUnsubscribed = (track: Track, publication: RemoteTrackPublication, participant: RemoteParticipant): void => {
|
||||
this.emit('trackUnsubscribed', { participant, publication, track });
|
||||
};
|
||||
|
||||
private onTrackMuted = (publication: TrackPublication, participant: LKParticipant): void => {
|
||||
this.emit('trackMuted', { participant, publication });
|
||||
};
|
||||
|
||||
private onTrackUnmuted = (publication: TrackPublication, participant: LKParticipant): void => {
|
||||
this.emit('trackUnmuted', { participant, publication });
|
||||
};
|
||||
|
||||
private onDisconnected = (): void => {
|
||||
this._connectionStatus = 'disconnected';
|
||||
this.emit('connectionStatusChanged', 'disconnected');
|
||||
this.emit('disconnected', undefined);
|
||||
};
|
||||
|
||||
private onReconnecting = (): void => {
|
||||
this._connectionStatus = 'reconnecting';
|
||||
this.emit('connectionStatusChanged', 'reconnecting');
|
||||
this.emit('reconnecting', undefined);
|
||||
};
|
||||
|
||||
private onReconnected = (): void => {
|
||||
this._connectionStatus = 'connected';
|
||||
this.emit('connectionStatusChanged', 'connected');
|
||||
this.emit('reconnected', undefined);
|
||||
};
|
||||
|
||||
private onConnectionStateChanged = (state: ConnectionState): void => {
|
||||
const status: ConnectionStatus = state === ConnectionState.Connected
|
||||
? 'connected'
|
||||
: state === ConnectionState.Connecting
|
||||
? 'connecting'
|
||||
: state === ConnectionState.Reconnecting
|
||||
? 'reconnecting'
|
||||
: 'disconnected';
|
||||
this._connectionStatus = status;
|
||||
this.emit('connectionStatusChanged', status);
|
||||
};
|
||||
|
||||
private emit<K extends keyof LiveKitEventMap>(event: K, data: LiveKitEventMap[K]): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (err) {
|
||||
console.error(`LiveKit event handler error for ${event}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const liveKitService = new LiveKitServiceImpl();
|
||||
export default liveKitService;
|
||||
2
src/services/livekit/index.ts
Normal file
2
src/services/livekit/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { liveKitService, default } from './LiveKitService';
|
||||
export type { LiveKitServiceConfig, LiveKitEventMap, ConnectionStatus } from './LiveKitService';
|
||||
@@ -1,672 +0,0 @@
|
||||
import {
|
||||
RTCPeerConnection,
|
||||
RTCSessionDescription,
|
||||
RTCIceCandidate,
|
||||
mediaDevices,
|
||||
MediaStream,
|
||||
} from 'react-native-webrtc';
|
||||
|
||||
export interface ICEServer {
|
||||
urls: string[];
|
||||
username?: string;
|
||||
credential?: string;
|
||||
}
|
||||
|
||||
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
export type WebRTCManagerEvent =
|
||||
| { type: 'icecandidate'; candidate: RTCIceCandidate | null }
|
||||
| { type: 'connectionstatechange'; state: ConnectionState }
|
||||
| { type: 'remotestream'; stream: MediaStream }
|
||||
| { type: 'negotiationneeded'; offer: RTCSessionDescriptionInit }
|
||||
| { type: 'error'; error: Error };
|
||||
|
||||
type EventHandler = (event: WebRTCManagerEvent) => void;
|
||||
|
||||
class WebRTCManager {
|
||||
private peerConnection: RTCPeerConnection | null = null;
|
||||
private localStream: MediaStream | null = null;
|
||||
private remoteStream: MediaStream | null = null;
|
||||
private pendingCandidates: RTCIceCandidateInit[] = [];
|
||||
private iceServers: ICEServer[] = [];
|
||||
private eventHandlers: Set<EventHandler> = new Set();
|
||||
private disposed = false;
|
||||
private isInitiator = false;
|
||||
|
||||
// ICE restart 相关状态
|
||||
private reconnectAttempts = 0;
|
||||
private readonly MAX_RECONNECT_ATTEMPTS = 3;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private disconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||
if (this.peerConnection) {
|
||||
this.dispose();
|
||||
}
|
||||
this.disposed = false;
|
||||
this.iceServers = iceServers.length > 0
|
||||
? iceServers
|
||||
: [{ urls: ['stun:stun.l.google.com:19302'] }];
|
||||
}
|
||||
|
||||
private buildPeerConnectionConfig(): RTCConfiguration {
|
||||
return {
|
||||
iceServers: this.iceServers.map((server) => ({
|
||||
urls: server.urls,
|
||||
...(server.username ? { username: server.username } : {}),
|
||||
...(server.credential ? { credential: server.credential } : {}),
|
||||
})),
|
||||
iceCandidatePoolSize: 10,
|
||||
};
|
||||
}
|
||||
|
||||
private createPeerConnection(): RTCPeerConnection {
|
||||
const config = this.buildPeerConnectionConfig();
|
||||
const pc = new RTCPeerConnection(config);
|
||||
|
||||
// @ts-ignore
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.emit({ type: 'icecandidate', candidate: event.candidate });
|
||||
} else {
|
||||
this.emit({ type: 'icecandidate', candidate: null });
|
||||
}
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
const state = pc.iceConnectionState as ConnectionState;
|
||||
this.emit({ type: 'connectionstatechange', state });
|
||||
this.handleIceConnectionStateChange(state);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState as ConnectionState;
|
||||
this.emit({ type: 'connectionstatechange', state });
|
||||
this.handleConnectionStateChange(state);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.ontrack = (event) => {
|
||||
console.log('[WebRTC] ontrack event, kind:', event.track?.kind, 'streams:', event.streams?.length ?? 0);
|
||||
if (!event.track) {
|
||||
console.log('[WebRTC] ontrack: no track in event');
|
||||
return;
|
||||
}
|
||||
|
||||
// Always manually construct/append to remoteStream to ensure all tracks are collected
|
||||
// react-native-webrtc may fire ontrack per-track and event.streams[0] might not contain all tracks
|
||||
if (!this.remoteStream) {
|
||||
this.remoteStream = new MediaStream();
|
||||
}
|
||||
|
||||
// Check if track already exists to avoid duplicates
|
||||
const existingTracks = this.remoteStream.getTracks();
|
||||
const trackExists = existingTracks.some(t => t.id === event.track.id);
|
||||
if (!trackExists) {
|
||||
this.remoteStream.addTrack(event.track);
|
||||
console.log('[WebRTC] Added remote track:', event.track.kind, 'total tracks:', this.remoteStream.getTracks().length);
|
||||
} else {
|
||||
console.log('[WebRTC] Remote track already exists:', event.track.kind);
|
||||
}
|
||||
|
||||
this.emit({ type: 'remotestream', stream: this.remoteStream });
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onsignalingstatechange = () => {
|
||||
console.log('[WebRTC] Signaling state changed:', pc.signalingState);
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
pc.onnegotiationneeded = async () => {
|
||||
console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState);
|
||||
if (!this.peerConnection || this.peerConnection !== pc) {
|
||||
console.log('[WebRTC] PeerConnection changed or disposed, skipping');
|
||||
return;
|
||||
}
|
||||
if (pc.signalingState !== 'stable') {
|
||||
console.log('[WebRTC] Skipping negotiation, not in stable state:', pc.signalingState);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const offer = await this.createOffer();
|
||||
if (this.peerConnection && !this.disposed) {
|
||||
this.emit({ type: 'negotiationneeded', offer });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WebRTC] Negotiation needed failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
|
||||
const constraints = voiceOnly
|
||||
? { audio: true, video: false }
|
||||
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
this.localStream = await mediaDevices.getUserMedia(constraints);
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to get local media stream:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a call using official addTrack approach
|
||||
* Follows react-native-webrtc CallGuide.md
|
||||
*/
|
||||
async startCall(isInitiator: boolean, callType: CallType = 'voice'): Promise<RTCSessionDescriptionInit | null> {
|
||||
if (this.disposed) throw new Error('WebRTCManager has been disposed');
|
||||
if (!this.localStream) throw new Error('Local stream not initialized');
|
||||
|
||||
this.isInitiator = isInitiator;
|
||||
this.peerConnection = this.createPeerConnection();
|
||||
|
||||
// Official approach: add all tracks using addTrack
|
||||
// addTrack automatically creates senders with proper directions
|
||||
this.localStream.getTracks().forEach((track) => {
|
||||
console.log('[WebRTC] Adding track:', track.kind, track.enabled);
|
||||
this.peerConnection!.addTrack(track, this.localStream!);
|
||||
});
|
||||
|
||||
if (isInitiator) {
|
||||
// For initiator, create offer directly
|
||||
const offer = await this.createOffer();
|
||||
return offer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
console.log('[WebRTC] Creating offer...');
|
||||
|
||||
const offerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true,
|
||||
};
|
||||
|
||||
const offer = await this.peerConnection.createOffer(offerOptions);
|
||||
|
||||
if (!this.peerConnection || this.disposed) {
|
||||
throw new Error('PeerConnection was disposed during offer creation');
|
||||
}
|
||||
|
||||
await this.peerConnection.setLocalDescription(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||
|
||||
console.log('[WebRTC] Creating answer...');
|
||||
|
||||
const answerOptions = {
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const answer = await this.peerConnection.createAnswer(answerOptions);
|
||||
|
||||
if (!this.peerConnection || this.disposed) {
|
||||
throw new Error('PeerConnection was disposed during answer creation');
|
||||
}
|
||||
|
||||
await this.peerConnection.setLocalDescription(answer);
|
||||
|
||||
// Process pending candidates
|
||||
await this.processPendingCandidates();
|
||||
|
||||
return answer;
|
||||
}
|
||||
|
||||
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
||||
await this.setRemoteDescription(offer);
|
||||
return this.createAnswer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to stable state (for glare handling)
|
||||
* Used when both peers try to negotiate simultaneously
|
||||
*/
|
||||
async rollback(): Promise<void> {
|
||||
if (!this.peerConnection) {
|
||||
throw new Error('PeerConnection not initialized');
|
||||
}
|
||||
|
||||
const pc = this.peerConnection;
|
||||
const signalingState = pc.signalingState;
|
||||
|
||||
console.log('[WebRTC] Attempting rollback, current state:', signalingState);
|
||||
|
||||
// Only rollback if we're not in stable state
|
||||
if (signalingState === 'stable') {
|
||||
console.log('[WebRTC] Already in stable state, no rollback needed');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// For react-native-webrtc, we may need to recreate the peer connection
|
||||
// as rollback is not fully supported
|
||||
if (signalingState === 'have-local-offer') {
|
||||
// Rollback local offer by setting local description to null/undefined
|
||||
// @ts-ignore - react-native-webrtc specific
|
||||
if (pc.setLocalDescription) {
|
||||
// @ts-ignore
|
||||
await pc.setLocalDescription({ type: 'rollback' });
|
||||
}
|
||||
} else if (signalingState === 'have-remote-offer') {
|
||||
// Rollback remote offer
|
||||
// @ts-ignore
|
||||
if (pc.setRemoteDescription) {
|
||||
// @ts-ignore
|
||||
await pc.setRemoteDescription({ type: 'rollback' });
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[WebRTC] Rollback successful');
|
||||
} catch (err) {
|
||||
console.error('[WebRTC] Rollback failed:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
if (!this.peerConnection) {
|
||||
console.error('[WebRTC] setRemoteDescription: PeerConnection is null');
|
||||
throw new Error('PeerConnection not initialized');
|
||||
}
|
||||
|
||||
if (!description.sdp) {
|
||||
throw new Error('setRemoteDescription: sdp is required');
|
||||
}
|
||||
|
||||
console.log('[WebRTC] Setting remote description, type:', description.type);
|
||||
|
||||
const desc = new RTCSessionDescription({
|
||||
type: description.type,
|
||||
sdp: description.sdp,
|
||||
});
|
||||
await this.peerConnection.setRemoteDescription(desc);
|
||||
|
||||
console.log('[WebRTC] Remote description set successfully');
|
||||
|
||||
// Process pending candidates after remote description is set
|
||||
await this.processPendingCandidates();
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (!this.peerConnection) {
|
||||
this.pendingCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.peerConnection.remoteDescription) {
|
||||
this.pendingCandidates.push(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const iceCandidate = new RTCIceCandidate(candidate);
|
||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to add ICE candidate:', error);
|
||||
this.pendingCandidates.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private async processPendingCandidates(): Promise<void> {
|
||||
if (this.pendingCandidates.length === 0) return;
|
||||
if (!this.peerConnection) return;
|
||||
|
||||
const candidates = [...this.pendingCandidates];
|
||||
this.pendingCandidates = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!this.peerConnection) return;
|
||||
|
||||
try {
|
||||
const iceCandidate = new RTCIceCandidate(candidate);
|
||||
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to add pending ICE candidate:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
if (!this.localStream) return;
|
||||
const audioTracks = this.localStream.getAudioTracks();
|
||||
audioTracks.forEach((track) => {
|
||||
track.enabled = !muted;
|
||||
});
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
if (!this.localStream) return false;
|
||||
const audioTracks = this.localStream.getAudioTracks();
|
||||
return audioTracks.some((track) => !track.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable video - official replaceTrack approach
|
||||
*/
|
||||
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 sender for video and replace the track
|
||||
const senders = this.peerConnection.getSenders();
|
||||
const videoSender = senders.find(s => s.track?.kind === 'video');
|
||||
|
||||
if (videoSender) {
|
||||
await videoSender.replaceTrack(videoTrack);
|
||||
console.log('[WebRTC] Video track replaced on existing sender');
|
||||
} else {
|
||||
// No existing video sender, add track
|
||||
this.peerConnection.addTrack(videoTrack, this.localStream!);
|
||||
console.log('[WebRTC] Video track added via addTrack');
|
||||
}
|
||||
|
||||
// Update local stream
|
||||
const newStream = new MediaStream();
|
||||
|
||||
if (this.localStream) {
|
||||
this.localStream.getAudioTracks().forEach((track) => {
|
||||
newStream.addTrack(track);
|
||||
});
|
||||
// Stop old video tracks
|
||||
this.localStream.getVideoTracks().forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
}
|
||||
|
||||
newStream.addTrack(videoTrack);
|
||||
this.localStream = newStream;
|
||||
|
||||
console.log('[WebRTC] Video enabled successfully');
|
||||
|
||||
return newStream;
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Failed to enable video:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable video - official replaceTrack approach
|
||||
*/
|
||||
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...');
|
||||
|
||||
// Find the sender for video and replace with null
|
||||
const senders = this.peerConnection.getSenders();
|
||||
const videoSender = senders.find(s => s.track?.kind === 'video');
|
||||
|
||||
if (videoSender) {
|
||||
await videoSender.replaceTrack(null);
|
||||
console.log('[WebRTC] Video track removed from sender');
|
||||
}
|
||||
|
||||
// Stop video tracks
|
||||
const videoTracks = this.localStream.getVideoTracks();
|
||||
videoTracks.forEach((track) => {
|
||||
track.stop();
|
||||
});
|
||||
|
||||
// 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');
|
||||
|
||||
return newStream;
|
||||
}
|
||||
|
||||
isVideoEnabled(): boolean {
|
||||
if (!this.localStream) return false;
|
||||
const videoTracks = this.localStream.getVideoTracks();
|
||||
return videoTracks.length > 0 && videoTracks.some((track) => track.enabled);
|
||||
}
|
||||
|
||||
getRemoteStream(): MediaStream | null {
|
||||
return this.remoteStream;
|
||||
}
|
||||
|
||||
getLocalStream(): MediaStream | null {
|
||||
return this.localStream;
|
||||
}
|
||||
|
||||
getPeerConnection(): RTCPeerConnection | null {
|
||||
return this.peerConnection;
|
||||
}
|
||||
|
||||
getSignalingState(): RTCSignalingState | null {
|
||||
return this.peerConnection?.signalingState || null;
|
||||
}
|
||||
|
||||
getIsInitiator(): boolean {
|
||||
return this.isInitiator;
|
||||
}
|
||||
|
||||
onEvent(handler: EventHandler): () => void {
|
||||
this.eventHandlers.add(handler);
|
||||
return () => {
|
||||
this.eventHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
private emit(event: WebRTCManagerEvent): void {
|
||||
this.eventHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler(event);
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] Event handler error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== ICE Restart 支持 ==========
|
||||
|
||||
/**
|
||||
* 处理 ICE 连接状态变化
|
||||
* 根据 W3C 规范: disconnected 状态可能间歇性触发并自发解决
|
||||
* failed 状态表示需要 ICE restart
|
||||
*/
|
||||
private handleIceConnectionStateChange(state: ConnectionState): void {
|
||||
console.log('[WebRTC] ICE connection state:', state);
|
||||
|
||||
switch (state) {
|
||||
case 'connected': // 连接成功,重置重连状态
|
||||
this.resetReconnectState();
|
||||
break;
|
||||
|
||||
case 'disconnected':
|
||||
// 临时断开,等待一段时间看是否自动恢复
|
||||
this.scheduleDisconnectCheck();
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
// ICE 失败,尝试 ICE restart
|
||||
this.attemptIceRestart();
|
||||
break;
|
||||
|
||||
case 'closed':
|
||||
this.clearReconnectTimers();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 PeerConnection 连接状态变化
|
||||
*/
|
||||
private handleConnectionStateChange(state: ConnectionState): void {
|
||||
console.log('[WebRTC] PeerConnection state:', state);
|
||||
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
this.resetReconnectState();
|
||||
break;
|
||||
|
||||
case 'disconnected':
|
||||
// 等待短暂时间看是否自动恢复
|
||||
this.scheduleDisconnectCheck();
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
// 连接完全失败
|
||||
this.emit({ type: 'error', error: new Error('Connection failed') });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 安排断开检查
|
||||
* 给 disconnected 状态一个恢复窗口(5秒)
|
||||
*/
|
||||
private scheduleDisconnectCheck(): void {
|
||||
if (this.disconnectTimer) {
|
||||
clearTimeout(this.disconnectTimer);
|
||||
}
|
||||
|
||||
this.disconnectTimer = setTimeout(() => {
|
||||
const pc = this.peerConnection;
|
||||
if (!pc || this.disposed) return;
|
||||
|
||||
// 如果 5 秒后仍然是 disconnected,尝试 ICE restart
|
||||
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
|
||||
console.log('[WebRTC] Connection still disconnected after 5s, attempting ICE restart');
|
||||
this.attemptIceRestart();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试 ICE restart
|
||||
* 参考: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Session_lifetime#ice_restart
|
||||
*/
|
||||
private async attemptIceRestart(): Promise<void> {
|
||||
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||
console.error('[WebRTC] Max reconnection attempts reached');
|
||||
this.emit({ type: 'error', error: new Error('Max reconnection attempts reached') });
|
||||
return;
|
||||
}
|
||||
|
||||
const pc = this.peerConnection;
|
||||
if (!pc || this.disposed) {
|
||||
console.log('[WebRTC] Cannot restart ICE: PeerConnection not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查信令状态
|
||||
if (pc.signalingState !== 'stable') {
|
||||
console.log('[WebRTC] Cannot restart ICE: signaling state not stable:', pc.signalingState);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
console.log(`[WebRTC] Attempting ICE restart (${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS})`);
|
||||
|
||||
try {
|
||||
// 尝试使用 restartIce() API (现代浏览器支持)
|
||||
// @ts-ignore
|
||||
if (pc.restartIce) {
|
||||
// @ts-ignore
|
||||
pc.restartIce();
|
||||
console.log('[WebRTC] restartIce() called');
|
||||
}
|
||||
|
||||
// 创建新的 offer,触发 ICE restart
|
||||
const offer = await pc.createOffer({ iceRestart: true });
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
console.log('[WebRTC] ICE restart offer created');
|
||||
|
||||
// 发送新的 offer 给对方
|
||||
this.emit({ type: 'negotiationneeded', offer });
|
||||
} catch (error) {
|
||||
console.error('[WebRTC] ICE restart failed:', error);
|
||||
this.emit({ type: 'error', error: error as Error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置重连状态
|
||||
*/
|
||||
private resetReconnectState(): void {
|
||||
this.reconnectAttempts = 0;
|
||||
this.clearReconnectTimers();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除重连定时器
|
||||
*/
|
||||
private clearReconnectTimers(): void {
|
||||
if (this.disconnectTimer) {
|
||||
clearTimeout(this.disconnectTimer);
|
||||
this.disconnectTimer = null;
|
||||
}
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.eventHandlers.clear();
|
||||
this.pendingCandidates = [];
|
||||
this.clearReconnectTimers();
|
||||
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach((track) => track.stop());
|
||||
this.localStream = null;
|
||||
}
|
||||
|
||||
if (this.remoteStream) {
|
||||
// Do not stop remote tracks; they are managed by the peer connection
|
||||
// and will be ended automatically when the connection closes
|
||||
this.remoteStream = null;
|
||||
}
|
||||
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const webrtcManager = new WebRTCManager();
|
||||
@@ -1,89 +0,0 @@
|
||||
// WebRTCManager for Web platform (stub implementation)
|
||||
import type {
|
||||
ICEServer,
|
||||
ConnectionState,
|
||||
WebRTCManagerEvent,
|
||||
} from './WebRTCManager';
|
||||
|
||||
type EventHandler = (event: WebRTCManagerEvent) => void;
|
||||
|
||||
class WebRTCManagerWeb {
|
||||
private eventHandlers: Set<EventHandler> = new Set();
|
||||
private disposed = false;
|
||||
|
||||
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||
console.log('[WebRTC] WebRTC not supported on web platform');
|
||||
}
|
||||
|
||||
async createLocalStream(voiceOnly = true): Promise<MediaStream | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async createOffer(): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
}
|
||||
|
||||
setMuted(muted: boolean): void {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
}
|
||||
|
||||
isMuted(): boolean {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return false;
|
||||
}
|
||||
|
||||
getRemoteStream(): MediaStream | null {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
getLocalStream(): MediaStream | null {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
getPeerConnection(): RTCPeerConnection | null {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
onEvent(handler: EventHandler): () => void {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return () => {
|
||||
this.eventHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.eventHandlers.clear();
|
||||
console.log('[WebRTC] WebRTC disposed on web platform');
|
||||
}
|
||||
}
|
||||
|
||||
export const webrtcManager = new WebRTCManagerWeb();
|
||||
@@ -1,2 +0,0 @@
|
||||
export { webrtcManager } from './WebRTCManager';
|
||||
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';
|
||||
@@ -1,26 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import { MediaStream } from 'react-native-webrtc';
|
||||
import {
|
||||
wsService,
|
||||
WSCallIncomingMessage,
|
||||
WSCallSDPMessage,
|
||||
WSCallICEMessage,
|
||||
WSErrorMessage,
|
||||
api,
|
||||
} from '@/services/core';
|
||||
import { webrtcManager, ICEServer } from '@/services/webrtc';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
import { getCurrentUserId } from '../auth/sessionStore';
|
||||
import { useUserStore } from '../userStore';
|
||||
import { userManager } from '../user/UserManager';
|
||||
|
||||
export type CallStatus =
|
||||
| 'idle' // 空闲状态
|
||||
| 'calling' // 正在呼出(已发送邀请,等待对方响应)
|
||||
| 'ringing' // 来电响铃中
|
||||
| 'connecting' // 正在建立连接(WebRTC 协商中)
|
||||
| 'connected' // 已接通
|
||||
| 'reconnecting' // 网络断开,正在重连
|
||||
| 'ended' // 已结束
|
||||
| 'failed'; // 连接失败
|
||||
export type CallStatus =
|
||||
| 'idle'
|
||||
| 'calling'
|
||||
| 'ringing'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'ended'
|
||||
| 'failed';
|
||||
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
@@ -39,6 +37,7 @@ export interface CallSession {
|
||||
isVideoEnabled: boolean;
|
||||
isPeerVideoEnabled: boolean;
|
||||
isInitiator: boolean;
|
||||
isPeerReady: boolean;
|
||||
}
|
||||
|
||||
export interface IncomingCallInfo {
|
||||
@@ -48,7 +47,6 @@ export interface IncomingCallInfo {
|
||||
callerName?: string;
|
||||
callerAvatar?: string | null;
|
||||
callType: string;
|
||||
iceServers: ICEServer[];
|
||||
receivedAt: number;
|
||||
lifetime?: number;
|
||||
}
|
||||
@@ -57,8 +55,6 @@ interface CallState {
|
||||
currentCall: CallSession | null;
|
||||
incomingCall: IncomingCallInfo | null;
|
||||
callDuration: number;
|
||||
peerStream: MediaStream | null;
|
||||
localStream: MediaStream | null;
|
||||
isMinimized: boolean;
|
||||
|
||||
initCall: () => () => void;
|
||||
@@ -84,15 +80,13 @@ let durationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let initCallUnsub: (() => void) | null = null;
|
||||
let unsubInvited: (() => void) | null = null;
|
||||
let pendingOffer: { callId: string; sdp: string } | null = null;
|
||||
let rtcUnsubscribe: (() => void) | null = null; // WebRTC event subscription
|
||||
let liveKitUnsubs: (() => void)[] = [];
|
||||
|
||||
// Constants
|
||||
const CALL_LIFETIME_MS = 55000;
|
||||
const CALL_TIMEOUT_MS = 115000;
|
||||
const IGNORE_CALL_ID_TTL = 30000;
|
||||
|
||||
// Track processed call IDs to prevent duplicates
|
||||
const processedCallIds = new Map<string, number>();
|
||||
|
||||
function cleanupProcessedCallIds() {
|
||||
@@ -105,222 +99,160 @@ function cleanupProcessedCallIds() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified WebRTC event handler
|
||||
* This is called from both initiator and receiver paths
|
||||
* Fetch LiveKit token from the backend and connect to the room.
|
||||
*/
|
||||
function setupWebRTCEvents(callId: string, myUserId: string): void {
|
||||
// Clean up any existing subscription
|
||||
if (rtcUnsubscribe) {
|
||||
rtcUnsubscribe();
|
||||
rtcUnsubscribe = null;
|
||||
async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<void> {
|
||||
const res = await api.get<{ token: string; url: string }>('/calls/token', {
|
||||
room: callId,
|
||||
});
|
||||
|
||||
const { token, url } = res.data;
|
||||
if (!token || !url) {
|
||||
throw new Error('Invalid LiveKit token response');
|
||||
}
|
||||
|
||||
rtcUnsubscribe = webrtcManager.onEvent((event) => {
|
||||
switch (event.type) {
|
||||
case 'icecandidate':
|
||||
if (event.candidate) {
|
||||
wsService.sendCallICE(callId, JSON.stringify(event.candidate.toJSON()));
|
||||
}
|
||||
break;
|
||||
setupLiveKitEvents(callId);
|
||||
|
||||
case 'remotestream':
|
||||
handleRemoteStream(event.stream);
|
||||
break;
|
||||
await liveKitService.connect(url, token);
|
||||
|
||||
case 'negotiationneeded':
|
||||
handleNegotiationNeeded(callId, event.offer);
|
||||
break;
|
||||
|
||||
case 'connectionstatechange':
|
||||
handleConnectionStateChange(event.state);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[CallStore] WebRTC error:', event.error);
|
||||
break;
|
||||
}
|
||||
});
|
||||
// Enable/disable video based on call type
|
||||
await liveKitService.setVideoEnabled(videoEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle remote stream with video track detection
|
||||
* Set up LiveKit room event handlers.
|
||||
*/
|
||||
function handleRemoteStream(stream: MediaStream): void {
|
||||
// Create a new MediaStream instance to force zustand subscribers to update
|
||||
// because react-native-webrtc may add tracks to the same stream object reference
|
||||
const newStream = new MediaStream();
|
||||
stream.getTracks().forEach((track) => {
|
||||
newStream.addTrack(track);
|
||||
});
|
||||
function setupLiveKitEvents(callId: string): void {
|
||||
// Clean up any existing subscriptions
|
||||
liveKitUnsubs.forEach((unsub) => unsub());
|
||||
liveKitUnsubs = [];
|
||||
|
||||
callStore.setState({ peerStream: newStream });
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('connected', () => {
|
||||
console.log('[CallStore] LiveKit connected');
|
||||
const now = Date.now();
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
: null,
|
||||
}));
|
||||
|
||||
// Detect video tracks in remote stream
|
||||
const videoTracks = newStream.getVideoTracks();
|
||||
const hasPeerVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||
wsService.sendCallReady(callId);
|
||||
|
||||
console.log('[CallStore] Remote stream received, hasVideo:', hasPeerVideo, 'videoTracks:', videoTracks.length, 'audioTracks:', newStream.getAudioTracks().length);
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
})
|
||||
);
|
||||
|
||||
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 with enhanced state machine
|
||||
*/
|
||||
function handleConnectionStateChange(state: string): void {
|
||||
console.log('[CallStore] Connection state changed:', state);
|
||||
|
||||
const { currentCall } = callStore.getState();
|
||||
if (!currentCall) return;
|
||||
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
// 连接成功,开始计时
|
||||
const now = Date.now();
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('disconnected', () => {
|
||||
console.log('[CallStore] LiveKit disconnected');
|
||||
const { currentCall } = callStore.getState();
|
||||
if (currentCall && currentCall.status !== 'ended' && currentCall.status !== 'failed') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
? { ...s.currentCall, status: 'reconnecting' }
|
||||
: null,
|
||||
}));
|
||||
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
break;
|
||||
|
||||
case 'disconnected':
|
||||
// 临时断开,进入重连状态
|
||||
if (currentCall.status === 'connected') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'reconnecting' }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
// 连接失败
|
||||
if (currentCall.status === 'connected' || currentCall.status === 'reconnecting') {
|
||||
callStore.getState().endCall('connection_failed');
|
||||
} else if (currentCall.status === 'connecting') {
|
||||
// 初始连接失败
|
||||
callStore.getState().endCall('connection_failed');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'closed':
|
||||
// 连接关闭
|
||||
if (currentCall.status !== 'ended' && currentCall.status !== 'failed') {
|
||||
callStore.getState().endCall('connection_closed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
let 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);
|
||||
// If rollback fails because we're already stable, that's fine - just proceed
|
||||
if (pc.signalingState !== 'stable') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Re-check state after rollback
|
||||
signalingState = pc.signalingState;
|
||||
if (signalingState !== 'stable') {
|
||||
console.warn('[CallStore] Not stable after rollback, state:', signalingState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('reconnecting', () => {
|
||||
console.log('[CallStore] LiveKit reconnecting');
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'reconnecting' }
|
||||
: null,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
pendingOffer = null;
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('reconnected', () => {
|
||||
console.log('[CallStore] LiveKit reconnected');
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected' }
|
||||
: null,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
// Set remote description and create answer
|
||||
try {
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type as 'offer' | 'answer',
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('connectionStatusChanged', (status) => {
|
||||
console.log('[CallStore] LiveKit connection status:', status);
|
||||
const { currentCall } = callStore.getState();
|
||||
if (!currentCall) return;
|
||||
|
||||
const answer = await webrtcManager.createAnswer();
|
||||
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to handle incoming offer:', err);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
callStore.getState().endCall('connection_failed');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackSubscribed', ({ track }) => {
|
||||
console.log('[CallStore] Remote track subscribed:', track.kind);
|
||||
if (track.kind === 'video') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: true }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackUnsubscribed', ({ track }) => {
|
||||
console.log('[CallStore] Remote track unsubscribed:', track.kind);
|
||||
if (track.kind === 'video') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: false }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackMuted', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: false }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackUnmuted', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: true }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('error', (err) => {
|
||||
console.error('[CallStore] LiveKit error:', 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
|
||||
* Clean up all resources.
|
||||
*/
|
||||
function cleanupResources(): void {
|
||||
if (callTimeoutTimer) {
|
||||
@@ -331,23 +263,17 @@ function cleanupResources(): void {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
if (rtcUnsubscribe) {
|
||||
rtcUnsubscribe();
|
||||
rtcUnsubscribe = null;
|
||||
}
|
||||
pendingOffer = null;
|
||||
liveKitUnsubs.forEach((unsub) => unsub());
|
||||
liveKitUnsubs = [];
|
||||
}
|
||||
|
||||
export const callStore = create<CallState>((set, get) => ({
|
||||
currentCall: null,
|
||||
incomingCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
isMinimized: false,
|
||||
|
||||
initCall: () => {
|
||||
// Prevent duplicate handler registration
|
||||
if (initCallUnsub) {
|
||||
initCallUnsub();
|
||||
initCallUnsub = null;
|
||||
@@ -376,7 +302,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check lifetime expiry
|
||||
const callAge = Date.now() - msg.created_at;
|
||||
const lifetime = msg.lifetime || 60000;
|
||||
if (callAge > lifetime - 5000) {
|
||||
@@ -386,7 +311,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch caller info
|
||||
let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
|
||||
useUserStore.getState().userCache[msg.caller_id];
|
||||
if (!caller) {
|
||||
@@ -413,7 +337,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
callerName,
|
||||
callerAvatar: caller?.avatar,
|
||||
callType: msg.call_type,
|
||||
iceServers: msg.ice_servers || [],
|
||||
receivedAt: Date.now(),
|
||||
lifetime: msg.lifetime,
|
||||
},
|
||||
@@ -421,7 +344,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// Set timeout based on lifetime
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
@@ -451,25 +373,17 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
|
||||
try {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connecting' }
|
||||
: null,
|
||||
}));
|
||||
|
||||
const isVideoCall = currentCall.callType === 'video';
|
||||
await webrtcManager.initialize(msg.ice_servers || []);
|
||||
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||
|
||||
set({ localStream: newStream });
|
||||
|
||||
// Setup unified WebRTC event handler
|
||||
setupWebRTCEvents(currentCall.id, myUserId);
|
||||
|
||||
// Start call with transceiver-based approach
|
||||
const offer = await webrtcManager.startCall(true, currentCall.callType);
|
||||
if (offer) {
|
||||
wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || '');
|
||||
}
|
||||
await joinLiveKitRoom(currentCall.id, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_accepted error:', err);
|
||||
console.error('[CallStore] call_accepted LiveKit join error:', err);
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
})
|
||||
@@ -557,41 +471,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_sdp', async (msg: WSCallSDPMessage) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
if (msg.from_id === myUserId) return;
|
||||
|
||||
if (msg.payload.sdp_type === 'offer') {
|
||||
await handleIncomingOffer(msg, myUserId);
|
||||
} else if (msg.payload.sdp_type === 'answer') {
|
||||
await handleIncomingAnswer(msg);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_ice', (msg: WSCallICEMessage) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
if (msg.from_id === myUserId) return;
|
||||
|
||||
try {
|
||||
const candidate = typeof msg.payload.candidate === 'string'
|
||||
? JSON.parse(msg.payload.candidate)
|
||||
: msg.payload.candidate;
|
||||
webrtcManager.addIceCandidate(candidate);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_ice error:', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_peer_muted', (msg) => {
|
||||
console.log('[CallStore] Peer muted:', msg.user_id, msg.muted);
|
||||
@@ -641,13 +520,14 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
peerName: calleeName,
|
||||
peerAvatar: callee?.avatar,
|
||||
callType,
|
||||
status: 'calling', // 改为 'calling' 表示正在呼出
|
||||
status: 'calling',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: false,
|
||||
isVideoEnabled: callType === 'video',
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: true,
|
||||
isPeerReady: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -667,7 +547,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { currentCall: cc } = get();
|
||||
// 只有在 'calling' 状态(呼出中)才超时
|
||||
if (cc && cc.status === 'calling') {
|
||||
console.warn('[CallStore] Call timeout');
|
||||
get().endCall('timeout');
|
||||
@@ -688,7 +567,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
@@ -703,8 +581,9 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
isMuted: false,
|
||||
isSpeakerOn: false,
|
||||
isVideoEnabled: isVideoCall,
|
||||
isPeerVideoEnabled: isVideoCall,
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: false,
|
||||
isPeerReady: false,
|
||||
},
|
||||
incomingCall: null,
|
||||
});
|
||||
@@ -712,19 +591,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
wsService.sendCallAnswer(incomingCall.callId);
|
||||
|
||||
try {
|
||||
await webrtcManager.initialize(incomingCall.iceServers);
|
||||
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||
|
||||
set({ localStream: newStream });
|
||||
|
||||
// Setup unified WebRTC event handler
|
||||
setupWebRTCEvents(incomingCall.callId, myUserId);
|
||||
|
||||
// Start call (non-initiator, will wait for offer)
|
||||
await webrtcManager.startCall(false, incomingCall.callType as CallType);
|
||||
|
||||
// Note: For non-initiator, we don't create an offer here.
|
||||
// We wait for the initiator's offer via handleIncomingOffer.
|
||||
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to accept call:', err);
|
||||
get().endCall('connection_failed');
|
||||
@@ -760,11 +627,13 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
set({
|
||||
currentCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
});
|
||||
|
||||
webrtcManager.dispose();
|
||||
try {
|
||||
await liveKitService.disconnect();
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Error disconnecting LiveKit:', err);
|
||||
}
|
||||
|
||||
if (callId && reason !== 'ended') {
|
||||
wsService.sendCallEnd(callId, reason);
|
||||
@@ -776,7 +645,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
if (!currentCall) return;
|
||||
|
||||
const newMuted = !currentCall.isMuted;
|
||||
webrtcManager.setMuted(newMuted);
|
||||
liveKitService.setMuted(newMuted);
|
||||
if (currentCall.id) {
|
||||
wsService.sendCallMute(currentCall.id, newMuted);
|
||||
}
|
||||
@@ -812,13 +681,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
if (!currentCall) return;
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
const newStream = await webrtcManager.enableVideo();
|
||||
set({ localStream: newStream });
|
||||
} else {
|
||||
const newStream = await webrtcManager.disableVideo();
|
||||
set({ localStream: newStream });
|
||||
}
|
||||
await liveKitService.setVideoEnabled(enabled);
|
||||
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
@@ -830,36 +693,27 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 重置所有状态,用于登出时清理
|
||||
reset: () => {
|
||||
// 清理所有定时器和资源
|
||||
cleanupResources();
|
||||
|
||||
// 清理 initCall 的订阅
|
||||
if (initCallUnsub) {
|
||||
initCallUnsub();
|
||||
initCallUnsub = null;
|
||||
}
|
||||
|
||||
// 清理 invited 订阅
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
unsubInvited = null;
|
||||
}
|
||||
|
||||
// 清理 WebRTC 资源
|
||||
webrtcManager.dispose();
|
||||
liveKitService.dispose();
|
||||
|
||||
// 清理已处理的通话ID缓存
|
||||
processedCallIds.clear();
|
||||
|
||||
// 重置状态
|
||||
set({
|
||||
currentCall: null,
|
||||
incomingCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
isMinimized: false,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user