Files
frontend/src/stores/call/callStore.ts
lan 70ab00795a
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
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
2026-06-01 13:45:45 +08:00

721 lines
19 KiB
TypeScript

import { create } from 'zustand';
import {
wsService,
WSCallIncomingMessage,
WSErrorMessage,
api,
} from '@/services/core';
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'
| 'connected'
| 'reconnecting'
| 'ended'
| 'failed';
export type CallType = 'voice' | 'video';
export interface CallSession {
id: string;
conversationId: string;
peerId: string;
peerName?: string;
peerAvatar?: string | null;
status: CallStatus;
callType: CallType;
startedAt?: number;
duration: number;
isMuted: boolean;
isSpeakerOn: boolean;
isVideoEnabled: boolean;
isPeerVideoEnabled: boolean;
isInitiator: boolean;
isPeerReady: boolean;
}
export interface IncomingCallInfo {
callId: string;
conversationId: string;
callerId: string;
callerName?: string;
callerAvatar?: string | null;
callType: string;
receivedAt: number;
lifetime?: number;
}
interface CallState {
currentCall: CallSession | null;
incomingCall: IncomingCallInfo | null;
callDuration: number;
isMinimized: boolean;
initCall: () => () => void;
startCall: (
conversationId: string,
calleeId: string,
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
callType?: CallType
) => Promise<void>;
acceptCall: () => Promise<void>;
rejectCall: () => void;
endCall: (reason?: string) => Promise<void>;
toggleMute: () => void;
toggleSpeaker: () => void;
toggleMinimize: () => void;
toggleVideo: () => Promise<void>;
setVideoEnabled: (enabled: boolean) => Promise<void>;
reset: () => void;
}
// Module-level variables for timers
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 liveKitUnsubs: (() => void)[] = [];
// Constants
const CALL_LIFETIME_MS = 55000;
const CALL_TIMEOUT_MS = 115000;
const IGNORE_CALL_ID_TTL = 30000;
const processedCallIds = new Map<string, number>();
function cleanupProcessedCallIds() {
const now = Date.now();
for (const [callId, timestamp] of processedCallIds) {
if (now - timestamp > IGNORE_CALL_ID_TTL) {
processedCallIds.delete(callId);
}
}
}
/**
* Fetch LiveKit token from the backend and connect to the room.
*/
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');
}
setupLiveKitEvents(callId);
await liveKitService.connect(url, token);
// Enable/disable video based on call type
await liveKitService.setVideoEnabled(videoEnabled);
}
/**
* Set up LiveKit room event handlers.
*/
function setupLiveKitEvents(callId: string): void {
// Clean up any existing subscriptions
liveKitUnsubs.forEach((unsub) => unsub());
liveKitUnsubs = [];
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,
}));
wsService.sendCallReady(callId);
if (durationTimer) clearInterval(durationTimer);
durationTimer = setInterval(() => {
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
}, 1000);
})
);
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: 'reconnecting' }
: null,
}));
}
})
);
liveKitUnsubs.push(
liveKitService.on('reconnecting', () => {
console.log('[CallStore] LiveKit reconnecting');
callStore.setState((s) => ({
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');
}
})
);
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);
})
);
}
/**
* Clean up all resources.
*/
function cleanupResources(): void {
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
if (durationTimer) {
clearInterval(durationTimer);
durationTimer = null;
}
liveKitUnsubs.forEach((unsub) => unsub());
liveKitUnsubs = [];
}
export const callStore = create<CallState>((set, get) => ({
currentCall: null,
incomingCall: null,
callDuration: 0,
isMinimized: false,
initCall: () => {
if (initCallUnsub) {
initCallUnsub();
initCallUnsub = null;
}
const unsubs: Array<() => void> = [];
unsubs.push(
wsService.on('call_incoming', async (msg) => {
cleanupProcessedCallIds();
if (processedCallIds.has(msg.call_id)) {
console.log('[CallStore] Ignoring already processed call:', msg.call_id);
return;
}
const { currentCall, incomingCall } = get();
if (incomingCall) {
wsService.sendCallBusy(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
return;
}
if (currentCall && currentCall.status !== 'idle') {
wsService.sendCallBusy(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
return;
}
const callAge = Date.now() - msg.created_at;
const lifetime = msg.lifetime || 60000;
if (callAge > lifetime - 5000) {
console.log('[CallStore] Ignoring stale incoming call, age:', callAge);
wsService.sendCallReject(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
return;
}
let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
useUserStore.getState().userCache[msg.caller_id];
if (!caller) {
try {
const fetchedCaller = await userManager.getUserById(msg.caller_id);
if (fetchedCaller) {
caller = {
nickname: fetchedCaller.nickname,
username: fetchedCaller.username,
avatar: fetchedCaller.avatar,
};
}
} catch (err) {
console.log('[CallStore] Failed to fetch caller info:', err);
}
}
const callerName = caller?.nickname || caller?.username || msg.caller_id;
set({
incomingCall: {
callId: msg.call_id,
conversationId: msg.conversation_id,
callerId: msg.caller_id,
callerName,
callerAvatar: caller?.avatar,
callType: msg.call_type,
receivedAt: Date.now(),
lifetime: msg.lifetime,
},
});
processedCallIds.set(msg.call_id, Date.now());
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
callTimeoutTimer = setTimeout(() => {
const { incomingCall: ic } = get();
if (ic?.callId === msg.call_id) {
console.log('[CallStore] Incoming call timeout');
wsService.sendCallReject(msg.call_id);
processedCallIds.set(msg.call_id, Date.now());
set({ incomingCall: null });
}
}, remainingTime);
})
);
unsubs.push(
wsService.on('call_accepted', async (msg) => {
const { currentCall } = get();
if (!currentCall || currentCall.id !== msg.call_id) return;
if (!currentCall.isInitiator) {
console.log('[CallStore] Ignoring call_accepted, we are not the initiator');
return;
}
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
try {
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, status: 'connecting' }
: null,
}));
const isVideoCall = currentCall.callType === 'video';
await joinLiveKitRoom(currentCall.id, isVideoCall);
} catch (err) {
console.error('[CallStore] call_accepted LiveKit join error:', err);
get().endCall('connection_failed');
}
})
);
unsubs.push(
wsService.on('call_rejected', (msg) => {
const { currentCall } = get();
if (currentCall?.id !== msg.call_id) return;
console.log('[CallStore] Call rejected');
get().endCall('rejected');
})
);
unsubs.push(
wsService.on('call_busy', (msg) => {
const { currentCall } = get();
if (currentCall?.id !== msg.call_id) return;
console.log('[CallStore] Call busy');
get().endCall('busy');
})
);
unsubs.push(
wsService.on('call_ended', (msg) => {
const { currentCall, incomingCall } = get();
processedCallIds.set(msg.call_id, Date.now());
if (currentCall?.id === msg.call_id) {
console.log('[CallStore] Call ended, duration:', msg.duration);
get().endCall('ended');
return;
}
if (incomingCall?.callId === msg.call_id) {
console.log('[CallStore] Incoming call cancelled by caller');
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
set({ incomingCall: null });
}
})
);
unsubs.push(
wsService.on('call_answered_elsewhere', (msg) => {
const { incomingCall } = get();
if (incomingCall?.callId === msg.call_id) {
console.log('[CallStore] Call answered on another device');
processedCallIds.set(msg.call_id, Date.now());
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
set({ incomingCall: null });
}
})
);
unsubs.push(
wsService.on('error', (msg: WSErrorMessage) => {
const { currentCall, incomingCall } = get();
console.log('[CallStore] Server error:', msg.code, msg.message);
if (msg.code === 'callee_offline') {
if (currentCall && currentCall.status === 'ringing') {
console.log('[CallStore] Callee is offline');
get().endCall('callee_offline');
}
}
if (msg.code === 'call_already_answered') {
if (incomingCall) {
console.log('[CallStore] Call already answered on another device');
processedCallIds.set(incomingCall.callId, Date.now());
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
set({ incomingCall: null });
}
}
})
);
unsubs.push(
wsService.on('call_peer_muted', (msg) => {
console.log('[CallStore] Peer muted:', msg.user_id, msg.muted);
})
);
const cleanup = () => {
cleanupResources();
if (unsubInvited) {
unsubInvited();
unsubInvited = null;
}
unsubs.forEach((unsub) => unsub());
initCallUnsub = null;
};
initCallUnsub = cleanup;
return cleanup;
},
startCall: async (
conversationId: string,
calleeId: string,
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
callType: CallType = 'voice'
) => {
const { currentCall } = get();
if (currentCall && currentCall.status !== 'idle') {
console.warn('[CallStore] Already in a call');
return;
}
const myUserId = getCurrentUserId();
if (!myUserId) {
console.error('[CallStore] Not logged in');
return;
}
const cachedCallee = useUserStore.getState().userCache[calleeId];
const callee = calleeInfo || cachedCallee;
const calleeName = callee?.nickname || callee?.username || calleeId;
set({
currentCall: {
id: '',
conversationId,
peerId: calleeId,
peerName: calleeName,
peerAvatar: callee?.avatar,
callType,
status: 'calling',
duration: 0,
isMuted: false,
isSpeakerOn: false,
isVideoEnabled: callType === 'video',
isPeerVideoEnabled: false,
isInitiator: true,
isPeerReady: false,
},
});
wsService.sendCallInvite(conversationId, calleeId, callType);
if (unsubInvited) {
unsubInvited();
}
unsubInvited = wsService.on('call_invited', (msg) => {
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, id: msg.call_id }
: null,
}));
});
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
callTimeoutTimer = setTimeout(() => {
const { currentCall: cc } = get();
if (cc && cc.status === 'calling') {
console.warn('[CallStore] Call timeout');
get().endCall('timeout');
}
}, CALL_TIMEOUT_MS);
},
acceptCall: async () => {
const { incomingCall } = get();
if (!incomingCall) return;
console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType);
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
const isVideoCall = incomingCall.callType === 'video';
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
set({
currentCall: {
id: incomingCall.callId,
conversationId: incomingCall.conversationId,
peerId: incomingCall.callerId,
peerName: incomingCall.callerName,
peerAvatar: incomingCall.callerAvatar,
callType: incomingCall.callType as CallType,
status: 'connecting',
duration: 0,
isMuted: false,
isSpeakerOn: false,
isVideoEnabled: isVideoCall,
isPeerVideoEnabled: false,
isInitiator: false,
isPeerReady: false,
},
incomingCall: null,
});
wsService.sendCallAnswer(incomingCall.callId);
try {
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
} catch (err) {
console.error('[CallStore] Failed to accept call:', err);
get().endCall('connection_failed');
}
},
rejectCall: () => {
const { incomingCall } = get();
if (!incomingCall) return;
if (callTimeoutTimer) {
clearTimeout(callTimeoutTimer);
callTimeoutTimer = null;
}
wsService.sendCallReject(incomingCall.callId);
set({ incomingCall: null });
},
endCall: async (reason = 'ended') => {
const { currentCall } = get();
if (!currentCall) return;
cleanupResources();
if (unsubInvited) {
unsubInvited();
unsubInvited = null;
}
const callId = currentCall.id;
set({
currentCall: null,
callDuration: 0,
});
try {
await liveKitService.disconnect();
} catch (err) {
console.error('[CallStore] Error disconnecting LiveKit:', err);
}
if (callId && reason !== 'ended') {
wsService.sendCallEnd(callId, reason);
}
},
toggleMute: () => {
const { currentCall } = get();
if (!currentCall) return;
const newMuted = !currentCall.isMuted;
liveKitService.setMuted(newMuted);
if (currentCall.id) {
wsService.sendCallMute(currentCall.id, newMuted);
}
set((s) => ({
currentCall: s.currentCall ? { ...s.currentCall, isMuted: newMuted } : null,
}));
},
toggleSpeaker: () => {
const { currentCall } = get();
if (!currentCall) return;
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isSpeakerOn: !s.currentCall.isSpeakerOn }
: null,
}));
},
toggleMinimize: () => {
set((s) => ({ isMinimized: !s.isMinimized }));
},
toggleVideo: async () => {
const { currentCall } = get();
if (!currentCall) return;
const newVideoEnabled = !currentCall.isVideoEnabled;
await get().setVideoEnabled(newVideoEnabled);
},
setVideoEnabled: async (enabled: boolean) => {
const { currentCall } = get();
if (!currentCall) return;
try {
await liveKitService.setVideoEnabled(enabled);
set((s) => ({
currentCall: s.currentCall
? { ...s.currentCall, isVideoEnabled: enabled, callType: enabled ? 'video' : 'voice' }
: null,
}));
} catch (err) {
console.error('[CallStore] Failed to toggle video:', err);
}
},
reset: () => {
cleanupResources();
if (initCallUnsub) {
initCallUnsub();
initCallUnsub = null;
}
if (unsubInvited) {
unsubInvited();
unsubInvited = null;
}
liveKitService.dispose();
processedCallIds.clear();
set({
currentCall: null,
incomingCall: null,
callDuration: 0,
isMinimized: false,
});
},
}));