feat(CallScreen): enhance video call functionality and UI updates
- Integrated video call support by adding local and remote video stream handling. - Implemented state management for video track detection in both local and peer streams. - Updated UI to conditionally render video components based on the presence of video tracks. - Added video toggle functionality to enable or disable local video during calls. - Enhanced incoming call modal to display call type (voice or video). - Refactored call store to manage call types and video state more effectively.
This commit is contained in:
@@ -12,7 +12,9 @@ import { useAuthStore } from './authStore';
|
||||
import { useUserStore } from './userStore';
|
||||
import { userManager } from './userManager';
|
||||
|
||||
export type CallStatus = 'idle' | 'ringing' | 'connecting' | 'connected' | 'ending';
|
||||
export type CallStatus = 'idle' | 'ringing' | 'connecting' | 'connected' | 'renegotiating' | 'ending';
|
||||
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
export interface CallSession {
|
||||
id: string;
|
||||
@@ -21,10 +23,13 @@ export interface CallSession {
|
||||
peerName?: string;
|
||||
peerAvatar?: string | null;
|
||||
status: CallStatus;
|
||||
callType: CallType;
|
||||
startedAt?: number;
|
||||
duration: number;
|
||||
isMuted: boolean;
|
||||
isSpeakerOn: boolean;
|
||||
isVideoEnabled: boolean;
|
||||
isPeerVideoEnabled: boolean;
|
||||
isInitiator: boolean;
|
||||
}
|
||||
|
||||
@@ -45,13 +50,15 @@ interface CallState {
|
||||
incomingCall: IncomingCallInfo | null;
|
||||
callDuration: number;
|
||||
peerStream: MediaStream | null;
|
||||
localStream: MediaStream | null;
|
||||
isMinimized: boolean;
|
||||
|
||||
initCall: () => () => void;
|
||||
startCall: (
|
||||
conversationId: string,
|
||||
calleeId: string,
|
||||
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }
|
||||
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
|
||||
callType?: CallType
|
||||
) => Promise<void>;
|
||||
acceptCall: () => Promise<void>;
|
||||
rejectCall: () => void;
|
||||
@@ -59,23 +66,26 @@ interface CallState {
|
||||
toggleMute: () => void;
|
||||
toggleSpeaker: () => void;
|
||||
toggleMinimize: () => void;
|
||||
toggleVideo: () => Promise<void>;
|
||||
setVideoEnabled: (enabled: boolean) => Promise<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 pendingOffer: { callId: string; sdp: string } | null = null;
|
||||
let rtcUnsubscribe: (() => void) | null = null; // WebRTC event subscription
|
||||
|
||||
// === Element + Telegram 结合: 常量 ===
|
||||
const CALL_LIFETIME_MS = 55000; // 55秒 (略小于后端60秒)
|
||||
const CALL_TIMEOUT_MS = 115000; // 115秒 (拨打方等待超时,略小于后端120秒)
|
||||
const IGNORE_CALL_ID_TTL = 30000; // 已处理callId保留30秒
|
||||
// Constants
|
||||
const CALL_LIFETIME_MS = 55000;
|
||||
const CALL_TIMEOUT_MS = 115000;
|
||||
const IGNORE_CALL_ID_TTL = 30000;
|
||||
|
||||
// === Element: 已处理的 callId 集合 (防重复) ===
|
||||
const processedCallIds = new Map<string, number>(); // callId -> timestamp
|
||||
// Track processed call IDs to prevent duplicates
|
||||
const processedCallIds = new Map<string, number>();
|
||||
|
||||
// 清理过期的 callId
|
||||
function cleanupProcessedCallIds() {
|
||||
const now = Date.now();
|
||||
for (const [callId, timestamp] of processedCallIds) {
|
||||
@@ -85,11 +95,204 @@ function cleanupProcessedCallIds() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified WebRTC event handler
|
||||
* This is called from both initiator and receiver paths
|
||||
*/
|
||||
function setupWebRTCEvents(callId: string, myUserId: string): void {
|
||||
// Clean up any existing subscription
|
||||
if (rtcUnsubscribe) {
|
||||
rtcUnsubscribe();
|
||||
rtcUnsubscribe = null;
|
||||
}
|
||||
|
||||
rtcUnsubscribe = webrtcManager.onEvent((event) => {
|
||||
switch (event.type) {
|
||||
case 'icecandidate':
|
||||
if (event.candidate) {
|
||||
wsService.sendCallICE(callId, JSON.stringify(event.candidate.toJSON()));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'remotestream':
|
||||
handleRemoteStream(event.stream);
|
||||
break;
|
||||
|
||||
case 'negotiationneeded':
|
||||
handleNegotiationNeeded(callId, event.offer);
|
||||
break;
|
||||
|
||||
case 'connectionstatechange':
|
||||
handleConnectionStateChange(event.state);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('[CallStore] WebRTC error:', event.error);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle remote stream with video track detection
|
||||
*/
|
||||
function handleRemoteStream(stream: MediaStream): void {
|
||||
callStore.setState({ peerStream: stream });
|
||||
|
||||
// Detect video tracks in remote stream
|
||||
const videoTracks = stream.getVideoTracks();
|
||||
const hasPeerVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||
|
||||
console.log('[CallStore] Remote stream received, hasVideo:', hasPeerVideo, 'videoTracks:', videoTracks.length);
|
||||
|
||||
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
|
||||
*/
|
||||
function handleConnectionStateChange(state: string): void {
|
||||
console.log('[CallStore] Connection state changed:', state);
|
||||
|
||||
if (state === 'connected') {
|
||||
const now = Date.now();
|
||||
callStore.setState((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
: null,
|
||||
}));
|
||||
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Only end call if connection failed after being connected
|
||||
// Don't end call during initial connection setup
|
||||
if (state === 'failed') {
|
||||
const { currentCall } = callStore.getState();
|
||||
if (currentCall && currentCall.status === 'connected') {
|
||||
callStore.getState().endCall('connection_lost');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const 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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pendingOffer = null;
|
||||
|
||||
// Set remote description and create answer
|
||||
try {
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type as 'offer' | 'answer',
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
|
||||
const answer = await webrtcManager.createAnswer();
|
||||
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to handle incoming offer:', 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
|
||||
*/
|
||||
function cleanupResources(): void {
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
if (durationTimer) {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
if (rtcUnsubscribe) {
|
||||
rtcUnsubscribe();
|
||||
rtcUnsubscribe = null;
|
||||
}
|
||||
pendingOffer = null;
|
||||
}
|
||||
|
||||
export const callStore = create<CallState>((set, get) => ({
|
||||
currentCall: null,
|
||||
incomingCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
isMinimized: false,
|
||||
|
||||
initCall: () => {
|
||||
@@ -103,10 +306,8 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
unsubs.push(
|
||||
wsService.on('call_incoming', async (msg) => {
|
||||
// === Element: 清理过期的 callId ===
|
||||
cleanupProcessedCallIds();
|
||||
|
||||
// === Element: 检查是否已处理过该 callId ===
|
||||
if (processedCallIds.has(msg.call_id)) {
|
||||
console.log('[CallStore] Ignoring already processed call:', msg.call_id);
|
||||
return;
|
||||
@@ -124,17 +325,17 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// === Element: 检查 lifetime 过期 ===
|
||||
// Check lifetime expiry
|
||||
const callAge = Date.now() - msg.created_at;
|
||||
const lifetime = msg.lifetime || 60000; // 默认60秒
|
||||
if (callAge > lifetime - 5000) { // 留5秒余量
|
||||
console.log('[CallStore] Ignoring stale incoming call, age:', callAge, 'ms, lifetime:', lifetime);
|
||||
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;
|
||||
}
|
||||
|
||||
// Try to get caller info from cache first, then fetch from API
|
||||
// Fetch caller info
|
||||
let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
|
||||
useUserStore.getState().userCache[msg.caller_id];
|
||||
if (!caller) {
|
||||
@@ -167,12 +368,11 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
},
|
||||
});
|
||||
|
||||
// 标记为已处理
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// 设置超时 (使用 lifetime)
|
||||
// Set timeout based on lifetime
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
const remainingTime = Math.max(lifetime - callAge - 1000, 5000); // 剩余时间,最少5秒
|
||||
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { incomingCall: ic } = get();
|
||||
if (ic?.callId === msg.call_id) {
|
||||
@@ -190,40 +390,30 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
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;
|
||||
}
|
||||
|
||||
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||
|
||||
try {
|
||||
const isVideoCall = currentCall.callType === 'video';
|
||||
await webrtcManager.initialize(msg.ice_servers || []);
|
||||
await webrtcManager.createLocalStream(true);
|
||||
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||
|
||||
// Listen for WebRTC events
|
||||
const unsubRTC = webrtcManager.onEvent((event) => {
|
||||
if (event.type === 'icecandidate' && event.candidate) {
|
||||
wsService.sendCallICE(currentCall.id, JSON.stringify(event.candidate.toJSON()));
|
||||
}
|
||||
if (event.type === 'remotestream') {
|
||||
set({ peerStream: event.stream });
|
||||
}
|
||||
if (event.type === 'connectionstatechange') {
|
||||
if (event.state === 'connected') {
|
||||
const now = Date.now();
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
: null,
|
||||
}));
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
set((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
if (event.state === 'disconnected' || event.state === 'failed') {
|
||||
get().endCall('connection_lost');
|
||||
}
|
||||
}
|
||||
});
|
||||
void unsubRTC;
|
||||
set({ localStream: newStream });
|
||||
|
||||
// startCall creates PeerConnection, adds tracks, and creates offer for initiator
|
||||
const offer = await webrtcManager.startCall(true);
|
||||
// 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 || '');
|
||||
}
|
||||
@@ -256,17 +446,14 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
wsService.on('call_ended', (msg) => {
|
||||
const { currentCall, incomingCall } = get();
|
||||
|
||||
// 标记为已处理
|
||||
processedCallIds.set(msg.call_id, Date.now());
|
||||
|
||||
// If this is an active call, end it
|
||||
if (currentCall?.id === msg.call_id) {
|
||||
console.log('[CallStore] Call ended, duration:', msg.duration);
|
||||
get().endCall('ended');
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is an incoming call that was cancelled by caller, clear it
|
||||
if (incomingCall?.callId === msg.call_id) {
|
||||
console.log('[CallStore] Incoming call cancelled by caller');
|
||||
if (callTimeoutTimer) {
|
||||
@@ -278,7 +465,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
// === Telegram: 其他设备已接听 ===
|
||||
unsubs.push(
|
||||
wsService.on('call_answered_elsewhere', (msg) => {
|
||||
const { incomingCall } = get();
|
||||
@@ -294,13 +480,11 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
})
|
||||
);
|
||||
|
||||
// === Telegram: 处理服务端错误 ===
|
||||
unsubs.push(
|
||||
wsService.on('error', (msg: WSErrorMessage) => {
|
||||
const { currentCall, incomingCall } = get();
|
||||
console.log('[CallStore] Server error:', msg.code, msg.message);
|
||||
|
||||
// 处理 callee_offline 错误
|
||||
if (msg.code === 'callee_offline') {
|
||||
if (currentCall && currentCall.status === 'ringing') {
|
||||
console.log('[CallStore] Callee is offline');
|
||||
@@ -308,7 +492,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 call_already_answered 错误
|
||||
if (msg.code === 'call_already_answered') {
|
||||
if (incomingCall) {
|
||||
console.log('[CallStore] Call already answered on another device');
|
||||
@@ -328,50 +511,13 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
// Check that we are not the sender of this SDP message
|
||||
const myUserId = useAuthStore.getState().currentUser?.id;
|
||||
if (myUserId && msg.from_id === myUserId) return;
|
||||
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||
if (msg.from_id === myUserId) return;
|
||||
|
||||
try {
|
||||
const pc = webrtcManager.getPeerConnection();
|
||||
|
||||
if (msg.payload.sdp_type === 'offer') {
|
||||
if (!pc) {
|
||||
// Cache offer for later processing when PeerConnection is ready
|
||||
pendingOffer = { callId: msg.call_id, sdp: msg.payload.sdp };
|
||||
console.log('[CallStore] Caching pending offer, PeerConnection not ready yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// We are the callee - only process if in 'stable' state
|
||||
const signalingState = pc.signalingState;
|
||||
if (signalingState !== 'stable') {
|
||||
console.warn('[CallStore] Ignoring offer, signaling state is', signalingState);
|
||||
return;
|
||||
}
|
||||
pendingOffer = null;
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type,
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
const answer = await webrtcManager.createAnswer();
|
||||
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
|
||||
} else if (msg.payload.sdp_type === 'answer') {
|
||||
if (!pc) return;
|
||||
|
||||
// We are the initiator - only process if in 'have-local-offer' state
|
||||
const signalingState = pc.signalingState;
|
||||
if (signalingState !== 'have-local-offer') {
|
||||
console.warn('[CallStore] Ignoring answer, signaling state is', signalingState);
|
||||
return;
|
||||
}
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: msg.payload.sdp_type,
|
||||
sdp: msg.payload.sdp,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CallStore] call_sdp error:', err);
|
||||
if (msg.payload.sdp_type === 'offer') {
|
||||
await handleIncomingOffer(msg, myUserId);
|
||||
} else if (msg.payload.sdp_type === 'answer') {
|
||||
await handleIncomingAnswer(msg);
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -381,9 +527,8 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { currentCall } = get();
|
||||
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||
|
||||
// Ignore ICE candidates from ourselves
|
||||
const myUserId = useAuthStore.getState().currentUser?.id;
|
||||
if (myUserId && msg.from_id === myUserId) return;
|
||||
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||
if (msg.from_id === myUserId) return;
|
||||
|
||||
try {
|
||||
const candidate = typeof msg.payload.candidate === 'string'
|
||||
@@ -403,8 +548,11 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
);
|
||||
|
||||
const cleanup = () => {
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
cleanupResources();
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
unsubInvited = null;
|
||||
}
|
||||
unsubs.forEach((unsub) => unsub());
|
||||
initCallUnsub = null;
|
||||
};
|
||||
@@ -415,7 +563,8 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
startCall: async (
|
||||
conversationId: string,
|
||||
calleeId: string,
|
||||
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null }
|
||||
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
|
||||
callType: CallType = 'voice'
|
||||
) => {
|
||||
const { currentCall } = get();
|
||||
if (currentCall && currentCall.status !== 'idle') {
|
||||
@@ -429,29 +578,30 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Use provided callee info first, then fall back to userCache
|
||||
const cachedCallee = useUserStore.getState().userCache[calleeId];
|
||||
const callee = calleeInfo || cachedCallee;
|
||||
const calleeName = callee?.nickname || callee?.username || calleeId;
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: '', // Will be filled when call_invited response comes
|
||||
id: '',
|
||||
conversationId,
|
||||
peerId: calleeId,
|
||||
peerName: calleeName,
|
||||
peerAvatar: callee?.avatar,
|
||||
callType,
|
||||
status: 'ringing',
|
||||
duration: 0,
|
||||
isMuted: false,
|
||||
isSpeakerOn: false,
|
||||
isVideoEnabled: callType === 'video',
|
||||
isPeerVideoEnabled: false,
|
||||
isInitiator: true,
|
||||
},
|
||||
});
|
||||
|
||||
wsService.sendCallInvite(conversationId, calleeId);
|
||||
wsService.sendCallInvite(conversationId, calleeId, callType);
|
||||
|
||||
// Listen for call_invited to get the call_id
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
}
|
||||
@@ -463,7 +613,6 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
}));
|
||||
});
|
||||
|
||||
// Timeout - 使用 CALL_TIMEOUT_MS (115秒,略小于后端 120秒)
|
||||
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = setTimeout(() => {
|
||||
const { currentCall: cc } = get();
|
||||
@@ -483,6 +632,9 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
|
||||
const isVideoCall = incomingCall.callType === 'video';
|
||||
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||
|
||||
set({
|
||||
currentCall: {
|
||||
id: incomingCall.callId,
|
||||
@@ -490,10 +642,13 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
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: isVideoCall,
|
||||
isInitiator: false,
|
||||
},
|
||||
incomingCall: null,
|
||||
@@ -503,55 +658,18 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
|
||||
try {
|
||||
await webrtcManager.initialize(incomingCall.iceServers);
|
||||
await webrtcManager.createLocalStream(true);
|
||||
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||
|
||||
// Listen for WebRTC events
|
||||
const unsubRTC = webrtcManager.onEvent((event) => {
|
||||
if (event.type === 'icecandidate' && event.candidate) {
|
||||
wsService.sendCallICE(incomingCall.callId, JSON.stringify(event.candidate.toJSON()));
|
||||
}
|
||||
if (event.type === 'remotestream') {
|
||||
set({ peerStream: event.stream });
|
||||
}
|
||||
if (event.type === 'connectionstatechange') {
|
||||
if (event.state === 'connected') {
|
||||
const now = Date.now();
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||
: null,
|
||||
}));
|
||||
if (durationTimer) clearInterval(durationTimer);
|
||||
durationTimer = setInterval(() => {
|
||||
set((s) => ({ callDuration: s.callDuration + 1 }));
|
||||
}, 1000);
|
||||
}
|
||||
if (event.state === 'disconnected' || event.state === 'failed') {
|
||||
get().endCall('connection_lost');
|
||||
}
|
||||
}
|
||||
});
|
||||
void unsubRTC;
|
||||
set({ localStream: newStream });
|
||||
|
||||
// startCall creates PeerConnection and adds tracks (non-initiator, no offer)
|
||||
await webrtcManager.startCall(false);
|
||||
// Setup unified WebRTC event handler
|
||||
setupWebRTCEvents(incomingCall.callId, myUserId);
|
||||
|
||||
// Process any pending offer that arrived before PeerConnection was ready
|
||||
if (pendingOffer && pendingOffer.callId === incomingCall.callId) {
|
||||
const offerMsg = pendingOffer;
|
||||
pendingOffer = null;
|
||||
console.log('[CallStore] Processing pending offer after PeerConnection ready');
|
||||
try {
|
||||
await webrtcManager.setRemoteDescription({
|
||||
type: 'offer',
|
||||
sdp: offerMsg.sdp,
|
||||
});
|
||||
const answer = await webrtcManager.createAnswer();
|
||||
wsService.sendCallSDP(offerMsg.callId, 'answer', answer.sdp || '');
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to process pending offer:', err);
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to accept call:', err);
|
||||
get().endCall('connection_failed');
|
||||
@@ -575,19 +693,12 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
const { currentCall } = get();
|
||||
if (!currentCall) return;
|
||||
|
||||
if (callTimeoutTimer) {
|
||||
clearTimeout(callTimeoutTimer);
|
||||
callTimeoutTimer = null;
|
||||
}
|
||||
if (durationTimer) {
|
||||
clearInterval(durationTimer);
|
||||
durationTimer = null;
|
||||
}
|
||||
cleanupResources();
|
||||
|
||||
if (unsubInvited) {
|
||||
unsubInvited();
|
||||
unsubInvited = null;
|
||||
}
|
||||
pendingOffer = null;
|
||||
|
||||
const callId = currentCall.id;
|
||||
|
||||
@@ -595,6 +706,7 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
currentCall: null,
|
||||
callDuration: 0,
|
||||
peerStream: null,
|
||||
localStream: null,
|
||||
});
|
||||
|
||||
webrtcManager.dispose();
|
||||
@@ -631,4 +743,35 @@ export const callStore = create<CallState>((set, get) => ({
|
||||
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 {
|
||||
if (enabled) {
|
||||
const newStream = await webrtcManager.enableVideo();
|
||||
set({ localStream: newStream });
|
||||
} else {
|
||||
const newStream = await webrtcManager.disableVideo();
|
||||
set({ localStream: newStream });
|
||||
}
|
||||
|
||||
set((s) => ({
|
||||
currentCall: s.currentCall
|
||||
? { ...s.currentCall, isVideoEnabled: enabled, callType: enabled ? 'video' : 'voice' }
|
||||
: null,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[CallStore] Failed to toggle video:', err);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -48,6 +48,7 @@ export {
|
||||
useHomeTabPressStore,
|
||||
} from './homeTabPressStore';
|
||||
export { callStore } from './callStore';
|
||||
export type { CallType, CallSession, CallStatus } from './callStore';
|
||||
export {
|
||||
useAppColors,
|
||||
useThemePreference,
|
||||
|
||||
Reference in New Issue
Block a user