feat(call): migrate from WebRTC to LiveKit
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / ota-ios (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled

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:
2026-06-01 13:45:45 +08:00
parent 72c30ed156
commit 70ab00795a
17 changed files with 2500 additions and 1420 deletions

View File

@@ -95,7 +95,8 @@ module.exports = {
// 配置 Web 端别名,避免加载原生模块
babel: {
dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc',
'livekit-client',
'@livekit',
],
},
},

View File

@@ -16,6 +16,7 @@
"supportsTablet": true,
"infoPlist": {
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
"NSCameraUsageDescription": "允许威友访问您的相机以进行视频通话",
"UIBackgroundModes": [
"fetch",
"remote-notification",
@@ -41,6 +42,7 @@
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
"CAMERA",
"RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK",
"READ_EXTERNAL_STORAGE",
@@ -138,6 +140,7 @@
}
],
"expo-font",
"@livekit/react-native",
[
"expo-notifications",
{

View File

@@ -7,8 +7,7 @@ const config = getDefaultConfig(__dirname);
// Add wasm asset support
config.resolver.assetExts.push('wasm');
// Fix for react-native-webrtc event-target-shim import warning
// The package doesn't properly export "./index" in its exports field
// Fix for livekit-client event-target-shim import warning
config.resolver.unstable_enablePackageExports = false;
// Support TypeScript path aliases (@/ -> ./src/)
@@ -36,7 +35,6 @@ const webShimPaths = [
// These packages import requireNativeComponent directly from 'react-native'
// (which resolves to react-native-web on web) and don't have web implementations.
const webPackageShims = {
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
};
const originalResolveRequest = config.resolver.resolveRequest;

1726
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -63,7 +63,9 @@
"react-native-share": "^12.2.6",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"@livekit/react-native": "^2.11.0",
"@livekit/react-native-webrtc": "^144.1.0",
"livekit-client": "^2.19.0",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",

View File

@@ -14,39 +14,80 @@ const withHuaweiPush = (config, options = {}) => {
config = withSettingsGradle(config, (config) => {
const contents = config.modResults.contents;
// Add Huawei Maven repo to pluginManagement.repositories for plugin resolution
if (!contents.includes('developer.huawei.com/repo')) {
if (contents.includes('pluginManagement')) {
config.modResults.contents = contents.replace(
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
`$1\n maven { url 'https://developer.huawei.com/repo/' }`
);
}
if (!config.modResults.contents.includes('developer.huawei.com/repo')) {
} else {
// No pluginManagement block at all — create one with the Huawei repo
config.modResults.contents =
`pluginManagement {\n repositories {\n maven { url 'https://developer.huawei.com/repo/' }\n }\n}\n` +
`pluginManagement {\n repositories {\n maven { url 'https://developer.huawei.com/repo/' }\n }\n}\n\n` +
config.modResults.contents;
}
}
if (!config.modResults.contents.includes("id 'com.huawei.agconnect'")) {
if (config.modResults.contents.includes('pluginManagement')) {
// Map the plugin ID to its Maven artifact via resolutionStrategy.
// AGConnect is not published as a standard Gradle plugin marker, so Gradle 9+
// cannot resolve it by plugin ID alone. This mapping tells Gradle where to find it.
if (!config.modResults.contents.includes('com.huawei.agconnect:agcp')) {
const pmMatch = config.modResults.contents.match(/pluginManagement\s*\{/);
if (pmMatch) {
config.modResults.contents = config.modResults.contents.replace(
/(pluginManagement\s*\{)/,
`$1\n plugins {\n id 'com.huawei.agconnect' version '1.9.1.301' apply false\n }`
`$1\n resolutionStrategy {\n eachPlugin {\n if (requested.id.id == 'com.huawei.agconnect') {\n useModule('com.huawei.agconnect:agcp:1.9.1.301')\n }\n }\n }`
);
}
}
if (!config.modResults.contents.includes('developer.huawei.com/repo')) {
// Ensure Huawei Maven repo also appears in dependencyResolutionManagement (Gradle 9+)
if (!config.modResults.contents.match(/dependencyResolutionManagement[\s\S]*developer\.huawei\.com\/repo/)) {
const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
if (depMgmtPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace(
depMgmtPattern,
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
`$&\n maven { url 'https://developer.huawei.com/repo/' }`
);
}
}
// Declare the plugin in a top-level plugins {} block (outside pluginManagement).
// In Gradle 9+, plugins declared with `apply false` in settings.gradle are
// available to all subprojects via `apply plugin:` or the plugins DSL.
if (!config.modResults.contents.includes("id 'com.huawei.agconnect'")) {
const pluginsBlockPattern = /^plugins\s*\{/m;
if (pluginsBlockPattern.test(config.modResults.contents)) {
// Add to existing top-level plugins block
config.modResults.contents = config.modResults.contents.replace(
/^plugins\s*\{/m,
`plugins {\n id 'com.huawei.agconnect' version '1.9.1.301' apply false`
);
} else {
// Insert a top-level plugins block after pluginManagement closing brace,
// or at the very beginning if no pluginManagement block exists.
const pmClosePattern = /\n\}\n/;
if (pmClosePattern.test(config.modResults.contents)) {
const firstClosing = config.modResults.contents.indexOf('}', config.modResults.contents.indexOf('pluginManagement')) + 1;
if (firstClosing > 0) {
const insertPos = firstClosing;
config.modResults.contents =
config.modResults.contents.slice(0, insertPos) +
`\n\nplugins {\n id 'com.huawei.agconnect' version '1.9.1.301' apply false\n}` +
config.modResults.contents.slice(insertPos);
}
} else {
// Fallback: prepend after pluginManagement block
config.modResults.contents =
config.modResults.contents.replace(
/(pluginManagement\s*\{[\s\S]*?\})/,
`$1\n\nplugins {\n id 'com.huawei.agconnect' version '1.9.1.301' apply false\n}`
);
}
}
}
return config;
});

View File

@@ -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;

View File

@@ -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',

View File

@@ -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';

View File

@@ -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 =>

View 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;

View File

@@ -0,0 +1,2 @@
export { liveKitService, default } from './LiveKitService';
export type { LiveKitServiceConfig, LiveKitEventMap, ConnectionStatus } from './LiveKitService';

View File

@@ -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();

View File

@@ -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();

View File

@@ -1,2 +0,0 @@
export { webrtcManager } from './WebRTCManager';
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';

View File

@@ -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,
});
},

View File

@@ -1,94 +0,0 @@
const React = require('react');
const RTCView = React.forwardRef((props, ref) => {
const { streamURL, mirror, objectFit, style, ...rest } = props;
const src = streamURL || (props.stream && props.stream.toURL ? props.stream.toURL() : '');
if (src) {
return React.createElement('video', {
...rest,
ref,
src,
autoPlay: true,
playsInline: true,
muted: props.muted || false,
style: {
...style,
transform: mirror ? 'scaleX(-1)' : undefined,
objectFit: objectFit || 'cover',
width: '100%',
height: '100%',
},
});
}
return React.createElement('div', { ...rest, ref, style });
});
RTCView.displayName = 'RTCView';
const ScreenCapturePickerView = React.forwardRef((props, ref) => {
return React.createElement('div', { ...props, ref, 'data-native-component': 'ScreenCapturePickerView' });
});
ScreenCapturePickerView.displayName = 'ScreenCapturePickerView';
const mediaDevices = typeof navigator !== 'undefined' && navigator.mediaDevices
? navigator.mediaDevices
: {
getUserMedia: () => Promise.reject(new Error('mediaDevices not available')),
getDisplayMedia: () => Promise.reject(new Error('mediaDevices not available')),
enumerateDevices: () => Promise.resolve([]),
};
const RTCPeerConnection = typeof window !== 'undefined' ? window.RTCPeerConnection : function () {};
const RTCSessionDescription = typeof window !== 'undefined' ? window.RTCSessionDescription : function () {};
const RTCIceCandidate = typeof window !== 'undefined' ? window.RTCIceCandidate : function () {};
const MediaStream = typeof window !== 'undefined' ? window.MediaStream : function () {};
const MediaStreamTrack = typeof window !== 'undefined' ? window.MediaStreamTrack : function () {};
function registerGlobals() {}
const permissions = {
request: () => Promise.resolve(true),
check: () => Promise.resolve(true),
};
const RTCAudioSession = {
setCategory: () => {},
setActive: () => {},
setMode: () => {},
};
const RTCRtpReceiver = typeof window !== 'undefined' ? window.RTCRtpReceiver : function () {};
const RTCRtpSender = typeof window !== 'undefined' ? window.RTCRtpSender : function () {};
const RTCRtpTransceiver = typeof window !== 'undefined' ? window.RTCRtpTransceiver : function () {};
const RTCErrorEvent = typeof window !== 'undefined' ? window.RTCErrorEvent : function () {};
const MediaStreamTrackEvent = typeof window !== 'undefined' ? window.MediaStreamTrackEvent : function () {};
const RTCPIPView = React.forwardRef((props, ref) => {
return React.createElement('div', { ...props, ref, 'data-native-component': 'RTCPIPView' });
});
RTCPIPView.displayName = 'RTCPIPView';
function startIOSPIP() {}
function stopIOSPIP() {}
module.exports = {
RTCView,
ScreenCapturePickerView,
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate,
MediaStream,
MediaStreamTrack,
MediaStreamTrackEvent,
mediaDevices,
permissions,
registerGlobals,
RTCAudioSession,
RTCRtpReceiver,
RTCRtpSender,
RTCRtpTransceiver,
RTCErrorEvent,
RTCPIPView,
startIOSPIP,
stopIOSPIP,
};