feat(call): implement CallKeep integration and enhance group call support
Integrate `expo-callkit-telecom` to support native system call handling (answering, ending, and muting via system UI). This includes a new `callKeepService` and a `CallKeepBootstrap` component to manage the lifecycle of system call events. Additionally, improves the calling experience by: - Adding support for group calls with participant tracking and UI indicators. - Enhancing LiveKit integration by replacing polling with event-driven video track synchronization. - Updating `WebSocketService` to prevent disconnection when the app enters the background during an active call. - Adding new WebSocket message types for participant join/leave events and group invites. - Refining call UI components (`CallScreen`, `FloatingCallWindow`, `IncomingCallModal`) for better visual feedback and safe area handling. Refactor LiveKit service to use event-driven updates for local and remote tracks, improving performance and reliability.
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
||||
api,
|
||||
} from '@/services/core';
|
||||
import { liveKitService } from '@/services/livekit';
|
||||
import { callKeepService } from '@/services/callkeep';
|
||||
import type { IncomingCallEvent, CallSession as SystemCallSession } from 'expo-callkit-telecom';
|
||||
import { getCurrentUserId } from '../auth/sessionStore';
|
||||
import { useUserStore } from '../userStore';
|
||||
import { userManager } from '../user/UserManager';
|
||||
@@ -22,14 +24,27 @@ export type CallStatus =
|
||||
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
export interface CallParticipant {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string | null;
|
||||
isMuted: boolean;
|
||||
isVideoEnabled: boolean;
|
||||
isLocal: boolean;
|
||||
isReady: boolean;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
export interface CallSession {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
groupId?: string;
|
||||
peerId: string;
|
||||
peerName?: string;
|
||||
peerAvatar?: string | null;
|
||||
status: CallStatus;
|
||||
callType: CallType;
|
||||
mediaType: 'voice' | 'video';
|
||||
startedAt?: number;
|
||||
duration: number;
|
||||
isMuted: boolean;
|
||||
@@ -38,6 +53,9 @@ export interface CallSession {
|
||||
isPeerVideoEnabled: boolean;
|
||||
isInitiator: boolean;
|
||||
isPeerReady: boolean;
|
||||
participants: Map<string, CallParticipant>;
|
||||
activeSpeakerId: string | null;
|
||||
pinnedParticipantId: string | null;
|
||||
}
|
||||
|
||||
export interface IncomingCallInfo {
|
||||
@@ -72,6 +90,19 @@ interface CallState {
|
||||
toggleMinimize: () => void;
|
||||
toggleVideo: () => Promise<void>;
|
||||
setVideoEnabled: (enabled: boolean) => Promise<void>;
|
||||
handleSystemAnswer: () => Promise<void>;
|
||||
handleIncomingFromPush: (session: SystemCallSession) => void;
|
||||
handleSystemMuted: (muted: boolean) => void;
|
||||
startGroupCall: (
|
||||
groupId: string,
|
||||
conversationId: string,
|
||||
callType: CallType
|
||||
) => Promise<void>;
|
||||
addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => void;
|
||||
removeParticipant: (userId: string) => void;
|
||||
setActiveSpeaker: (userId: string | null) => void;
|
||||
pinParticipant: (userId: string | null) => void;
|
||||
updateParticipantState: (userId: string, updates: Partial<CallParticipant>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -99,6 +130,41 @@ function cleanupProcessedCallIds() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build initial participants map for a new call session.
|
||||
*/
|
||||
function buildInitialParticipants(
|
||||
localUserId: string,
|
||||
localName: string,
|
||||
calleeId: string,
|
||||
calleeName: string,
|
||||
calleeAvatar?: string | null,
|
||||
): Map<string, CallParticipant> {
|
||||
const m = new Map<string, CallParticipant>();
|
||||
const now = Date.now();
|
||||
m.set(localUserId, {
|
||||
id: localUserId,
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: now,
|
||||
});
|
||||
m.set(calleeId, {
|
||||
id: calleeId,
|
||||
name: calleeName,
|
||||
avatar: calleeAvatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: false,
|
||||
joinedAt: 0,
|
||||
});
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch LiveKit token from the backend and connect to the room.
|
||||
* Connects audio-only first, then enables video separately.
|
||||
@@ -116,10 +182,13 @@ async function joinLiveKitRoom(callId: string, videoEnabled: boolean): Promise<v
|
||||
// Store video preference for enabling after connection
|
||||
pendingVideoEnabled = videoEnabled;
|
||||
|
||||
// Configure RTCAudioSession for the call type before connecting
|
||||
callKeepService.configureAudioForCall(videoEnabled);
|
||||
|
||||
setupLiveKitEvents(callId);
|
||||
|
||||
// Always connect as audio-only first
|
||||
await liveKitService.connect(url, token, videoEnabled);
|
||||
await liveKitService.connect(url, token);
|
||||
|
||||
// Enable microphone
|
||||
await liveKitService.setMuted(false);
|
||||
@@ -172,6 +241,10 @@ function setupLiveKitEvents(callId: string): void {
|
||||
|
||||
// Schedule video enable after settling (avoids iOS deadlock)
|
||||
enablePendingVideo();
|
||||
|
||||
// Sync with system CallKit/Telecom UI
|
||||
callKeepService.fulfillIncomingCallConnected();
|
||||
callKeepService.reportOutgoingCallConnected();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -278,6 +351,67 @@ function setupLiveKitEvents(callId: string): void {
|
||||
console.error('[CallStore] LiveKit error:', err);
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('activeSpeakersChanged', ({ speakers }) => {
|
||||
const activeId = speakers.length > 0 ? speakers[0].identity : null;
|
||||
callStore.getState().setActiveSpeaker(activeId);
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('participantConnected', ({ participant }) => {
|
||||
console.log('[CallStore] Remote participant connected:', participant.identity);
|
||||
callStore.setState((s) => {
|
||||
if (!s.currentCall) return {};
|
||||
const newParticipants = new Map(s.currentCall.participants);
|
||||
if (!newParticipants.has(participant.identity)) {
|
||||
newParticipants.set(participant.identity, {
|
||||
id: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: true,
|
||||
joinedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return { currentCall: { ...s.currentCall, participants: newParticipants } };
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('participantDisconnected', ({ participant }) => {
|
||||
console.log('[CallStore] Remote participant disconnected:', participant.identity);
|
||||
callStore.getState().removeParticipant(participant.identity);
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('localTrackPublished', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isVideoEnabled: true }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
liveKitUnsubs.push(
|
||||
liveKitService.on('localTrackUnpublished', ({ publication }) => {
|
||||
if (publication.source === 'camera') {
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isVideoEnabled: false }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,6 +442,28 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
initCallUnsub = null;
|
||||
}
|
||||
|
||||
// Initialize CallKit/Telecom service with system UI callbacks
|
||||
callKeepService.initialize({
|
||||
onOutgoingStarted: () => {
|
||||
console.log('[CallStore] System outgoing call started');
|
||||
},
|
||||
onSystemAnswered: () => {
|
||||
console.log('[CallStore] System answered');
|
||||
get().acceptCall();
|
||||
},
|
||||
onSystemEnded: () => {
|
||||
console.log('[CallStore] System ended call');
|
||||
get().endCall('system_ended');
|
||||
},
|
||||
onSystemMuted: (muted: boolean) => {
|
||||
get().toggleMute();
|
||||
},
|
||||
onIncomingCallFromPush: (_session) => {
|
||||
// Incoming call from push when app was killed — already handled by WS event
|
||||
console.log('[CallStore] Incoming call from push (already handled by WS)');
|
||||
},
|
||||
});
|
||||
|
||||
const unsubs: Array<() => void> = [];
|
||||
|
||||
unsubs.push(
|
||||
@@ -371,14 +527,30 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
},
|
||||
});
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// Report incoming call to system CallKit/Telecom UI
|
||||
callKeepService.reportIncomingCallEvent({
|
||||
eventId: `${msg.call_id}-${Date.now()}`,
|
||||
serverCallId: msg.call_id,
|
||||
hasVideo: msg.call_type === 'video',
|
||||
startedAt: new Date(msg.created_at).toISOString(),
|
||||
caller: {
|
||||
id: msg.caller_id,
|
||||
displayName: callerName,
|
||||
avatarUrl: caller?.avatar || undefined,
|
||||
},
|
||||
metadata: { conversationId: msg.conversation_id },
|
||||
});
|
||||
|
||||
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.preventDisconnectOnBackground = false;
|
||||
wsService.sendCallReject(msg.call_id);
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
set({ incomingCall: null });
|
||||
@@ -450,6 +622,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
if (incomingCall?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Incoming call cancelled by caller');
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
@@ -464,6 +637,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { incomingCall } = get();
|
||||
if (incomingCall?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Call answered on another device');
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
@@ -506,6 +680,24 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_participant_joined', (msg) => {
|
||||
console.log('[CallStore] Participant joined:', msg.user_id);
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
get().addParticipant(msg.user_id, { name: msg.user_id });
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_participant_left', (msg) => {
|
||||
console.log('[CallStore] Participant left:', msg.user_id, 'reason:', msg.reason);
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
get().removeParticipant(msg.user_id);
|
||||
})
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
cleanupResources();
|
||||
if (unsubInvited) {
|
||||
@@ -537,10 +729,17 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
const cachedCallee = useUserStore.getState().userCache[calleeId];
|
||||
const callee = calleeInfo || cachedCallee;
|
||||
const calleeName = callee?.nickname || callee?.username || calleeId;
|
||||
|
||||
const localUser = useUserStore.getState().userCache[myUserId];
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId;
|
||||
|
||||
const initialParticipants = buildInitialParticipants(myUserId, localName, calleeId, calleeName, callee?.avatar);
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: '',
|
||||
@@ -549,6 +748,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
peerName: calleeName,
|
||||
peerAvatar: callee?.avatar,
|
||||
callType,
|
||||
mediaType: callType,
|
||||
status: 'calling',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
@@ -557,20 +757,38 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: true,
|
||||
isPeerReady: false,
|
||||
participants: initialParticipants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
});
|
||||
|
||||
wsService.sendCallInvite(conversationId, calleeId, callType);
|
||||
|
||||
callKeepService.startOutgoingCall(calleeId, calleeName, callType === 'video').then((systemCallId) => {
|
||||
if (systemCallId) {
|
||||
// Sync system call ID with store, but don't overwrite if WS already set it
|
||||
const { currentCall: cc } = get();
|
||||
if (cc && !cc.id) {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall ? { ...s.currentCall, id: systemCallId } : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
}
|
||||
unsubInvited = wsService.on('call_invited', (msg) => {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, id: msg.call_id }
|
||||
: null,
|
||||
}));
|
||||
set((s) => {
|
||||
if (!s.currentCall) return {};
|
||||
// WS call_id is the authoritative one; sync system call ID too
|
||||
if (s.currentCall.id !== msg.call_id) {
|
||||
callKeepService.endActiveCall(); // end old system session with wrong ID
|
||||
}
|
||||
return { currentCall: { ...s.currentCall, id: msg.call_id } };
|
||||
});
|
||||
});
|
||||
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
@@ -587,6 +805,8 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { incomingCall } = get();
|
||||
if (!incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType);
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
@@ -597,6 +817,33 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
|
||||
|
||||
const myUserId = getCurrentUserId();
|
||||
const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null;
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId || 'me';
|
||||
|
||||
const participants = new Map<string, CallParticipant>();
|
||||
const now = Date.now();
|
||||
participants.set(myUserId || 'me', {
|
||||
id: myUserId || 'me',
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: now,
|
||||
});
|
||||
participants.set(incomingCall.callerId, {
|
||||
id: incomingCall.callerId,
|
||||
name: incomingCall.callerName || incomingCall.callerId,
|
||||
avatar: incomingCall.callerAvatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: false,
|
||||
joinedAt: 0,
|
||||
});
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: incomingCall.callId,
|
||||
@@ -605,6 +852,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
peerName: incomingCall.callerName,
|
||||
peerAvatar: incomingCall.callerAvatar,
|
||||
callType: incomingCall.callType as CallType,
|
||||
mediaType: incomingCall.callType as CallType,
|
||||
status: 'connecting',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
@@ -613,6 +861,9 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: false,
|
||||
isPeerReady: false,
|
||||
participants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
incomingCall: null,
|
||||
});
|
||||
@@ -623,6 +874,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to accept call:', err);
|
||||
callKeepService.failIncomingCallConnected();
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
},
|
||||
@@ -631,12 +883,15 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { incomingCall } = get();
|
||||
if (!incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
wsService.sendCallReject(incomingCall.callId);
|
||||
callKeepService.endActiveCall();
|
||||
set({ incomingCall: null });
|
||||
},
|
||||
|
||||
@@ -644,6 +899,8 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
|
||||
cleanupResources();
|
||||
|
||||
if (unsubInvited) {
|
||||
@@ -664,6 +921,13 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
console.error('[CallStore] Error disconnecting LiveKit:', err);
|
||||
}
|
||||
|
||||
// Sync end with system CallKit/Telecom UI
|
||||
if (reason === 'ended' || !reason) {
|
||||
callKeepService.endActiveCall();
|
||||
} else {
|
||||
callKeepService.reportCallEndedExternal(reason as any);
|
||||
}
|
||||
|
||||
if (callId && reason !== 'ended') {
|
||||
wsService.sendCallEnd(callId, reason);
|
||||
}
|
||||
@@ -675,6 +939,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
const newMuted = !currentCall.isMuted;
|
||||
liveKitService.setMuted(newMuted);
|
||||
callKeepService.setMuted(newMuted);
|
||||
if (currentCall.id) {
|
||||
wsService.sendCallMute(currentCall.id, newMuted);
|
||||
}
|
||||
@@ -692,6 +957,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
? { ...s.currentCall, isSpeakerOn: newSpeakerOn }
|
||||
: null,
|
||||
}));
|
||||
callKeepService.setAudioOutputToSpeaker(newSpeakerOn);
|
||||
await liveKitService.setSpeakerOn(newSpeakerOn);
|
||||
},
|
||||
|
||||
@@ -713,6 +979,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
try {
|
||||
await liveKitService.setVideoEnabled(enabled);
|
||||
callKeepService.reportVideoEnabled(enabled);
|
||||
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
@@ -724,6 +991,272 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
handleSystemAnswer: async () => {
|
||||
const { incomingCall, currentCall } = get();
|
||||
// If already handling a call from the in-app flow, skip
|
||||
if (currentCall && currentCall.status !== 'idle') return;
|
||||
if (!incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
|
||||
const myUserId = getCurrentUserId();
|
||||
const localUser = myUserId ? useUserStore.getState().userCache[myUserId] : null;
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId || 'me';
|
||||
|
||||
const sysParticipants = new Map<string, CallParticipant>();
|
||||
const sysNow = Date.now();
|
||||
sysParticipants.set(myUserId || 'me', {
|
||||
id: myUserId || 'me',
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: sysNow,
|
||||
});
|
||||
sysParticipants.set(incomingCall.callerId, {
|
||||
id: incomingCall.callerId,
|
||||
name: incomingCall.callerName || incomingCall.callerId,
|
||||
avatar: incomingCall.callerAvatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: false,
|
||||
joinedAt: 0,
|
||||
});
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: incomingCall.callId,
|
||||
conversationId: incomingCall.conversationId,
|
||||
peerId: incomingCall.callerId,
|
||||
peerName: incomingCall.callerName,
|
||||
peerAvatar: incomingCall.callerAvatar,
|
||||
callType: incomingCall.callType as CallType,
|
||||
mediaType: incomingCall.callType as CallType,
|
||||
status: 'connecting',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: isVideoCall,
|
||||
isVideoEnabled: isVideoCall,
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: false,
|
||||
isPeerReady: false,
|
||||
participants: sysParticipants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
incomingCall: null,
|
||||
});
|
||||
|
||||
wsService.sendCallAnswer(incomingCall.callId);
|
||||
|
||||
try {
|
||||
await joinLiveKitRoom(incomingCall.callId, isVideoCall);
|
||||
} catch (err) {
|
||||
console.error('[CallStore] handleSystemAnswer failed:', err);
|
||||
callKeepService.failIncomingCallConnected();
|
||||
get().endCall('connection_failed');
|
||||
}
|
||||
},
|
||||
|
||||
handleIncomingFromPush: (session: SystemCallSession) => {
|
||||
const ie = session.incomingCallEvent;
|
||||
if (!ie) return;
|
||||
|
||||
const { currentCall, incomingCall } = get();
|
||||
if (currentCall || incomingCall) return;
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
const metadata = ie.metadata ?? {};
|
||||
const conversationId = (metadata.conversationId as string) || '';
|
||||
|
||||
set({
|
||||
incomingCall: {
|
||||
callId: ie.serverCallId,
|
||||
conversationId,
|
||||
callerId: ie.caller.id,
|
||||
callerName: ie.caller.displayName || ie.caller.id,
|
||||
callerAvatar: ie.caller.avatarUrl ?? null,
|
||||
callType: ie.hasVideo ? 'video' : 'voice',
|
||||
receivedAt: Date.now(),
|
||||
lifetime: 55000,
|
||||
},
|
||||
});
|
||||
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { incomingCall: ic } = get();
|
||||
if (ic?.callId === ie.serverCallId) {
|
||||
console.log('[CallStore] Incoming push call timeout');
|
||||
wsService.preventDisconnectOnBackground = false;
|
||||
callKeepService.endActiveCall();
|
||||
set({ incomingCall: null });
|
||||
}
|
||||
}, 55000);
|
||||
},
|
||||
|
||||
handleSystemMuted: (muted: boolean) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
liveKitService.setMuted(muted);
|
||||
if (currentCall.id) {
|
||||
wsService.sendCallMute(currentCall.id, muted);
|
||||
}
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall ? { ...s.currentCall, isMuted: muted } : null,
|
||||
}));
|
||||
},
|
||||
|
||||
startGroupCall: async (groupId: string, conversationId: string, callType: CallType) => {
|
||||
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;
|
||||
}
|
||||
|
||||
wsService.preventDisconnectOnBackground = true;
|
||||
|
||||
const localUser = useUserStore.getState().userCache[myUserId];
|
||||
const localName = localUser?.nickname || localUser?.username || myUserId;
|
||||
|
||||
const participants = new Map<string, CallParticipant>();
|
||||
const now = Date.now();
|
||||
participants.set(myUserId, {
|
||||
id: myUserId,
|
||||
name: localName,
|
||||
avatar: null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: true,
|
||||
isReady: true,
|
||||
joinedAt: now,
|
||||
});
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: '',
|
||||
conversationId,
|
||||
groupId,
|
||||
peerId: '',
|
||||
peerName: undefined,
|
||||
peerAvatar: null,
|
||||
callType,
|
||||
mediaType: callType,
|
||||
status: 'calling',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: callType === 'video',
|
||||
isVideoEnabled: callType === 'video',
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: true,
|
||||
isPeerReady: false,
|
||||
participants,
|
||||
activeSpeakerId: null,
|
||||
pinnedParticipantId: null,
|
||||
},
|
||||
});
|
||||
|
||||
wsService.sendCallGroupInvite(groupId, conversationId, callType);
|
||||
|
||||
if (unsubInvited) unsubInvited();
|
||||
unsubInvited = wsService.on('call_invited', (msg: any) => {
|
||||
set((s) => {
|
||||
if (!s.currentCall) return {};
|
||||
return { currentCall: { ...s.currentCall, id: msg.call_id } };
|
||||
});
|
||||
});
|
||||
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { currentCall: cc } = get();
|
||||
if (cc && cc.status === 'calling') {
|
||||
console.warn('[CallStore] Group call timeout');
|
||||
get().endCall('timeout');
|
||||
}
|
||||
}, CALL_TIMEOUT_MS);
|
||||
},
|
||||
|
||||
addParticipant: (userId: string, info: { name: string; avatar?: string | null }) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
const newParticipants = new Map(currentCall.participants);
|
||||
if (!newParticipants.has(userId)) {
|
||||
newParticipants.set(userId, {
|
||||
id: userId,
|
||||
name: info.name || userId,
|
||||
avatar: info.avatar ?? null,
|
||||
isMuted: false,
|
||||
isVideoEnabled: false,
|
||||
isLocal: false,
|
||||
isReady: true,
|
||||
joinedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
const existing = newParticipants.get(userId)!;
|
||||
newParticipants.set(userId, {
|
||||
...existing,
|
||||
isReady: true,
|
||||
joinedAt: existing.joinedAt || Date.now(),
|
||||
});
|
||||
}
|
||||
set({ currentCall: { ...currentCall, participants: newParticipants } });
|
||||
},
|
||||
|
||||
removeParticipant: (userId: string) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
const newParticipants = new Map(currentCall.participants);
|
||||
newParticipants.delete(userId);
|
||||
set({ currentCall: { ...currentCall, participants: newParticipants } });
|
||||
},
|
||||
|
||||
setActiveSpeaker: (userId: string | null) => {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, activeSpeakerId: userId }
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
pinParticipant: (userId: string | null) => {
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, pinnedParticipantId: userId }
|
||||
: null,
|
||||
}));
|
||||
},
|
||||
|
||||
updateParticipantState: (userId: string, updates: Partial<CallParticipant>) => {
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
const p = currentCall.participants.get(userId);
|
||||
if (!p) return;
|
||||
|
||||
const newParticipants = new Map(currentCall.participants);
|
||||
newParticipants.set(userId, { ...p, ...updates });
|
||||
set({ currentCall: { ...currentCall, participants: newParticipants } });
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
cleanupResources();
|
||||
|
||||
@@ -738,6 +1271,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}
|
||||
|
||||
liveKitService.dispose();
|
||||
callKeepService.endActiveCall();
|
||||
|
||||
processedCallIds.clear();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user