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 端别名,避免加载原生模块 // 配置 Web 端别名,避免加载原生模块
babel: { babel: {
dangerouslyAddModulePathsToTranspile: [ dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc', 'livekit-client',
'@livekit',
], ],
}, },
}, },

View File

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

View File

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

View File

@@ -14,30 +14,36 @@ const withHuaweiPush = (config, options = {}) => {
config = withSettingsGradle(config, (config) => { config = withSettingsGradle(config, (config) => {
const contents = config.modResults.contents; 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('developer.huawei.com/repo')) {
if (contents.includes('pluginManagement')) { if (contents.includes('pluginManagement')) {
config.modResults.contents = contents.replace( config.modResults.contents = contents.replace(
/(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/, /(pluginManagement\s*\{[\s\S]*?repositories\s*\{)/,
`$1\n maven { url 'https://developer.huawei.com/repo/' }` `$1\n maven { url 'https://developer.huawei.com/repo/' }`
); );
} } else {
if (!config.modResults.contents.includes('developer.huawei.com/repo')) { // No pluginManagement block at all — create one with the Huawei repo
config.modResults.contents = 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; config.modResults.contents;
} }
} }
if (!config.modResults.contents.includes("id 'com.huawei.agconnect'")) { // Map the plugin ID to its Maven artifact via resolutionStrategy.
if (config.modResults.contents.includes('pluginManagement')) { // 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( config.modResults.contents = config.modResults.contents.replace(
/(pluginManagement\s*\{)/, /(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*\{/; const depMgmtPattern = /dependencyResolutionManagement\s*\{[\s\S]*?repositories\s*\{/;
if (depMgmtPattern.test(config.modResults.contents)) { if (depMgmtPattern.test(config.modResults.contents)) {
config.modResults.contents = config.modResults.contents.replace( config.modResults.contents = config.modResults.contents.replace(
@@ -47,6 +53,41 @@ const withHuaweiPush = (config, options = {}) => {
} }
} }
// 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; return config;
}); });

View File

@@ -8,8 +8,12 @@ import {
StatusBar, StatusBar,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { callStore } from '../../stores/call';
import { RTCView } from 'react-native-webrtc'; import { liveKitService } from '@/services/livekit';
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60); 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')}`; return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}; };
const CallScreen: React.FC = () => { const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall); const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration); const callDuration = callStore((s) => s.callDuration);
const peerStream = callStore((s) => s.peerStream);
const localStream = callStore((s) => s.localStream);
const endCall = callStore((s) => s.endCall); const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute); const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker); const toggleSpeaker = callStore((s) => s.toggleSpeaker);
@@ -30,33 +31,66 @@ const CallScreen: React.FC = () => {
const isMinimized = callStore((s) => s.isMinimized); const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize); const toggleMinimize = callStore((s) => s.toggleMinimize);
// Track whether peer has video const [remoteVideoTrack, setRemoteVideoTrack] = useState<VideoTrack | null>(null);
const [hasPeerVideo, setHasPeerVideo] = useState(false); const [localVideoTrack, setLocalVideoTrack] = useState<VideoTrack | null>(null);
// Track whether local has video
const [hasLocalVideo, setHasLocalVideo] = useState(false);
// Check peer stream for video tracks // Subscribe to LiveKit track events
useEffect(() => { useEffect(() => {
if (peerStream) { const unsubs: (() => void)[] = [];
const videoTracks = peerStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled); unsubs.push(
setHasPeerVideo(hasVideo); liveKitService.on('trackSubscribed', ({ track }) => {
} else { if (track.kind === Track.Kind.Video) {
setHasPeerVideo(false); setRemoteVideoTrack(track as VideoTrack);
} }
}, [peerStream]); })
// Check local stream for video tracks );
useEffect(() => {
if (localStream) { unsubs.push(
const videoTracks = localStream.getVideoTracks(); liveKitService.on('trackUnsubscribed', ({ track }) => {
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled); if (track.kind === Track.Kind.Video) {
setHasLocalVideo(hasVideo); setRemoteVideoTrack(null);
} else {
setHasLocalVideo(false);
} }
}, [localStream]); })
);
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 // Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null; if (!currentCall || isMinimized) return null;
const getStatusText = (): string => { const getStatusText = (): string => {
switch (currentCall.status) { switch (currentCall.status) {
case 'calling': case 'calling':
@@ -77,63 +111,52 @@ const CallScreen: React.FC = () => {
return ''; return '';
} }
}; };
const handleEndCall = () => { const handleEndCall = () => {
endCall('hangup'); endCall('hangup');
}; };
const handleToggleMute = () => {
toggleMute(); const showRemoteVideo = !!remoteVideoTrack;
}; const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
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 isVideoCallActive = showRemoteVideo || showLocalVideo; const isVideoCallActive = showRemoteVideo || showLocalVideo;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent /> <StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background */}
<View style={styles.background} /> <View style={styles.background} />
{/* Remote video - full screen when peer has video */}
{showRemoteVideo && peerStream && ( {/* Remote video - full screen */}
<RTCView {showRemoteVideo && remoteVideoTrack && (
streamURL={peerStream.toURL()} <VideoView
videoTrack={remoteVideoTrack}
style={styles.fullScreenVideo} style={styles.fullScreenVideo}
objectFit="cover" objectFit="cover"
mirror={false}
/> />
)} )}
{/* Local video - picture in picture */} {/* Local video - picture in picture */}
{showLocalVideo && localStream && ( {showLocalVideo && localVideoTrack && (
<View style={styles.localVideoContainer}> <View style={styles.localVideoContainer}>
<RTCView <VideoView
streamURL={localStream.toURL()} videoTrack={localVideoTrack}
style={styles.localVideo} style={styles.localVideo}
objectFit="cover" objectFit="cover"
mirror={true} mirror={true}
/> />
</View> </View>
)} )}
{/* Top area: minimize button */} {/* Top area: minimize button */}
<View style={styles.topBar}> <View style={styles.topBar}>
<TouchableOpacity <TouchableOpacity
style={styles.topButton} style={styles.topButton}
onPress={handleMinimize} onPress={toggleMinimize}
activeOpacity={0.7} activeOpacity={0.7}
> >
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" /> <MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* Center: Peer info - only show when no video */} {/* Center: Peer info - only show when no video */}
{!isVideoCallActive && ( {!isVideoCallActive && (
<View style={styles.centerArea}> <View style={styles.centerArea}>
@@ -154,6 +177,7 @@ const CallScreen: React.FC = () => {
<Text style={styles.status}>{getStatusText()}</Text> <Text style={styles.status}>{getStatusText()}</Text>
</View> </View>
)} )}
{/* Peer name overlay when video is active */} {/* Peer name overlay when video is active */}
{isVideoCallActive && ( {isVideoCallActive && (
<View style={styles.videoOverlayInfo}> <View style={styles.videoOverlayInfo}>
@@ -163,13 +187,14 @@ const CallScreen: React.FC = () => {
<Text style={styles.videoStatus}>{getStatusText()}</Text> <Text style={styles.videoStatus}>{getStatusText()}</Text>
</View> </View>
)} )}
{/* Bottom controls */} {/* Bottom controls */}
<View style={styles.controls}> <View style={styles.controls}>
<View style={styles.controlRow}> <View style={styles.controlRow}>
{/* Mute */} {/* Mute */}
<TouchableOpacity <TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]} style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={handleToggleMute} onPress={toggleMute}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}> <View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
@@ -185,25 +210,25 @@ const CallScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
{/* Video toggle */} {/* Video toggle */}
<TouchableOpacity <TouchableOpacity
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]} style={[styles.controlButton, currentCall.isVideoEnabled && styles.controlButtonActive]}
onPress={handleToggleVideo} onPress={toggleVideo}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}> <View style={[styles.controlCircle, currentCall.isVideoEnabled && styles.controlCircleActive]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name={hasLocalVideo ? 'video' : 'video-off'} name={currentCall.isVideoEnabled ? 'video' : 'video-off'}
size={26} size={26}
color={hasLocalVideo ? '#333' : '#fff'} color={currentCall.isVideoEnabled ? '#333' : '#fff'}
/> />
</View> </View>
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}> <Text style={[styles.controlLabel, currentCall.isVideoEnabled && styles.controlLabelActive]}>
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'} {currentCall.isVideoEnabled ? '关闭摄像头' : '打开摄像头'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* Speaker */} {/* Speaker */}
<TouchableOpacity <TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]} style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker} onPress={toggleSpeaker}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}> <View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
@@ -218,7 +243,7 @@ const CallScreen: React.FC = () => {
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* End call - prominent red button */} {/* End call */}
<TouchableOpacity <TouchableOpacity
style={styles.endCallButton} style={styles.endCallButton}
onPress={handleEndCall} onPress={handleEndCall}
@@ -234,9 +259,6 @@ const CallScreen: React.FC = () => {
); );
}; };
const CONTROL_CIRCLE_SIZE = 56;
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -408,5 +430,4 @@ const styles = StyleSheet.create({
}, },
}); });
export default CallScreen; export default CallScreen;

