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; acceptCall: () => Promise; rejectCall: () => void; endCall: (reason?: string) => Promise; toggleMute: () => void; toggleSpeaker: () => void; toggleMinimize: () => void; toggleVideo: () => Promise; setVideoEnabled: (enabled: boolean) => Promise; reset: () => void; } // Module-level variables for timers let durationTimer: ReturnType | null = null; let callTimeoutTimer: ReturnType | 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(); 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 { 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((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, }); }, }));