import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react'; import { getSocket } from '../lib/socket'; import { api } from '../lib/api'; import { useLang } from '../lib/i18n'; interface ParticipantInfo { id: string; username: string; displayName?: string; avatar?: string | null; } interface PeerState { pc: RTCPeerConnection; remoteStream: MediaStream; hasVideo: boolean; } interface GroupCallModalProps { isOpen: boolean; onClose: () => void; chatId: string; chatName: string; callType: 'voice' | 'video'; } // ICE config reuse let cachedIceConfig: RTCConfiguration | null = null; let iceCacheFetchedAt = 0; const ICE_CACHE_TTL = 3600_000; const FALLBACK_ICE: RTCConfiguration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ], }; async function getIceServers(): Promise { if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) return cachedIceConfig; try { const data = await api.getIceServers(); if (data.iceServers?.length > 0) { cachedIceConfig = { iceServers: data.iceServers }; iceCacheFetchedAt = Date.now(); return cachedIceConfig; } } catch { /* fallback */ } return FALLBACK_ICE; } export default function GroupCallModal({ isOpen, onClose, chatId, chatName, callType: initialCallType }: GroupCallModalProps) { const { t } = useLang(); const [isMuted, setIsMuted] = useState(false); const [isVideoOff, setIsVideoOff] = useState(initialCallType === 'voice'); const [isScreenSharing, setIsScreenSharing] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [duration, setDuration] = useState(0); const [participants, setParticipants] = useState>(new Map()); const [remoteVolume, setRemoteVolume] = useState(1); const [showVolumeSlider, setShowVolumeSlider] = useState(false); const [noiseSuppression, setNoiseSuppression] = useState(false); const [joined, setJoined] = useState(false); const [microphones, setMicrophones] = useState([]); const [activeMicId, setActiveMicId] = useState(''); const [showMicMenu, setShowMicMenu] = useState(false); const localStreamRef = useRef(null); const screenStreamRef = useRef(null); const peersRef = useRef>(new Map()); const localVideoRef = useRef(null); const timerRef = useRef>(undefined); const joinedRef = useRef(false); const remoteAudioRefs = useRef>(new Map()); // Force re-render when peer video state changes const [, forceUpdate] = useState(0); // Noise gate refs const noiseGateCtxRef = useRef(null); const noiseGateGainRef = useRef(null); const noiseGateRafRef = useRef(0); const noiseGateTrackRef = useRef(null); const cleanup = useCallback(() => { if (timerRef.current) clearInterval(timerRef.current); if (localStreamRef.current) { localStreamRef.current.getTracks().forEach(t => t.stop()); localStreamRef.current = null; } if (screenStreamRef.current) { screenStreamRef.current.getTracks().forEach(t => t.stop()); screenStreamRef.current = null; } for (const [, peer] of peersRef.current) { peer.pc.close(); } peersRef.current.clear(); remoteAudioRefs.current.clear(); // Clean up noise gate if (noiseGateRafRef.current) cancelAnimationFrame(noiseGateRafRef.current); if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; } if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; } noiseGateGainRef.current = null; setParticipants(new Map()); setDuration(0); setIsMuted(false); setIsVideoOff(initialCallType === 'voice'); setIsScreenSharing(false); setIsMinimized(false); setJoined(false); joinedRef.current = false; setNoiseSuppression(false); setShowMicMenu(false); }, [initialCallType]); const createPeerConnection = useCallback(async (targetUserId: string, initiator: boolean) => { const iceConfig = await getIceServers(); const pc = new RTCPeerConnection(iceConfig); const remoteStream = new MediaStream(); const peerState: PeerState = { pc, remoteStream, hasVideo: false }; peersRef.current.set(targetUserId, peerState); // Add local tracks if (localStreamRef.current) { localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current!)); } // Always ensure a video m-line exists for future camera/screen share stability const hasVideoTransceiver = pc.getTransceivers().some(t => t.receiver?.track?.kind === 'video'); if (!hasVideoTransceiver) { pc.addTransceiver('video', { direction: 'recvonly' }); } pc.onicecandidate = (e) => { if (e.candidate) { const socket = getSocket(); socket?.emit('group_ice_candidate', { chatId, targetUserId, candidate: e.candidate }); } }; pc.ontrack = (e) => { if (!remoteStream.getTracks().includes(e.track)) { remoteStream.addTrack(e.track); } const checkHasVideo = () => { return remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted); }; peerState.hasVideo = checkHasVideo(); e.track.onunmute = () => { peerState.hasVideo = checkHasVideo(); forceUpdate(n => n + 1); }; e.track.onmute = () => { peerState.hasVideo = checkHasVideo(); forceUpdate(n => n + 1); }; // Play audio through audio element const audioEl = remoteAudioRefs.current.get(targetUserId); if (audioEl && audioEl.srcObject !== remoteStream) { audioEl.srcObject = remoteStream; audioEl.volume = remoteVolume; audioEl.play().catch(() => {}); } forceUpdate(n => n + 1); }; pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { console.warn(`[GroupCall] Peer ${targetUserId} connection ${pc.connectionState}`); } }; if (initiator) { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('group_call_offer', { chatId, targetUserId, offer: pc.localDescription }); } return peerState; }, [chatId, remoteVolume]); const joinCall = useCallback(async () => { if (joinedRef.current) return; joinedRef.current = true; try { const wantVideo = initialCallType === 'video'; const stream = await navigator.mediaDevices.getUserMedia({ audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true }, video: wantVideo, }).catch(async () => { // Fallback to audio only return navigator.mediaDevices.getUserMedia({ audio: { noiseSuppression: true, echoCancellation: true } }); }); localStreamRef.current = stream; if (!stream.getVideoTracks().length) setIsVideoOff(true); const audioSettings = stream.getAudioTracks()[0]?.getSettings(); if (audioSettings?.deviceId) setActiveMicId(audioSettings.deviceId); refreshMicrophones(); const socket = getSocket(); socket?.emit('group_call_join', { chatId, callType: initialCallType }); setJoined(true); timerRef.current = setInterval(() => setDuration(d => d + 1), 1000); } catch (err: any) { console.error('Error joining group call:', err); if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') { alert('Разрешите доступ к микрофону в настройках браузера для совершения звонков'); } joinedRef.current = false; } }, [chatId, initialCallType, t]); const leaveCall = useCallback(() => { const socket = getSocket(); socket?.emit('group_call_leave', { chatId }); cleanup(); onClose(); }, [chatId, cleanup, onClose]); // Toggle mic const toggleMic = useCallback(() => { if (localStreamRef.current) { localStreamRef.current.getAudioTracks().forEach(t => { t.enabled = !t.enabled; }); setIsMuted(m => !m); } }, []); // Toggle video const toggleVideo = useCallback(async () => { if (!isVideoOff) { // Turn off video if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = false; }); } setIsVideoOff(true); } else { // Turn on video if (localStreamRef.current?.getVideoTracks().some(t => t.readyState === 'live')) { localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = true; }); setIsVideoOff(false); } else { try { const camStream = await navigator.mediaDevices.getUserMedia({ video: true }); const videoTrack = camStream.getVideoTracks()[0]; if (videoTrack && localStreamRef.current) { localStreamRef.current.addTrack(videoTrack); // Add to all peer connections for (const [targetUserId, peer] of peersRef.current) { peer.pc.addTrack(videoTrack, localStreamRef.current); const offer = await peer.pc.createOffer(); await peer.pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription }); } setIsVideoOff(false); } } catch { console.warn('Camera unavailable'); } } } }, [isVideoOff]); // Toggle screen share const toggleScreenShare = useCallback(async () => { if (isScreenSharing) { if (screenStreamRef.current) { screenStreamRef.current.getTracks().forEach(t => t.stop()); screenStreamRef.current = null; } // Replace screen track with null on all peers for (const [targetUserId, peer] of peersRef.current) { const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video'); if (sender) { await sender.replaceTrack(null); const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender); if (transceiver) transceiver.direction = 'recvonly'; const offer = await peer.pc.createOffer(); await peer.pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription }); } } setIsScreenSharing(false); } else { try { const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } }, audio: false, }); screenStreamRef.current = screenStream; const screenTrack = screenStream.getVideoTracks()[0]; for (const [targetUserId, peer] of peersRef.current) { const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video'); if (sender) { await sender.replaceTrack(screenTrack); const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender); if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { transceiver.direction = 'sendrecv'; const offer = await peer.pc.createOffer(); await peer.pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription }); } } else { peer.pc.addTrack(screenTrack, localStreamRef.current || screenStream); const offer = await peer.pc.createOffer(); await peer.pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription }); } } screenTrack.onended = () => { setIsScreenSharing(false); if (screenStreamRef.current) { screenStreamRef.current.getTracks().forEach(t => t.stop()); screenStreamRef.current = null; } }; setIsScreenSharing(true); } catch { console.error('Screen share failed'); } } }, [isScreenSharing, chatId]); // Noise gate with look-ahead: analyse undelayed signal, gate delayed signal const applyNoiseGate = useCallback(async () => { if (!localStreamRef.current) return; const rawTrack = localStreamRef.current.getAudioTracks()[0]; if (!rawTrack) return; try { const ctx = new AudioContext(); const source = ctx.createMediaStreamSource(new MediaStream([rawTrack])); // Look-ahead delay: signal is delayed so gate can open before audio arrives const delayNode = ctx.createDelay(0.2); delayNode.delayTime.value = 0.05; // 50ms look-ahead // Analysis path: bandpass on voice fundamentals (undelayed, sees audio early) const analysisHP = ctx.createBiquadFilter(); analysisHP.type = 'highpass'; analysisHP.frequency.value = 100; analysisHP.Q.value = 0.7; const analysisLP = ctx.createBiquadFilter(); analysisLP.type = 'lowpass'; analysisLP.frequency.value = 4000; analysisLP.Q.value = 0.7; const analyser = ctx.createAnalyser(); analyser.fftSize = 2048; analyser.smoothingTimeConstant = 0.3; const gainNode = ctx.createGain(); gainNode.gain.value = 0; const dest = ctx.createMediaStreamDestination(); // Signal path: source → delay → gain → output (full quality, just gated) source.connect(delayNode); delayNode.connect(gainNode); gainNode.connect(dest); // Analysis path (no delay): source → bandpass → analyser source.connect(analysisHP); analysisHP.connect(analysisLP); analysisLP.connect(analyser); const dataArray = new Float32Array(analyser.fftSize); const OPEN_THRESHOLD = -38; const CLOSE_THRESHOLD = -48; const CONFIRM_MS = 22; const HOLD_TIME = 150; const ATTACK = 0.002; const RELEASE = 0.02; // State machine: 0=closed, 1=pending, 2=open let state = 0; let pendingSince = 0; let holdUntil = 0; const check = () => { analyser.getFloatTimeDomainData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i]; const rms = Math.sqrt(sum / dataArray.length); const db = rms > 0 ? 20 * Math.log10(rms) : -100; const now = performance.now(); if (state === 0) { if (db > OPEN_THRESHOLD) { state = 1; pendingSince = now; } } else if (state === 1) { if (db <= CLOSE_THRESHOLD) { state = 0; } else if (now - pendingSince >= CONFIRM_MS) { state = 2; holdUntil = now + HOLD_TIME; gainNode.gain.setTargetAtTime(1, ctx.currentTime, ATTACK); } } else { if (db > CLOSE_THRESHOLD) { holdUntil = now + HOLD_TIME; } if (now >= holdUntil) { state = 0; gainNode.gain.setTargetAtTime(0, ctx.currentTime, RELEASE); } } noiseGateRafRef.current = requestAnimationFrame(check); }; check(); const gatedTrack = dest.stream.getAudioTracks()[0]; gatedTrack.enabled = !isMuted; // Replace on all peers for (const [, peer] of peersRef.current) { const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio'); if (sender) await sender.replaceTrack(gatedTrack); } noiseGateCtxRef.current = ctx; noiseGateGainRef.current = gainNode; noiseGateTrackRef.current = gatedTrack; setNoiseSuppression(true); } catch (err) { console.error('Failed to apply noise gate:', err); } }, [isMuted]); const removeNoiseGate = useCallback(async () => { // Restore raw mic track to all peers if (localStreamRef.current) { const rawTrack = localStreamRef.current.getAudioTracks()[0]; if (rawTrack) { for (const [, peer] of peersRef.current) { const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio'); if (sender) await sender.replaceTrack(rawTrack); } } } if (noiseGateRafRef.current) { cancelAnimationFrame(noiseGateRafRef.current); noiseGateRafRef.current = 0; } if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; } if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; } noiseGateGainRef.current = null; setNoiseSuppression(false); }, []); const toggleNoiseSuppression = useCallback(async () => { if (noiseSuppression) { await removeNoiseGate(); } else { await applyNoiseGate(); } }, [noiseSuppression, applyNoiseGate, removeNoiseGate]); // Enumerate microphones const refreshMicrophones = useCallback(async () => { try { const devices = await navigator.mediaDevices.enumerateDevices(); const audioInputs = devices.filter(d => d.kind === 'audioinput'); setMicrophones(audioInputs); return audioInputs; } catch { return []; } }, []); // Switch microphone const switchMicrophone = useCallback(async (deviceId: string) => { setShowMicMenu(false); try { const newStream = await navigator.mediaDevices.getUserMedia({ audio: { deviceId: { exact: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true }, }); const newTrack = newStream.getAudioTracks()[0]; if (!newTrack) return; newTrack.enabled = !isMuted; const wasGated = noiseSuppression; if (wasGated) await removeNoiseGate(); if (localStreamRef.current) { localStreamRef.current.getAudioTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); localStreamRef.current.addTrack(newTrack); } for (const [, peer] of peersRef.current) { const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio'); if (sender) await sender.replaceTrack(newTrack); } setActiveMicId(deviceId); if (wasGated) await applyNoiseGate(); } catch (err) { console.error('Switch mic failed:', err); } }, [isMuted, noiseSuppression, removeNoiseGate, applyNoiseGate]); // Volume const handleVolumeChange = useCallback((vol: number) => { const v = Math.max(0, Math.min(1, vol)); setRemoteVolume(v); for (const [, el] of remoteAudioRefs.current) { el.volume = v; } }, []); // Socket event handlers useEffect(() => { const socket = getSocket(); if (!socket || !isOpen) return; const onParticipants = async (data: { chatId: string; participants: ParticipantInfo[] }) => { if (data.chatId !== chatId) return; const newMap = new Map(); for (const p of data.participants) { newMap.set(p.id, p); // Create peer connection to each existing participant (we are the initiator) if (!peersRef.current.has(p.id)) { await createPeerConnection(p.id, true); } } setParticipants(prev => { const merged = new Map(prev); for (const [k, v] of newMap) merged.set(k, v); return merged; }); }; const onUserJoined = (data: { chatId: string; userId: string; userInfo: ParticipantInfo }) => { if (data.chatId !== chatId) return; setParticipants(prev => { const next = new Map(prev); next.set(data.userId, data.userInfo); return next; }); // The new joiner will send us an offer — we wait for it }; const onUserLeft = (data: { chatId: string; userId: string }) => { if (data.chatId !== chatId) return; const peer = peersRef.current.get(data.userId); if (peer) { peer.pc.close(); peersRef.current.delete(data.userId); } remoteAudioRefs.current.delete(data.userId); setParticipants(prev => { const next = new Map(prev); next.delete(data.userId); return next; }); forceUpdate(n => n + 1); }; const onOffer = async (data: { chatId: string; from: string; offer: RTCSessionDescriptionInit }) => { if (data.chatId !== chatId) return; let peerState = peersRef.current.get(data.from); if (!peerState) { peerState = await createPeerConnection(data.from, false); } await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.offer)); const answer = await peerState.pc.createAnswer(); await peerState.pc.setLocalDescription(answer); socket.emit('group_call_answer', { chatId, targetUserId: data.from, answer: peerState.pc.localDescription }); }; const onAnswer = async (data: { chatId: string; from: string; answer: RTCSessionDescriptionInit }) => { if (data.chatId !== chatId) return; const peerState = peersRef.current.get(data.from); if (peerState) { await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.answer)); } }; const onIceCandidate = (data: { chatId: string; from: string; candidate: RTCIceCandidateInit }) => { if (data.chatId !== chatId) return; const peerState = peersRef.current.get(data.from); if (peerState?.pc.remoteDescription) { peerState.pc.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(console.error); } }; const onRenegotiate = async (data: { chatId: string; from: string; offer: RTCSessionDescriptionInit }) => { if (data.chatId !== chatId) return; const peerState = peersRef.current.get(data.from); if (!peerState) return; await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.offer)); const answer = await peerState.pc.createAnswer(); await peerState.pc.setLocalDescription(answer); socket.emit('group_call_renegotiate_answer', { chatId, targetUserId: data.from, answer: peerState.pc.localDescription }); }; const onRenegotiateAnswer = async (data: { chatId: string; from: string; answer: RTCSessionDescriptionInit }) => { if (data.chatId !== chatId) return; const peerState = peersRef.current.get(data.from); if (peerState) { await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.answer)); } }; socket.on('group_call_participants', onParticipants); socket.on('group_call_user_joined', onUserJoined); socket.on('group_call_user_left', onUserLeft); socket.on('group_call_offer', onOffer); socket.on('group_call_answer', onAnswer); socket.on('group_ice_candidate', onIceCandidate); socket.on('group_call_renegotiate', onRenegotiate); socket.on('group_call_renegotiate_answer', onRenegotiateAnswer); return () => { socket.off('group_call_participants', onParticipants); socket.off('group_call_user_joined', onUserJoined); socket.off('group_call_user_left', onUserLeft); socket.off('group_call_offer', onOffer); socket.off('group_call_answer', onAnswer); socket.off('group_ice_candidate', onIceCandidate); socket.off('group_call_renegotiate', onRenegotiate); socket.off('group_call_renegotiate_answer', onRenegotiateAnswer); }; }, [isOpen, chatId, createPeerConnection]); // Auto-join on open useEffect(() => { if (isOpen && !joined) { joinCall(); } }, [isOpen, joined, joinCall]); // Sync local video useEffect(() => { if (!localVideoRef.current) return; const desired = isScreenSharing && screenStreamRef.current ? screenStreamRef.current : localStreamRef.current; if (desired && localVideoRef.current.srcObject !== desired) { localVideoRef.current.srcObject = desired; } }); // Cleanup on unmount useEffect(() => { return () => { if (joinedRef.current) { const socket = getSocket(); socket?.emit('group_call_leave', { chatId }); } cleanup(); }; }, [chatId, cleanup]); const formatDuration = (sec: number) => { const m = Math.floor(sec / 60); const s = sec % 60; return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; }; if (!isOpen) return null; const participantList = Array.from(participants.values()); const hasLocalVideo = !!(localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing); // Grid columns based on participant count const totalStreams = participantList.length + 1; // +1 for self const gridCols = totalStreams <= 1 ? 1 : totalStreams <= 4 ? 2 : 3; return ( {/* Hidden audio elements for each remote participant */} {participantList.map(p => (