View File

@@ -1,13 +1,19 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { import {
View, View,
Text, Text,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
Image,
StatusBar, StatusBar,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { callStore } from '../../stores/call';
import { liveKitService } from '@/services/livekit';
type VideoTrack = LocalVideoTrack | RemoteVideoTrack;
const formatDuration = (seconds: number): string => { const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);
@@ -19,70 +25,224 @@ const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall); const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration); const callDuration = callStore((s) => s.callDuration);
const endCall = callStore((s) => s.endCall); 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 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; if (!currentCall || isMinimized) return null;
const getStatusText = (): string => { const getStatusText = (): string => {
switch (currentCall.status) { switch (currentCall.status) {
case 'calling':
return '正在等待对方接听...';
case 'ringing': case 'ringing':
return '通话功能暂不支持网页端'; return '来电响铃中...';
case 'connecting': case 'connecting':
return '通话功能暂不支持网页端'; return '连接中...';
case 'connected': case 'connected':
return '通话功能暂不支持网页端'; return formatDuration(callDuration);
case 'reconnecting':
return '网络重连中...';
case 'ended': case 'ended':
return '通话已结束'; return '通话已结束';
case 'failed':
return '连接失败';
default: default:
return ''; return '';
} }
}; };
const handleEndCall = () => { const showRemoteVideo = !!remoteVideoTrack;
endCall('hangup'); const showLocalVideo = currentCall?.isVideoEnabled && !!localVideoTrack;
}; const isVideoCallActive = showRemoteVideo || showLocalVideo;
return ( return (
<View style={styles.container}> <View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent /> <StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} /> <View style={styles.background} />
{/* Center: Peer info */} {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>
)}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={toggleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{!isVideoCallActive && (
<View style={styles.centerArea}> <View style={styles.centerArea}>
<View style={styles.avatarOuter}> <View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}> <View style={[styles.avatar, styles.avatarPlaceholder]}>
<MaterialCommunityIcons name="web-off" size={50} color="#fff" /> <Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View> </View>
)}
</View> </View>
<Text style={styles.peerName} numberOfLines={1}> <Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'} {currentCall.peerName || '未知用户'}
</Text> </Text>
<Text style={styles.status}>{getStatusText()}</Text> <Text style={styles.status}>{getStatusText()}</Text>
</View> </View>
)}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
<Text style={styles.videoPeerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
{/* Bottom controls */}
<View style={styles.controls}> <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 <TouchableOpacity
style={styles.endCallButton} style={styles.endCallButton}
onPress={handleEndCall} onPress={() => endCall('hangup')}
activeOpacity={0.8} activeOpacity={0.8}
> >
<View style={styles.endCallCircle}> <View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" /> <MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View> </View>
<Text style={styles.endCallLabel}></Text> <Text style={styles.endCallLabel}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
); );
}; };
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
@@ -93,6 +253,25 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E', 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: { centerArea: {
position: 'absolute', position: 'absolute',
top: '28%', top: '28%',
@@ -132,20 +311,98 @@ const styles = StyleSheet.create({
color: 'rgba(255, 255, 255, 0.5)', color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3, 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: { controls: {
position: 'absolute', position: 'absolute',
bottom: 60, bottom: 60,
left: 0, left: 0,
right: 0, right: 0,
alignItems: 'center', 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: { endCallButton: {
alignItems: 'center', alignItems: 'center',
}, },
endCallCircle: { endCallCircle: {
width: END_CALL_SIZE, width: 64,
height: END_CALL_SIZE, height: 64,
borderRadius: END_CALL_SIZE / 2, borderRadius: 32,
backgroundColor: '#E54D42', backgroundColor: '#E54D42',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',

View File

@@ -24,14 +24,11 @@ export type {
WSCallRejectedMessage, WSCallRejectedMessage,
WSCallBusyMessage, WSCallBusyMessage,
WSCallEndedMessage, WSCallEndedMessage,
WSCallSDPMessage,
WSCallICEMessage,
WSCallPeerMutedMessage, WSCallPeerMutedMessage,
WSCallInvitedMessage, WSCallInvitedMessage,
WSCallAnsweredElsewhereMessage, WSCallAnsweredElsewhereMessage,
WSErrorMessage, WSErrorMessage,
WSServerMessage, WSServerMessage,
ICEServerConfig,
} from './wsService'; } from './wsService';
export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler'; export { handleError, createErrorHandler, safeAsync, createAppError } from './errorHandler';

View File

@@ -39,8 +39,7 @@ export type WSMessageType =
| 'call_accepted' | 'call_accepted'
| 'call_rejected' | 'call_rejected'
| 'call_busy' | 'call_busy'
| 'call_sdp' | 'call_ready'
| 'call_ice'
| 'call_ended' | 'call_ended'
| 'call_peer_muted' | 'call_peer_muted'
| 'call_invited' | 'call_invited'
@@ -56,20 +55,12 @@ export interface WSCallIncomingMessage {
media_type?: string; media_type?: string;
created_at: number; created_at: number;
lifetime?: number; lifetime?: number;
ice_servers?: ICEServerConfig[];
}
export interface ICEServerConfig {
urls: string[];
username?: string;
credential?: string;
} }
export interface WSCallAcceptedMessage { export interface WSCallAcceptedMessage {
type: 'call_accepted'; type: 'call_accepted';
call_id: string; call_id: string;
started_at: number; started_at: number;
ice_servers?: ICEServerConfig[];
} }
export interface WSCallRejectedMessage { export interface WSCallRejectedMessage {
@@ -92,25 +83,6 @@ export interface WSCallEndedMessage {
ended_at?: number; 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 { export interface WSCallPeerMutedMessage {
type: 'call_peer_muted'; type: 'call_peer_muted';
call_id: string; call_id: string;
@@ -282,8 +254,6 @@ export type WSMessage =
| WSCallRejectedMessage | WSCallRejectedMessage
| WSCallBusyMessage | WSCallBusyMessage
| WSCallEndedMessage | WSCallEndedMessage
| WSCallSDPMessage
| WSCallICEMessage
| WSCallPeerMutedMessage | WSCallPeerMutedMessage
| WSCallInvitedMessage | WSCallInvitedMessage
| WSCallAnsweredElsewhereMessage | WSCallAnsweredElsewhereMessage
@@ -496,12 +466,6 @@ class WebSocketService {
case 'call_busy': case 'call_busy':
this.handleCallBusy(payload); this.handleCallBusy(payload);
break; break;
case 'call_sdp':
this.handleCallSDP(payload);
break;
case 'call_ice':
this.handleCallICE(payload);
break;
case 'call_ended': case 'call_ended':
this.handleCallEnded(payload); this.handleCallEnded(payload);
break; break;
@@ -664,7 +628,6 @@ class WebSocketService {
call_type: payload.media_type || payload.call_type, call_type: payload.media_type || payload.call_type,
created_at: payload.created_at, created_at: payload.created_at,
lifetime: payload.lifetime, lifetime: payload.lifetime,
ice_servers: payload.ice_servers,
}; };
this.emit('call_incoming', m); this.emit('call_incoming', m);
} }
@@ -674,7 +637,6 @@ class WebSocketService {
type: 'call_accepted', type: 'call_accepted',
call_id: payload.call_id, call_id: payload.call_id,
started_at: payload.started_at, started_at: payload.started_at,
ice_servers: payload.ice_servers,
}; };
this.emit('call_accepted', m); this.emit('call_accepted', m);
} }
@@ -696,26 +658,6 @@ class WebSocketService {
this.emit('call_busy', m); 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 { private handleCallEnded(payload: any): void {
const m: WSCallEndedMessage = { const m: WSCallEndedMessage = {
type: 'call_ended', type: 'call_ended',
@@ -793,21 +735,6 @@ class WebSocketService {
this.sendFireAndForget('call_busy', { call_id: callId }); 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 { sendCallEnd(callId: string, reason?: string): void {
this.sendFireAndForget('call_end', { call_id: callId, reason }); this.sendFireAndForget('call_end', { call_id: callId, reason });
} }
@@ -816,6 +743,10 @@ class WebSocketService {
this.sendFireAndForget('call_mute', { call_id: callId, muted }); this.sendFireAndForget('call_mute', { call_id: callId, muted });
} }
sendCallReady(callId: string): void {
this.sendFireAndForget('call_ready', { call_id: callId });
}
private handleMessageSent(payload: any): void { private handleMessageSent(payload: any): void {
// 找到对应的发送请求并resolve // 找到对应的发送请求并resolve
const pendingMsg = this.pendingMessages.find(p => 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 { create } from 'zustand';
import { MediaStream } from 'react-native-webrtc';
import { import {
wsService, wsService,
WSCallIncomingMessage, WSCallIncomingMessage,
WSCallSDPMessage,
WSCallICEMessage,
WSErrorMessage, WSErrorMessage,
api,
} from '@/services/core'; } from '@/services/core';
import { webrtcManager, ICEServer } from '@/services/webrtc'; import { liveKitService } from '@/services/livekit';
import { getCurrentUserId } from '../auth/sessionStore'; import { getCurrentUserId } from '../auth/sessionStore';
import { useUserStore } from '../userStore'; import { useUserStore } from '../userStore';
import { userManager } from '../user/UserManager'; import { userManager } from '../user/UserManager';
export type CallStatus = export type CallStatus =
| 'idle' // 空闲状态 | 'idle'
| 'calling' // 正在呼出(已发送邀请,等待对方响应) | 'calling'
| 'ringing' // 来电响铃中 | 'ringing'
| 'connecting' // 正在建立连接WebRTC 协商中) | 'connecting'
| 'connected' // 已接通 | 'connected'
| 'reconnecting' // 网络断开,正在重连 | 'reconnecting'
| 'ended' // 已结束 | 'ended'
| 'failed'; // 连接失败 | 'failed';
export type CallType = 'voice' | 'video'; export type CallType = 'voice' | 'video';
@@ -39,6 +37,7 @@ export interface CallSession {
isVideoEnabled: boolean; isVideoEnabled: boolean;
isPeerVideoEnabled: boolean; isPeerVideoEnabled: boolean;
isInitiator: boolean; isInitiator: boolean;
isPeerReady: boolean;
} }
export interface IncomingCallInfo { export interface IncomingCallInfo {
@@ -48,7 +47,6 @@ export interface IncomingCallInfo {
callerName?: string; callerName?: string;
callerAvatar?: string | null; callerAvatar?: string | null;
callType: string; callType: string;
iceServers: ICEServer[];
receivedAt: number; receivedAt: number;
lifetime?: number; lifetime?: number;
} }
@@ -57,8 +55,6 @@ interface CallState {
currentCall: CallSession | null; currentCall: CallSession | null;
incomingCall: IncomingCallInfo | null; incomingCall: IncomingCallInfo | null;
callDuration: number; callDuration: number;
peerStream: MediaStream | null;
localStream: MediaStream | null;
isMinimized: boolean; isMinimized: boolean;
initCall: () => () => void; initCall: () => () => void;
@@ -84,15 +80,13 @@ let durationTimer: ReturnType<typeof setInterval> | null = null;
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null; let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
let initCallUnsub: (() => void) | null = null; let initCallUnsub: (() => void) | null = null;
let unsubInvited: (() => void) | null = null; let unsubInvited: (() => void) | null = null;
let pendingOffer: { callId: string; sdp: string } | null = null; let liveKitUnsubs: (() => void)[] = [];
let rtcUnsubscribe: (() => void) | null = null; // WebRTC event subscription
// Constants // Constants
const CALL_LIFETIME_MS = 55000; const CALL_LIFETIME_MS = 55000;
const CALL_TIMEOUT_MS = 115000; const CALL_TIMEOUT_MS = 115000;
const IGNORE_CALL_ID_TTL = 30000; const IGNORE_CALL_ID_TTL = 30000;
// Track processed call IDs to prevent duplicates
const processedCallIds = new Map<string, number>(); const processedCallIds = new Map<string, number>();
function cleanupProcessedCallIds() { function cleanupProcessedCallIds() {
@@ -105,89 +99,37 @@ function cleanupProcessedCallIds() {
} }
/** /**
* Unified WebRTC event handler * Fetch LiveKit token from the backend and connect to the room.
* This is called from both initiator and receiver paths
*/ */
function setupWebRTCEvents(callId: string, myUserId: string): void { async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<void> {
// Clean up any existing subscription const res = await api.get<{ token: string; url: string }>('/calls/token', {
if (rtcUnsubscribe) { room: callId,
rtcUnsubscribe();
rtcUnsubscribe = null;
}
rtcUnsubscribe = webrtcManager.onEvent((event) => {
switch (event.type) {
case 'icecandidate':
if (event.candidate) {
wsService.sendCallICE(callId, JSON.stringify(event.candidate.toJSON()));
}
break;
case 'remotestream':
handleRemoteStream(event.stream);
break;
case 'negotiationneeded':
handleNegotiationNeeded(callId, event.offer);
break;
case 'connectionstatechange':
handleConnectionStateChange(event.state);
break;
case 'error':
console.error('[CallStore] WebRTC error:', event.error);
break;
}
}); });
const { token, url } = res.data;
if (!token || !url) {
throw new Error('Invalid LiveKit token response');
}
setupLiveKitEvents(callId);
await liveKitService.connect(url, token);
// 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 { function setupLiveKitEvents(callId: string): void {
// Create a new MediaStream instance to force zustand subscribers to update // Clean up any existing subscriptions
// because react-native-webrtc may add tracks to the same stream object reference liveKitUnsubs.forEach((unsub) => unsub());
const newStream = new MediaStream(); liveKitUnsubs = [];
stream.getTracks().forEach((track) => {
newStream.addTrack(track);
});
callStore.setState({ peerStream: newStream }); liveKitUnsubs.push(
liveKitService.on('connected', () => {
// Detect video tracks in remote stream console.log('[CallStore] LiveKit connected');
const videoTracks = newStream.getVideoTracks();
const hasPeerVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
console.log('[CallStore] Remote stream received, hasVideo:', hasPeerVideo, 'videoTracks:', videoTracks.length, 'audioTracks:', newStream.getAudioTracks().length);
callStore.setState((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isPeerVideoEnabled: hasPeerVideo }
: null,
}));
}
/**
* Handle negotiation needed event - send offer to peer
*/
function handleNegotiationNeeded(callId: string, offer: RTCSessionDescriptionInit): void {
console.log('[CallStore] Negotiation needed, sending offer');
wsService.sendCallSDP(callId, 'offer', offer.sdp || '');
}
/**
* Handle connection state change 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(); const now = Date.now();
callStore.setState((s) => ({ callStore.setState((s) => ({
currentCall: s.currentCall currentCall: s.currentCall
@@ -195,132 +137,122 @@ function handleNegotiationNeeded(callId: string, offer: RTCSessionDescriptionIni
: null, : null,
})); }));
wsService.sendCallReady(callId);
if (durationTimer) clearInterval(durationTimer); if (durationTimer) clearInterval(durationTimer);
durationTimer = setInterval(() => { durationTimer = setInterval(() => {
callStore.setState((s) => ({ callDuration: s.callDuration + 1 })); callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
}, 1000); }, 1000);
break; })
);
case 'disconnected': liveKitUnsubs.push(
// 临时断开,进入重连状态 liveKitService.on('disconnected', () => {
if (currentCall.status === 'connected') { console.log('[CallStore] LiveKit disconnected');
const { currentCall } = callStore.getState();
if (currentCall && currentCall.status !== 'ended' && currentCall.status !== 'failed') {
callStore.setState((s) => ({ callStore.setState((s) => ({
currentCall: s.currentCall currentCall: s.currentCall
? { ...s.currentCall, status: 'reconnecting' } ? { ...s.currentCall, status: 'reconnecting' }
: null, : null,
})); }));
} }
break; })
);
case 'failed': liveKitUnsubs.push(
// 连接失败 liveKitService.on('reconnecting', () => {
if (currentCall.status === 'connected' || currentCall.status === 'reconnecting') { console.log('[CallStore] LiveKit reconnecting');
callStore.getState().endCall('connection_failed'); callStore.setState((s) => ({
} else if (currentCall.status === 'connecting') { currentCall: s.currentCall
// 初始连接失败 ? { ...s.currentCall, status: 'reconnecting' }
: null,
}));
})
);
liveKitUnsubs.push(
liveKitService.on('reconnected', () => {
console.log('[CallStore] LiveKit reconnected');
callStore.setState((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, status: 'connected' }
: null,
}));
})
);
liveKitUnsubs.push(
liveKitService.on('connectionStatusChanged', (status) => {
console.log('[CallStore] LiveKit connection status:', status);
const { currentCall } = callStore.getState();
if (!currentCall) return;
if (status === 'failed') {
callStore.getState().endCall('connection_failed'); callStore.getState().endCall('connection_failed');
} }
break; })
);
case 'closed': liveKitUnsubs.push(
// 连接关闭 liveKitService.on('trackSubscribed', ({ track }) => {
if (currentCall.status !== 'ended' && currentCall.status !== 'failed') { console.log('[CallStore] Remote track subscribed:', track.kind);
callStore.getState().endCall('connection_closed'); if (track.kind === 'video') {
callStore.setState((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isPeerVideoEnabled: true }
: null,
}));
} }
break; })
);
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(
* Handle incoming SDP offer with Glare handling liveKitService.on('trackUnmuted', ({ publication }) => {
*/ if (publication.source === 'camera') {
async function handleIncomingOffer(msg: WSCallSDPMessage, myUserId: string): Promise<void> { callStore.setState((s) => ({
let pc = webrtcManager.getPeerConnection(); currentCall: s.currentCall
if (!pc) { ? { ...s.currentCall, isPeerVideoEnabled: true }
// Cache offer for later processing : null,
pendingOffer = { callId: msg.call_id, sdp: msg.payload.sdp }; }));
console.log('[CallStore] Caching pending offer, PeerConnection not ready yet');
return;
} }
})
);
let signalingState = pc.signalingState; liveKitUnsubs.push(
console.log('[CallStore] Received offer, signalingState:', signalingState); liveKitService.on('error', (err) => {
console.error('[CallStore] LiveKit error:', err);
// 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;
}
}
pendingOffer = null;
// Set remote description and create answer
try {
await webrtcManager.setRemoteDescription({
type: msg.payload.sdp_type as 'offer' | 'answer',
sdp: msg.payload.sdp,
});
const answer = await webrtcManager.createAnswer();
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
} catch (err) {
console.error('[CallStore] Failed to handle incoming offer:', err);
}
} }
/** /**
* Handle incoming SDP answer * Clean up all resources.
*/
async function handleIncomingAnswer(msg: WSCallSDPMessage): Promise<void> {
const pc = webrtcManager.getPeerConnection();
if (!pc) return;
const signalingState = pc.signalingState;
console.log('[CallStore] Received answer, signalingState:', signalingState);
if (signalingState !== 'have-local-offer') {
console.warn('[CallStore] Ignoring answer, signaling state is', signalingState);
return;
}
try {
await webrtcManager.setRemoteDescription({
type: msg.payload.sdp_type as 'offer' | 'answer',
sdp: msg.payload.sdp,
});
} catch (err) {
console.error('[CallStore] Failed to set remote description from answer:', err);
}
}
/**
* Clean up all resources
*/ */
function cleanupResources(): void { function cleanupResources(): void {
if (callTimeoutTimer) { if (callTimeoutTimer) {
@@ -331,23 +263,17 @@ function cleanupResources(): void {
clearInterval(durationTimer); clearInterval(durationTimer);
durationTimer = null; durationTimer = null;
} }
if (rtcUnsubscribe) { liveKitUnsubs.forEach((unsub) => unsub());
rtcUnsubscribe(); liveKitUnsubs = [];
rtcUnsubscribe = null;
}
pendingOffer = null;
} }
export const callStore = create<CallState>((set, get) => ({ export const callStore = create<CallState>((set, get) => ({
currentCall: null, currentCall: null,
incomingCall: null, incomingCall: null,
callDuration: 0, callDuration: 0,
peerStream: null,
localStream: null,
isMinimized: false, isMinimized: false,
initCall: () => { initCall: () => {
// Prevent duplicate handler registration
if (initCallUnsub) { if (initCallUnsub) {
initCallUnsub(); initCallUnsub();
initCallUnsub = null; initCallUnsub = null;
@@ -376,7 +302,6 @@ export const callStore = create<CallState>((set, get) => ({
return; return;
} }
// Check lifetime expiry
const callAge = Date.now() - msg.created_at; const callAge = Date.now() - msg.created_at;
const lifetime = msg.lifetime || 60000; const lifetime = msg.lifetime || 60000;
if (callAge > lifetime - 5000) { if (callAge > lifetime - 5000) {
@@ -386,7 +311,6 @@ export const callStore = create<CallState>((set, get) => ({
return; return;
} }
// Fetch caller info
let caller: { nickname?: string; username?: string; avatar?: string | null } | null = let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
useUserStore.getState().userCache[msg.caller_id]; useUserStore.getState().userCache[msg.caller_id];
if (!caller) { if (!caller) {
@@ -413,7 +337,6 @@ export const callStore = create<CallState>((set, get) => ({
callerName, callerName,
callerAvatar: caller?.avatar, callerAvatar: caller?.avatar,
callType: msg.call_type, callType: msg.call_type,
iceServers: msg.ice_servers || [],
receivedAt: Date.now(), receivedAt: Date.now(),
lifetime: msg.lifetime, lifetime: msg.lifetime,
}, },
@@ -421,7 +344,6 @@ export const callStore = create<CallState>((set, get) => ({
processedCallIds.set(msg.call_id, Date.now()); processedCallIds.set(msg.call_id, Date.now());
// Set timeout based on lifetime
if (callTimeoutTimer) clearTimeout(callTimeoutTimer); if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
const remainingTime = Math.max(lifetime - callAge - 1000, 5000); const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
callTimeoutTimer = setTimeout(() => { callTimeoutTimer = setTimeout(() => {
@@ -451,25 +373,17 @@ export const callStore = create<CallState>((set, get) => ({
callTimeoutTimer = null; callTimeoutTimer = null;
} }
const myUserId = getCurrentUserId() || '';
try { try {
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, status: 'connecting' }
: null,
}));
const isVideoCall = currentCall.callType === 'video'; const isVideoCall = currentCall.callType === 'video';
await webrtcManager.initialize(msg.ice_servers || []); await joinLiveKitRoom(currentCall.id, isVideoCall);
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 || '');
}
} catch (err) { } catch (err) {
console.error('[CallStore] call_accepted error:', err); console.error('[CallStore] call_accepted LiveKit join error:', err);
get().endCall('connection_failed'); 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( unsubs.push(
wsService.on('call_peer_muted', (msg) => { wsService.on('call_peer_muted', (msg) => {
console.log('[CallStore] Peer muted:', msg.user_id, msg.muted); console.log('[CallStore] Peer muted:', msg.user_id, msg.muted);
@@ -641,13 +520,14 @@ export const callStore = create<CallState>((set, get) => ({
peerName: calleeName, peerName: calleeName,
peerAvatar: callee?.avatar, peerAvatar: callee?.avatar,
callType, callType,
status: 'calling', // 改为 'calling' 表示正在呼出 status: 'calling',
duration: 0, duration: 0,
isMuted: false, isMuted: false,
isSpeakerOn: false, isSpeakerOn: false,
isVideoEnabled: callType === 'video', isVideoEnabled: callType === 'video',
isPeerVideoEnabled: false, isPeerVideoEnabled: false,
isInitiator: true, isInitiator: true,
isPeerReady: false,
}, },
}); });
@@ -667,7 +547,6 @@ export const callStore = create<CallState>((set, get) => ({
if (callTimeoutTimer) clearTimeout(callTimeoutTimer); if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
callTimeoutTimer = setTimeout(() => { callTimeoutTimer = setTimeout(() => {
const { currentCall: cc } = get(); const { currentCall: cc } = get();
// 只有在 'calling' 状态(呼出中)才超时
if (cc && cc.status === 'calling') { if (cc && cc.status === 'calling') {
console.warn('[CallStore] Call timeout'); console.warn('[CallStore] Call timeout');
get().endCall('timeout'); get().endCall('timeout');
@@ -688,7 +567,6 @@ export const callStore = create<CallState>((set, get) => ({
const isVideoCall = incomingCall.callType === 'video'; const isVideoCall = incomingCall.callType === 'video';
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall); console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
const myUserId = getCurrentUserId() || '';
set({ set({
currentCall: { currentCall: {
@@ -703,8 +581,9 @@ export const callStore = create<CallState>((set, get) => ({
isMuted: false, isMuted: false,
isSpeakerOn: false, isSpeakerOn: false,
isVideoEnabled: isVideoCall, isVideoEnabled: isVideoCall,
isPeerVideoEnabled: isVideoCall, isPeerVideoEnabled: false,
isInitiator: false, isInitiator: false,
isPeerReady: false,
}, },
incomingCall: null, incomingCall: null,
}); });
@@ -712,19 +591,7 @@ export const callStore = create<CallState>((set, get) => ({
wsService.sendCallAnswer(incomingCall.callId); wsService.sendCallAnswer(incomingCall.callId);
try { try {
await webrtcManager.initialize(incomingCall.iceServers); await joinLiveKitRoom(incomingCall.callId, isVideoCall);
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.
} catch (err) { } catch (err) {
console.error('[CallStore] Failed to accept call:', err); console.error('[CallStore] Failed to accept call:', err);
get().endCall('connection_failed'); get().endCall('connection_failed');
@@ -760,11 +627,13 @@ export const callStore = create<CallState>((set, get) => ({
set({ set({
currentCall: null, currentCall: null,
callDuration: 0, 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') { if (callId && reason !== 'ended') {
wsService.sendCallEnd(callId, reason); wsService.sendCallEnd(callId, reason);
@@ -776,7 +645,7 @@ export const callStore = create<CallState>((set, get) => ({
if (!currentCall) return; if (!currentCall) return;
const newMuted = !currentCall.isMuted; const newMuted = !currentCall.isMuted;
webrtcManager.setMuted(newMuted); liveKitService.setMuted(newMuted);
if (currentCall.id) { if (currentCall.id) {
wsService.sendCallMute(currentCall.id, newMuted); wsService.sendCallMute(currentCall.id, newMuted);
} }
@@ -812,13 +681,7 @@ export const callStore = create<CallState>((set, get) => ({
if (!currentCall) return; if (!currentCall) return;
try { try {
if (enabled) { await liveKitService.setVideoEnabled(enabled);
const newStream = await webrtcManager.enableVideo();
set({ localStream: newStream });
} else {
const newStream = await webrtcManager.disableVideo();
set({ localStream: newStream });
}
set((s) => ({ set((s) => ({
currentCall: s.currentCall currentCall: s.currentCall
@@ -830,36 +693,27 @@ export const callStore = create<CallState>((set, get) => ({
} }
}, },
// 重置所有状态,用于登出时清理
reset: () => { reset: () => {
// 清理所有定时器和资源
cleanupResources(); cleanupResources();
// 清理 initCall 的订阅
if (initCallUnsub) { if (initCallUnsub) {
initCallUnsub(); initCallUnsub();
initCallUnsub = null; initCallUnsub = null;
} }
// 清理 invited 订阅
if (unsubInvited) { if (unsubInvited) {
unsubInvited(); unsubInvited();
unsubInvited = null; unsubInvited = null;
} }
// 清理 WebRTC 资源 liveKitService.dispose();
webrtcManager.dispose();
// 清理已处理的通话ID缓存
processedCallIds.clear(); processedCallIds.clear();
// 重置状态
set({ set({
currentCall: null, currentCall: null,
incomingCall: null, incomingCall: null,
callDuration: 0, callDuration: 0,
peerStream: null,
localStream: null,
isMinimized: false, 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,
};