feat(call): migrate from WebRTC to LiveKit
Replace the custom WebRTC implementation with LiveKit for improved stability and feature support. - Remove `react-native-webrtc` and custom `WebRTCManager` - Implement `LiveKitService` for room and track management - Update `callStore` to handle LiveKit events and connection states - Refactor `CallScreen` (mobile and web) to use `@livekit/react-native` and `VideoView` - Update WebSocket protocol to use `call_ready` instead of manual SDP/ICE exchanges - Add necessary camera and microphone permissions to `app.json` - Update Metro and Babel configurations for LiveKit compatibility
This commit is contained in:
@@ -1,26 +1,24 @@
|
||||
import { create } from 'zustand';
|
||||
import { MediaStream } from 'react-native-webrtc';
|
||||
import {
|
||||
wsService,
|
||||
WSCallIncomingMessage,
|
||||
WSCallSDPMessage,
|
||||
WSCallICEMessage,
|
||||
WSErrorMessage,
|
||||
api,
|
||||
} from '@/services/core';
|
||||
import { webrtcManager, ICEServer } from '@/services/webrtc';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
import { getCurrentUserId } from '../auth/sessionStore';
|
||||
import { useUserStore } from '../userStore';
|
||||
import { userManager } from '../user/UserManager';
|
||||
|
||||
export type CallStatus =
|
||||
| 'idle' // 空闲状态
|
||||
| 'calling' // 正在呼出(已发送邀请,等待对方响应)
|
||||
| 'ringing' // 来电响铃中
|
||||
| 'connecting' // 正在建立连接(WebRTC 协商中)
|
||||
| 'connected' // 已接通
|
||||
| 'reconnecting' // 网络断开,正在重连
|
||||
| 'ended' // 已结束
|
||||
| 'failed'; // 连接失败
|
||||
export type CallStatus =
|
||||
| 'idle'
|
||||
| 'calling'
|
||||
| 'ringing'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'reconnecting'
|
||||
| 'ended'
|
||||
| 'failed';
|
||||
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
@@ -39,6 +37,7 @@ export interface CallSession {
|
||||
isVideoEnabled: boolean;
|
||||
isPeerVideoEnabled: boolean;
|
||||
isInitiator: boolean;
|
||||
isPeerReady: boolean;
|
||||
}
|
||||
|
||||
export interface IncomingCallInfo {
|
||||
@@ -48,7 +47,6 @@ export interface IncomingCallInfo {
|
||||
callerName?: string;
|
||||
callerAvatar?: string | null;
|
||||
callType: string;
|
||||
iceServers: ICEServer[];
|
||||
receivedAt: number;
|
||||
lifetime?: number;
|
||||
}
|
||||
@@ -57,8 +55,6 @@ interface CallState {
|
||||
currentCall: CallSession | null;
|
||||
incomingCall: IncomingCallInfo | null;
|
||||
callDuration: number;
|
||||
peerStream: MediaStream | null;
|
||||
localStream: MediaStream | null;
|
||||
isMinimized: boolean;
|
||||
|
||||
initCall: () => () => void;
|
||||
@@ -84,15 +80,13 @@ let durationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let initCallUnsub: (() => void) | null = null;
|
||||
let unsubInvited: (() => void) | null = null;
|
||||
let pendingOffer: { callId: string; sdp: string } | null = null;
|
||||
let rtcUnsubscribe: (() => void) | null = null; // WebRTC event subscription
|
||||
let liveKitUnsubs: (() => void)[] = [];
|
||||
|
||||
// Constants
|
||||
const CALL_LIFETIME_MS = 55000;
|
||||
const CALL_TIMEOUT_MS = 115000;
|
||||
const IGNORE_CALL_ID_TTL = 30000;
|
||||
|
||||
// Track processed call IDs to prevent duplicates
|
||||
const processedCallIds = new Map<string, number>();
|
||||
|
||||
function cleanupProcessedCallIds() {
|
||||
@@ -105,222 +99,160 @@ function cleanupProcessedCallIds() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified WebRTC event handler
|
||||
* This is called from both initiator and receiver paths
|
||||
* Fetch LiveKit token from the backend and connect to the room.
|
||||
*/
|
||||
function setupWebRTCEvents(callId: string, myUserId: string): void {
|
||||
// Clean up any existing subscription
|
||||
if (rtcUnsubscribe) {
|
||||
rtcUnsubscribe();
|
||||
rtcUnsubscribe = null;
|
||||
async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<void> {
|
||||
const res = await api.get<{ token: string; url: string }>('/calls/token', {
|
||||
room: callId,
|
||||
});
|
||||
|
||||
const { token, url } = res.data;
|
||||
if (!token || !url) {
|
||||
throw new Error('Invalid LiveKit token response');
|
||||
}
|
||||
|
||||
rtcUnsubscribe = webrtcManager.onEvent((event) => {
|
||||
switch (event.type) {
|
||||
case 'icecandidate':
|
||||
if (event.candidate) {
|
||||
wsService.sendCallICE(callId, JSON.stringify(event.candidate.toJSON()));
|
||||
}
|
||||
break;
|
||||
setupLiveKitEvents(callId);
|
||||
|
||||
case 'remotestream':
|
||||
handleRemoteStream(event.stream);
|
||||
break;
|
||||
await liveKitService.connect(url, token);
|
||||
|
||||
case 'negotiationneeded':
|
||||
handleNegotiationNeeded(callId, event.offer);
|
||||
break;
|
||||
|
||||
case 'connectionstatechange':
|
||||
handleConnectionStateChange(event.state);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[CallStore] WebRTC error:', event.error);
|
||||
break;
|
||||
}
|
||||
});
|
||||
// Enable/disable video based on call type
|
||||
await liveKitService.setVideoEnabled(videoEnabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle remote stream with video track detection
|
||||
* Set up LiveKit room event handlers.
|
||||
*/
|
||||
function handleRemoteStream(stream: MediaStream): void {
|
||||
// Create a new MediaStream instance to force zustand subscribers to update
|
||||
// because react-native-webrtc may add tracks to the same stream object reference
|
||||
const newStream = new MediaStream();
|
||||
stream.getTracks().forEach((track) => {
|
||||
newStream.addTrack(track);
|
||||
});
|
||||
function setupLiveKitEvents(callId: string): void {
|
||||
// Clean up any existing subscriptions
|
||||
liveKitUnsubs.forEach((unsub) => unsub());
|
||||
liveKitUnsubs = [];
|
||||
|
||||
callStore.setState({ peerStream: newStream });
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('connected', () => {
|
||||
console.log('[CallStore] LiveKit connected');
|
||||
const now = Date.now();
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
: null,
|
||||
}));
|
||||
|
||||
// Detect video tracks in remote stream
|
||||
const videoTracks = newStream.getVideoTracks();
|
||||
const hasPeerVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||
wsService.sendCallReady(callId);
|
||||
|
||||
console.log('[CallStore] Remote stream received, hasVideo:', hasPeerVideo, 'videoTracks:', videoTracks.length, 'audioTracks:', newStream.getAudioTracks().length);
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
})
|
||||
);
|
||||
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: hasPeerVideo }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle negotiation needed event - send offer to peer
|
||||
*/
|
||||
function handleNegotiationNeeded(callId: string, offer: RTCSessionDescriptionInit): void {
|
||||
console.log('[CallStore] Negotiation needed, sending offer');
|
||||
wsService.sendCallSDP(callId, 'offer', offer.sdp || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection state change with enhanced state machine
|
||||
*/
|
||||
function handleConnectionStateChange(state: string): void {
|
||||
console.log('[CallStore] Connection state changed:', state);
|
||||
|
||||
const { currentCall } = callStore.getState();
|
||||
if (!currentCall) return;
|
||||
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
// 连接成功,开始计时
|
||||
const now = Date.now();
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('disconnected', () => {
|
||||
console.log('[CallStore] LiveKit disconnected');
|
||||
const { currentCall } = callStore.getState();
|
||||
if (currentCall && currentCall.status !== 'ended' && currentCall.status !== 'failed') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
? { ...s.currentCall, status: 'reconnecting' }
|
||||
: null,
|
||||
}));
|
||||
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
break;
|
||||
|
||||
case 'disconnected':
|
||||
// 临时断开,进入重连状态
|
||||
if (currentCall.status === 'connected') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'reconnecting' }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'failed':
|
||||
// 连接失败
|
||||
if (currentCall.status === 'connected' || currentCall.status === 'reconnecting') {
|
||||
callStore.getState().endCall('connection_failed');
|
||||
} else if (currentCall.status === 'connecting') {
|
||||
// 初始连接失败
|
||||
callStore.getState().endCall('connection_failed');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'closed':
|
||||
// 连接关闭
|
||||
if (currentCall.status !== 'ended' && currentCall.status !== 'failed') {
|
||||
callStore.getState().endCall('connection_closed');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming SDP offer with Glare handling
|
||||
*/
|
||||
async function handleIncomingOffer(msg: WSCallSDPMessage, myUserId: string): Promise<void> {
|
||||
let pc = webrtcManager.getPeerConnection();
|
||||
if (!pc) {
|
||||
// Cache offer for later processing
|
||||
pendingOffer = { callId: msg.call_id, sdp: msg.payload.sdp };
|
||||
console.log('[CallStore] Caching pending offer, PeerConnection not ready yet');
|
||||
return;
|
||||
}
|
||||
|
||||
let signalingState = pc.signalingState;
|
||||
console.log('[CallStore] Received offer, signalingState:', signalingState);
|
||||
|
||||
// Glare handling: if we're not in stable state, we have a conflict
|
||||
if (signalingState !== 'stable') {
|
||||
const remoteUserId = msg.from_id;
|
||||
|
||||
// Compare user IDs to determine who wins
|
||||
// Higher user ID wins the negotiation
|
||||
if (myUserId > remoteUserId) {
|
||||
console.log('[CallStore] Glare: I win (my ID > remote ID), ignoring incoming offer');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[CallStore] Glare: Remote wins (remote ID > my ID), rolling back');
|
||||
|
||||
// Rollback to stable state
|
||||
try {
|
||||
await webrtcManager.rollback();
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Rollback failed:', err);
|
||||
// If rollback fails because we're already stable, that's fine - just proceed
|
||||
if (pc.signalingState !== 'stable') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Re-check state after rollback
|
||||
signalingState = pc.signalingState;
|
||||
if (signalingState !== 'stable') {
|
||||
console.warn('[CallStore] Not stable after rollback, state:', signalingState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('reconnecting', () => {
|
||||
console.log('[CallStore] LiveKit reconnecting');
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'reconnecting' }
|
||||
: null,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
pendingOffer = null;
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('reconnected', () => {
|
||||
console.log('[CallStore] LiveKit reconnected');
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected' }
|
||||
: null,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
// Set remote description and create answer
|
||||
try {
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type as 'offer' | 'answer',
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('connectionStatusChanged', (status) => {
|
||||
console.log('[CallStore] LiveKit connection status:', status);
|
||||
const { currentCall } = callStore.getState();
|
||||
if (!currentCall) return;
|
||||
|
||||
const answer = await webrtcManager.createAnswer();
|
||||
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to handle incoming offer:', err);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
callStore.getState().endCall('connection_failed');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackSubscribed', ({ track }) => {
|
||||
console.log('[CallStore] Remote track subscribed:', track.kind);
|
||||
if (track.kind === 'video') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: true }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackUnsubscribed', ({ track }) => {
|
||||
console.log('[CallStore] Remote track unsubscribed:', track.kind);
|
||||
if (track.kind === 'video') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: false }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackMuted', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: false }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('trackUnmuted', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isPeerVideoEnabled: true }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('error', (err) => {
|
||||
console.error('[CallStore] LiveKit error:', err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming SDP answer
|
||||
*/
|
||||
async function handleIncomingAnswer(msg: WSCallSDPMessage): Promise<void> {
|
||||
const pc = webrtcManager.getPeerConnection();
|
||||
if (!pc) return;
|
||||
|
||||
const signalingState = pc.signalingState;
|
||||
console.log('[CallStore] Received answer, signalingState:', signalingState);
|
||||
|
||||
if (signalingState !== 'have-local-offer') {
|
||||
console.warn('[CallStore] Ignoring answer, signaling state is', signalingState);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type as 'offer' | 'answer',
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to set remote description from answer:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources
|
||||
* Clean up all resources.
|
||||
*/
|
||||
function cleanupResources(): void {
|
||||
if (callTimeoutTimer) {
|
||||
@@ -331,23 +263,17 @@ function cleanupResources(): void {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
if (rtcUnsubscribe) {
|
||||
rtcUnsubscribe();
|
||||
rtcUnsubscribe = null;
|
||||
}
|
||||
pendingOffer = null;
|
||||
liveKitUnsubs.forEach((unsub) => unsub());
|
||||
liveKitUnsubs = [];
|
||||
}
|
||||
|
||||
export const callStore = create<CallState>((set, get) => ({
|
||||
currentCall: null,
|
||||
incomingCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
isMinimized: false,
|
||||
|
||||
initCall: () => {
|
||||
// Prevent duplicate handler registration
|
||||
if (initCallUnsub) {
|
||||
initCallUnsub();
|
||||
initCallUnsub = null;
|
||||
@@ -376,7 +302,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Check lifetime expiry
|
||||
const callAge = Date.now() - msg.created_at;
|
||||
const lifetime = msg.lifetime || 60000;
|
||||
if (callAge > lifetime - 5000) {
|
||||
@@ -386,7 +311,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch caller info
|
||||
let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
|
||||
useUserStore.getState().userCache[msg.caller_id];
|
||||
if (!caller) {
|
||||
@@ -413,7 +337,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
callerName,
|
||||
callerAvatar: caller?.avatar,
|
||||
callType: msg.call_type,
|
||||
iceServers: msg.ice_servers || [],
|
||||
receivedAt: Date.now(),
|
||||
lifetime: msg.lifetime,
|
||||
},
|
||||
@@ -421,7 +344,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// Set timeout based on lifetime
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
@@ -451,25 +373,17 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
|
||||
try {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connecting' }
|
||||
: null,
|
||||
}));
|
||||
|
||||
const isVideoCall = currentCall.callType === 'video';
|
||||
await webrtcManager.initialize(msg.ice_servers || []);
|
||||
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||
|
||||
set({ localStream: newStream });
|
||||
|
||||
// Setup unified WebRTC event handler
|
||||
setupWebRTCEvents(currentCall.id, myUserId);
|
||||
|
||||
// Start call with transceiver-based approach
|
||||
const offer = await webrtcManager.startCall(true, currentCall.callType);
|
||||
if (offer) {
|
||||
wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || '');
|
||||
}
|
||||
await joinLiveKitRoom(currentCall.id, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_accepted error:', err);
|
||||
console.error('[CallStore] call_accepted LiveKit join error:', err);
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
})
|
||||
@@ -557,41 +471,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_sdp', async (msg: WSCallSDPMessage) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
if (msg.from_id === myUserId) return;
|
||||
|
||||
if (msg.payload.sdp_type === 'offer') {
|
||||
await handleIncomingOffer(msg, myUserId);
|
||||
} else if (msg.payload.sdp_type === 'answer') {
|
||||
await handleIncomingAnswer(msg);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_ice', (msg: WSCallICEMessage) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
if (msg.from_id === myUserId) return;
|
||||
|
||||
try {
|
||||
const candidate = typeof msg.payload.candidate === 'string'
|
||||
? JSON.parse(msg.payload.candidate)
|
||||
: msg.payload.candidate;
|
||||
webrtcManager.addIceCandidate(candidate);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_ice error:', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_peer_muted', (msg) => {
|
||||
console.log('[CallStore] Peer muted:', msg.user_id, msg.muted);
|
||||
@@ -641,13 +520,14 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
peerName: calleeName,
|
||||
peerAvatar: callee?.avatar,
|
||||
callType,
|
||||
status: 'calling', // 改为 'calling' 表示正在呼出
|
||||
status: 'calling',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: false,
|
||||
isVideoEnabled: callType === 'video',
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: true,
|
||||
isPeerReady: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -667,7 +547,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { currentCall: cc } = get();
|
||||
// 只有在 'calling' 状态(呼出中)才超时
|
||||
if (cc && cc.status === 'calling') {
|
||||
console.warn('[CallStore] Call timeout');
|
||||
get().endCall('timeout');
|
||||
@@ -688,7 +567,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
|
||||
const myUserId = getCurrentUserId() || '';
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
@@ -703,8 +581,9 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
isMuted: false,
|
||||
isSpeakerOn: false,
|
||||
isVideoEnabled: isVideoCall,
|
||||
isPeerVideoEnabled: isVideoCall,
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: false,
|
||||
isPeerReady: false,
|
||||
},
|
||||
incomingCall: null,
|
||||
});
|
||||
@@ -712,19 +591,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
wsService.sendCallAnswer(incomingCall.callId);
|
||||
|
||||
try {
|
||||
await webrtcManager.initialize(incomingCall.iceServers);
|
||||
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||
|
||||
set({ localStream: newStream });
|
||||
|
||||
// Setup unified WebRTC event handler
|
||||
setupWebRTCEvents(incomingCall.callId, myUserId);
|
||||
|
||||
// Start call (non-initiator, will wait for offer)
|
||||
await webrtcManager.startCall(false, incomingCall.callType as CallType);
|
||||
|
||||
// Note: For non-initiator, we don't create an offer here.
|
||||
// We wait for the initiator's offer via handleIncomingOffer.
|
||||
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to accept call:', err);
|
||||
get().endCall('connection_failed');
|
||||
@@ -760,11 +627,13 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
set({
|
||||
currentCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
});
|
||||
|
||||
webrtcManager.dispose();
|
||||
try {
|
||||
await liveKitService.disconnect();
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Error disconnecting LiveKit:', err);
|
||||
}
|
||||
|
||||
if (callId && reason !== 'ended') {
|
||||
wsService.sendCallEnd(callId, reason);
|
||||
@@ -776,7 +645,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
if (!currentCall) return;
|
||||
|
||||
const newMuted = !currentCall.isMuted;
|
||||
webrtcManager.setMuted(newMuted);
|
||||
liveKitService.setMuted(newMuted);
|
||||
if (currentCall.id) {
|
||||
wsService.sendCallMute(currentCall.id, newMuted);
|
||||
}
|
||||
@@ -812,13 +681,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
if (!currentCall) return;
|
||||
|
||||
try {
|
||||
if (enabled) {
|
||||
const newStream = await webrtcManager.enableVideo();
|
||||
set({ localStream: newStream });
|
||||
} else {
|
||||
const newStream = await webrtcManager.disableVideo();
|
||||
set({ localStream: newStream });
|
||||
}
|
||||
await liveKitService.setVideoEnabled(enabled);
|
||||
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
@@ -830,36 +693,27 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// 重置所有状态,用于登出时清理
|
||||
reset: () => {
|
||||
// 清理所有定时器和资源
|
||||
cleanupResources();
|
||||
|
||||
// 清理 initCall 的订阅
|
||||
if (initCallUnsub) {
|
||||
initCallUnsub();
|
||||
initCallUnsub = null;
|
||||
}
|
||||
|
||||
// 清理 invited 订阅
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
unsubInvited = null;
|
||||
}
|
||||
|
||||
// 清理 WebRTC 资源
|
||||
webrtcManager.dispose();
|
||||
liveKitService.dispose();
|
||||
|
||||
// 清理已处理的通话ID缓存
|
||||
processedCallIds.clear();
|
||||
|
||||
// 重置状态
|
||||
set({
|
||||
currentCall: null,
|
||||
incomingCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
isMinimized: false,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user