2026-03-27 17:12:19 +08:00
|
|
|
import { create } from 'zustand';
|
|
|
|
|
import {
|
|
|
|
|
wsService,
|
|
|
|
|
WSCallIncomingMessage,
|
|
|
|
|
WSErrorMessage,
|
2026-06-01 13:45:45 +08:00
|
|
|
api,
|
2026-04-13 01:30:37 +08:00
|
|
|
} from '@/services/core';
|
2026-06-01 13:45:45 +08:00
|
|
|
import { liveKitService } from '@/services/livekit';
|
2026-04-13 04:56:58 +08:00
|
|
|
import { getCurrentUserId } from '../auth/sessionStore';
|
|
|
|
|
import { useUserStore } from '../userStore';
|
2026-04-24 16:44:01 +08:00
|
|
|
import { userManager } from '../user/UserManager';
|
2026-03-27 17:12:19 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
export type CallStatus =
|
|
|
|
|
| 'idle'
|
|
|
|
|
| 'calling'
|
|
|
|
|
| 'ringing'
|
|
|
|
|
| 'connecting'
|
|
|
|
|
| 'connected'
|
|
|
|
|
| 'reconnecting'
|
|
|
|
|
| 'ended'
|
|
|
|
|
| 'failed';
|
2026-03-28 00:56:52 +08:00
|
|
|
|
|
|
|
|
export type CallType = 'voice' | 'video';
|
2026-03-27 17:12:19 +08:00
|
|
|
|
|
|
|
|
export interface CallSession {
|
|
|
|
|
id: string;
|
|
|
|
|
conversationId: string;
|
|
|
|
|
peerId: string;
|
|
|
|
|
peerName?: string;
|
|
|
|
|
peerAvatar?: string | null;
|
|
|
|
|
status: CallStatus;
|
2026-03-28 00:56:52 +08:00
|
|
|
callType: CallType;
|
2026-03-27 17:12:19 +08:00
|
|
|
startedAt?: number;
|
|
|
|
|
duration: number;
|
|
|
|
|
isMuted: boolean;
|
|
|
|
|
isSpeakerOn: boolean;
|
2026-03-28 00:56:52 +08:00
|
|
|
isVideoEnabled: boolean;
|
|
|
|
|
isPeerVideoEnabled: boolean;
|
2026-03-27 17:12:19 +08:00
|
|
|
isInitiator: boolean;
|
2026-06-01 13:45:45 +08:00
|
|
|
isPeerReady: boolean;
|
2026-03-27 17:12:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-28 00:56:52 +08:00
|
|
|
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
|
|
|
|
|
callType?: CallType
|
2026-03-27 17:12:19 +08:00
|
|
|
) => Promise<void>;
|
|
|
|
|
acceptCall: () => Promise<void>;
|
|
|
|
|
rejectCall: () => void;
|
|
|
|
|
endCall: (reason?: string) => Promise<void>;
|
|
|
|
|
toggleMute: () => void;
|
|
|
|
|
toggleSpeaker: () => void;
|
|
|
|
|
toggleMinimize: () => void;
|
2026-03-28 00:56:52 +08:00
|
|
|
toggleVideo: () => Promise<void>;
|
|
|
|
|
setVideoEnabled: (enabled: boolean) => Promise<void>;
|
2026-04-01 02:17:36 +08:00
|
|
|
reset: () => void;
|
2026-03-27 17:12:19 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
// Module-level variables for timers
|
2026-03-27 17:12:19 +08:00
|
|
|
let durationTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
let initCallUnsub: (() => void) | null = null;
|
|
|
|
|
let unsubInvited: (() => void) | null = null;
|
2026-06-01 13:45:45 +08:00
|
|
|
let liveKitUnsubs: (() => void)[] = [];
|
2026-06-02 13:28:16 +08:00
|
|
|
let pendingVideoEnabled: boolean | null = null;
|
2026-03-27 17:12:19 +08:00
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
// Constants
|
|
|
|
|
const CALL_LIFETIME_MS = 55000;
|
|
|
|
|
const CALL_TIMEOUT_MS = 115000;
|
|
|
|
|
const IGNORE_CALL_ID_TTL = 30000;
|
2026-03-27 17:12:19 +08:00
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
const processedCallIds = new Map<string, number>();
|
2026-03-27 17:12:19 +08:00
|
|
|
|
|
|
|
|
function cleanupProcessedCallIds() {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
for (const [callId, timestamp] of processedCallIds) {
|
|
|
|
|
if (now - timestamp > IGNORE_CALL_ID_TTL) {
|
|
|
|
|
processedCallIds.delete(callId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
/**
|
2026-06-01 13:45:45 +08:00
|
|
|
* Fetch LiveKit token from the backend and connect to the room.
|
2026-06-02 13:28:16 +08:00
|
|
|
* Connects audio-only first, then enables video separately.
|
2026-03-28 00:56:52 +08:00
|
|
|
*/
|
2026-06-01 13:45:45 +08:00
|
|
|
async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<void> {
|
|
|
|
|
const res = await api.get<{ token: string; url: string }>('/calls/token', {
|
|
|
|
|
room: callId,
|
2026-04-22 16:54:51 +08:00
|
|
|
});
|
|
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
const { token, url } = res.data;
|
|
|
|
|
if (!token || !url) {
|
|
|
|
|
throw new Error('Invalid LiveKit token response');
|
|
|
|
|
}
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-02 13:28:16 +08:00
|
|
|
// Store video preference for enabling after connection
|
|
|
|
|
pendingVideoEnabled = videoEnabled;
|
|
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
setupLiveKitEvents(callId);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-02 13:28:16 +08:00
|
|
|
// Always connect as audio-only first
|
|
|
|
|
await liveKitService.connect(url, token, videoEnabled);
|
|
|
|
|
|
|
|
|
|
// Enable microphone
|
|
|
|
|
await liveKitService.setMuted(false);
|
|
|
|
|
|
|
|
|
|
// Video will be enabled by enablePendingVideo() called from connected event
|
|
|
|
|
}
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-02 13:28:16 +08:00
|
|
|
/**
|
|
|
|
|
* Enable video after a delay to avoid iOS main thread deadlock.
|
|
|
|
|
* Called from the connected event handler.
|
|
|
|
|
*/
|
|
|
|
|
function enablePendingVideo(): void {
|
|
|
|
|
if (!pendingVideoEnabled) return;
|
|
|
|
|
const enable = pendingVideoEnabled;
|
|
|
|
|
pendingVideoEnabled = null;
|
|
|
|
|
|
|
|
|
|
// Use a longer timeout to let the JS thread fully settle
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
liveKitService.setVideoEnabled(enable).catch((err) => {
|
|
|
|
|
console.warn('[CallStore] Failed to enable video:', err);
|
|
|
|
|
});
|
|
|
|
|
}, 1000);
|
2026-03-28 00:56:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-01 13:45:45 +08:00
|
|
|
* Set up LiveKit room event handlers.
|
2026-03-28 00:56:52 +08:00
|
|
|
*/
|
2026-06-01 13:45:45 +08:00
|
|
|
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,
|
|
|
|
|
}));
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
wsService.sendCallReady(callId);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-02 13:28:16 +08:00
|
|
|
// Start duration timer immediately
|
2026-06-01 13:45:45 +08:00
|
|
|
if (durationTimer) clearInterval(durationTimer);
|
|
|
|
|
durationTimer = setInterval(() => {
|
|
|
|
|
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
|
|
|
|
}, 1000);
|
2026-06-02 13:28:16 +08:00
|
|
|
|
|
|
|
|
// Schedule video enable after settling (avoids iOS deadlock)
|
|
|
|
|
enablePendingVideo();
|
2026-06-01 13:45:45 +08:00
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 04:35:05 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitUnsubs.push(
|
|
|
|
|
liveKitService.on('disconnected', () => {
|
|
|
|
|
console.log('[CallStore] LiveKit disconnected');
|
|
|
|
|
const { currentCall } = callStore.getState();
|
|
|
|
|
if (currentCall && currentCall.status !== 'ended' && currentCall.status !== 'failed') {
|
2026-03-28 04:35:05 +08:00
|
|
|
callStore.setState((s) => ({
|
|
|
|
|
currentCall: s.currentCall
|
2026-06-01 13:45:45 +08:00
|
|
|
? { ...s.currentCall, status: 'reconnecting' }
|
2026-03-28 04:35:05 +08:00
|
|
|
: null,
|
|
|
|
|
}));
|
2026-03-28 01:41:26 +08:00
|
|
|
}
|
2026-06-01 13:45:45 +08:00
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 01:41:26 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitUnsubs.push(
|
|
|
|
|
liveKitService.on('reconnecting', () => {
|
|
|
|
|
console.log('[CallStore] LiveKit reconnecting');
|
|
|
|
|
callStore.setState((s) => ({
|
|
|
|
|
currentCall: s.currentCall
|
|
|
|
|
? { ...s.currentCall, status: 'reconnecting' }
|
|
|
|
|
: null,
|
|
|
|
|
}));
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitUnsubs.push(
|
|
|
|
|
liveKitService.on('reconnected', () => {
|
|
|
|
|
console.log('[CallStore] LiveKit reconnected');
|
|
|
|
|
callStore.setState((s) => ({
|
|
|
|
|
currentCall: s.currentCall
|
|
|
|
|
? { ...s.currentCall, status: 'connected' }
|
|
|
|
|
: null,
|
|
|
|
|
}));
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitUnsubs.push(
|
|
|
|
|
liveKitService.on('connectionStatusChanged', (status) => {
|
|
|
|
|
console.log('[CallStore] LiveKit connection status:', status);
|
|
|
|
|
const { currentCall } = callStore.getState();
|
|
|
|
|
if (!currentCall) return;
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
if (status === 'failed') {
|
|
|
|
|
callStore.getState().endCall('connection_failed');
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitUnsubs.push(
|
|
|
|
|
liveKitService.on('trackMuted', ({ publication }) => {
|
|
|
|
|
if (publication.source === 'camera') {
|
|
|
|
|
callStore.setState((s) => ({
|
|
|
|
|
currentCall: s.currentCall
|
|
|
|
|
? { ...s.currentCall, isPeerVideoEnabled: false }
|
|
|
|
|
: null,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
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);
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-03-28 00:56:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-01 13:45:45 +08:00
|
|
|
* Clean up all resources.
|
2026-03-28 00:56:52 +08:00
|
|
|
*/
|
|
|
|
|
function cleanupResources(): void {
|
|
|
|
|
if (callTimeoutTimer) {
|
|
|
|
|
clearTimeout(callTimeoutTimer);
|
|
|
|
|
callTimeoutTimer = null;
|
|
|
|
|
}
|
|
|
|
|
if (durationTimer) {
|
|
|
|
|
clearInterval(durationTimer);
|
|
|
|
|
durationTimer = null;
|
|
|
|
|
}
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitUnsubs.forEach((unsub) => unsub());
|
|
|
|
|
liveKitUnsubs = [];
|
2026-03-28 00:56:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 17:12:19 +08:00
|
|
|
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;
|
2026-03-28 00:56:52 +08:00
|
|
|
const lifetime = msg.lifetime || 60000;
|
|
|
|
|
if (callAge > lifetime - 5000) {
|
|
|
|
|
console.log('[CallStore] Ignoring stale incoming call, age:', callAge);
|
2026-03-27 17:12:19 +08:00
|
|
|
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);
|
2026-03-28 00:56:52 +08:00
|
|
|
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
|
2026-03-27 17:12:19 +08:00
|
|
|
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;
|
|
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
if (!currentCall.isInitiator) {
|
|
|
|
|
console.log('[CallStore] Ignoring call_accepted, we are not the initiator');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (callTimeoutTimer) {
|
|
|
|
|
clearTimeout(callTimeoutTimer);
|
|
|
|
|
callTimeoutTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 17:12:19 +08:00
|
|
|
try {
|
2026-06-01 13:45:45 +08:00
|
|
|
set((s) => ({
|
|
|
|
|
currentCall: s.currentCall
|
|
|
|
|
? { ...s.currentCall, status: 'connecting' }
|
|
|
|
|
: null,
|
|
|
|
|
}));
|
2026-03-27 17:12:19 +08:00
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
const isVideoCall = currentCall.callType === 'video';
|
|
|
|
|
await joinLiveKitRoom(currentCall.id, isVideoCall);
|
2026-03-27 17:12:19 +08:00
|
|
|
} catch (err) {
|
2026-06-01 13:45:45 +08:00
|
|
|
console.error('[CallStore] call_accepted LiveKit join error:', err);
|
2026-03-27 17:12:19 +08:00
|
|
|
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) {
|
2026-06-02 13:28:16 +08:00
|
|
|
console.log('[CallStore] Call ended, reason:', msg.reason, 'ended_by:', msg.ended_by, 'duration:', msg.duration);
|
2026-03-27 17:12:19 +08:00
|
|
|
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 = () => {
|
2026-03-28 00:56:52 +08:00
|
|
|
cleanupResources();
|
|
|
|
|
if (unsubInvited) {
|
|
|
|
|
unsubInvited();
|
|
|
|
|
unsubInvited = null;
|
|
|
|
|
}
|
2026-03-27 17:12:19 +08:00
|
|
|
unsubs.forEach((unsub) => unsub());
|
|
|
|
|
initCallUnsub = null;
|
|
|
|
|
};
|
|
|
|
|
initCallUnsub = cleanup;
|
|
|
|
|
return cleanup;
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
startCall: async (
|
|
|
|
|
conversationId: string,
|
|
|
|
|
calleeId: string,
|
2026-03-28 00:56:52 +08:00
|
|
|
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
|
|
|
|
|
callType: CallType = 'voice'
|
2026-03-27 17:12:19 +08:00
|
|
|
) => {
|
|
|
|
|
const { currentCall } = get();
|
|
|
|
|
if (currentCall && currentCall.status !== 'idle') {
|
|
|
|
|
console.warn('[CallStore] Already in a call');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 16:30:44 +08:00
|
|
|
const myUserId = getCurrentUserId();
|
2026-03-27 17:12:19 +08:00
|
|
|
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: {
|
2026-03-28 00:56:52 +08:00
|
|
|
id: '',
|
2026-03-27 17:12:19 +08:00
|
|
|
conversationId,
|
|
|
|
|
peerId: calleeId,
|
|
|
|
|
peerName: calleeName,
|
|
|
|
|
peerAvatar: callee?.avatar,
|
2026-03-28 00:56:52 +08:00
|
|
|
callType,
|
2026-06-01 13:45:45 +08:00
|
|
|
status: 'calling',
|
2026-03-27 17:12:19 +08:00
|
|
|
duration: 0,
|
|
|
|
|
isMuted: false,
|
2026-06-02 13:28:16 +08:00
|
|
|
isSpeakerOn: callType === 'video',
|
2026-03-28 00:56:52 +08:00
|
|
|
isVideoEnabled: callType === 'video',
|
|
|
|
|
isPeerVideoEnabled: false,
|
2026-03-27 17:12:19 +08:00
|
|
|
isInitiator: true,
|
2026-06-01 13:45:45 +08:00
|
|
|
isPeerReady: false,
|
2026-03-27 17:12:19 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
wsService.sendCallInvite(conversationId, calleeId, callType);
|
2026-03-27 17:12:19 +08:00
|
|
|
|
|
|
|
|
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();
|
2026-03-28 04:35:05 +08:00
|
|
|
if (cc && cc.status === 'calling') {
|
2026-03-27 17:12:19 +08:00
|
|
|
console.warn('[CallStore] Call timeout');
|
|
|
|
|
get().endCall('timeout');
|
|
|
|
|
}
|
|
|
|
|
}, CALL_TIMEOUT_MS);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
acceptCall: async () => {
|
|
|
|
|
const { incomingCall } = get();
|
|
|
|
|
if (!incomingCall) return;
|
|
|
|
|
|
2026-03-28 04:35:05 +08:00
|
|
|
console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType);
|
|
|
|
|
|
2026-03-27 17:12:19 +08:00
|
|
|
if (callTimeoutTimer) {
|
|
|
|
|
clearTimeout(callTimeoutTimer);
|
|
|
|
|
callTimeoutTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
const isVideoCall = incomingCall.callType === 'video';
|
2026-03-28 04:35:05 +08:00
|
|
|
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
2026-03-27 17:12:19 +08:00
|
|
|
set({
|
|
|
|
|
currentCall: {
|
|
|
|
|
id: incomingCall.callId,
|
|
|
|
|
conversationId: incomingCall.conversationId,
|
|
|
|
|
peerId: incomingCall.callerId,
|
|
|
|
|
peerName: incomingCall.callerName,
|
|
|
|
|
peerAvatar: incomingCall.callerAvatar,
|
2026-03-28 00:56:52 +08:00
|
|
|
callType: incomingCall.callType as CallType,
|
2026-03-27 17:12:19 +08:00
|
|
|
status: 'connecting',
|
|
|
|
|
duration: 0,
|
|
|
|
|
isMuted: false,
|
2026-06-02 13:28:16 +08:00
|
|
|
isSpeakerOn: isVideoCall,
|
2026-03-28 00:56:52 +08:00
|
|
|
isVideoEnabled: isVideoCall,
|
2026-06-01 13:45:45 +08:00
|
|
|
isPeerVideoEnabled: false,
|
2026-03-27 17:12:19 +08:00
|
|
|
isInitiator: false,
|
2026-06-01 13:45:45 +08:00
|
|
|
isPeerReady: false,
|
2026-03-27 17:12:19 +08:00
|
|
|
},
|
|
|
|
|
incomingCall: null,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
wsService.sendCallAnswer(incomingCall.callId);
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-01 13:45:45 +08:00
|
|
|
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
|
2026-03-27 17:12:19 +08:00
|
|
|
} 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;
|
|
|
|
|
|
2026-03-28 00:56:52 +08:00
|
|
|
cleanupResources();
|
|
|
|
|
|
2026-03-27 17:12:19 +08:00
|
|
|
if (unsubInvited) {
|
|
|
|
|
unsubInvited();
|
|
|
|
|
unsubInvited = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const callId = currentCall.id;
|
|
|
|
|
|
|
|
|
|
set({
|
|
|
|
|
currentCall: null,
|
|
|
|
|
callDuration: 0,
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
try {
|
|
|
|
|
await liveKitService.disconnect();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[CallStore] Error disconnecting LiveKit:', err);
|
|
|
|
|
}
|
2026-03-27 17:12:19 +08:00
|
|
|
|
|
|
|
|
if (callId && reason !== 'ended') {
|
|
|
|
|
wsService.sendCallEnd(callId, reason);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleMute: () => {
|
|
|
|
|
const { currentCall } = get();
|
|
|
|
|
if (!currentCall) return;
|
|
|
|
|
|
|
|
|
|
const newMuted = !currentCall.isMuted;
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitService.setMuted(newMuted);
|
2026-03-27 17:12:19 +08:00
|
|
|
if (currentCall.id) {
|
|
|
|
|
wsService.sendCallMute(currentCall.id, newMuted);
|
|
|
|
|
}
|
|
|
|
|
set((s) => ({
|
|
|
|
|
currentCall: s.currentCall ? { ...s.currentCall, isMuted: newMuted } : null,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-02 13:28:16 +08:00
|
|
|
toggleSpeaker: async () => {
|
2026-03-27 17:12:19 +08:00
|
|
|
const { currentCall } = get();
|
|
|
|
|
if (!currentCall) return;
|
2026-06-02 13:28:16 +08:00
|
|
|
const newSpeakerOn = !currentCall.isSpeakerOn;
|
2026-03-27 17:12:19 +08:00
|
|
|
set((s) => ({
|
|
|
|
|
currentCall: s.currentCall
|
2026-06-02 13:28:16 +08:00
|
|
|
? { ...s.currentCall, isSpeakerOn: newSpeakerOn }
|
2026-03-27 17:12:19 +08:00
|
|
|
: null,
|
|
|
|
|
}));
|
2026-06-02 13:28:16 +08:00
|
|
|
await liveKitService.setSpeakerOn(newSpeakerOn);
|
2026-03-27 17:12:19 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
toggleMinimize: () => {
|
|
|
|
|
set((s) => ({ isMinimized: !s.isMinimized }));
|
|
|
|
|
},
|
2026-03-28 00:56:52 +08:00
|
|
|
|
|
|
|
|
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 {
|
2026-06-01 13:45:45 +08:00
|
|
|
await liveKitService.setVideoEnabled(enabled);
|
2026-03-28 00:56:52 +08:00
|
|
|
|
|
|
|
|
set((s) => ({
|
|
|
|
|
currentCall: s.currentCall
|
|
|
|
|
? { ...s.currentCall, isVideoEnabled: enabled, callType: enabled ? 'video' : 'voice' }
|
|
|
|
|
: null,
|
|
|
|
|
}));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[CallStore] Failed to toggle video:', err);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-01 02:17:36 +08:00
|
|
|
|
|
|
|
|
reset: () => {
|
|
|
|
|
cleanupResources();
|
|
|
|
|
|
|
|
|
|
if (initCallUnsub) {
|
|
|
|
|
initCallUnsub();
|
|
|
|
|
initCallUnsub = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (unsubInvited) {
|
|
|
|
|
unsubInvited();
|
|
|
|
|
unsubInvited = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-01 13:45:45 +08:00
|
|
|
liveKitService.dispose();
|
2026-04-01 02:17:36 +08:00
|
|
|
|
|
|
|
|
processedCallIds.clear();
|
|
|
|
|
|
|
|
|
|
set({
|
|
|
|
|
currentCall: null,
|
|
|
|
|
incomingCall: null,
|
|
|
|
|
callDuration: 0,
|
|
|
|
|
isMinimized: false,
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-03-27 17:12:19 +08:00
|
|
|
}));
|