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

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