Files
forkmessager/apps/web/src/modules/calls/presentation/components/GroupCallModal.tsx
T

1052 lines
45 KiB
TypeScript

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 { useChatStore } from '../../../chats/application/chatStore';
import { useAuthStore } from '../../../auth/application/authStore';
import { getSocket } from '../../../../core/infrastructure/socket';
import { getMediaUrl } from '../../../../core/utils/utils';
import { UserApi } from '../../../users/infrastructure/userApi';
import { useLang } from '../../../../core/infrastructure/i18n';
interface ParticipantInfo {
id: string;
username: string;
displayName?: string;
avatar?: string | null;
isSharingScreen?: boolean;
isMuted?: boolean;
isVideoOff?: boolean;
}
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<RTCConfiguration> {
if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) return cachedIceConfig;
try {
const data = await UserApi.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<Map<string, ParticipantInfo>>(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<MediaDeviceInfo[]>([]);
const [activeMicId, setActiveMicId] = useState<string>('');
const [showMicMenu, setShowMicMenu] = useState(false);
const [sharingUserId, setSharingUserId] = useState<string | null>(null);
const [maximizedUserId, setMaximizedUserId] = useState<string | null>(null);
const localStreamRef = useRef<MediaStream | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const peersRef = useRef<Map<string, PeerState>>(new Map());
const localVideoRef = useRef<HTMLVideoElement>(null);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const joinedRef = useRef(false);
const remoteAudioRefs = useRef<Map<string, HTMLAudioElement>>(new Map());
// Force re-render when peer video state changes
const [, forceUpdate] = useState(0);
// Noise gate refs
const noiseGateCtxRef = useRef<AudioContext | null>(null);
const noiseGateGainRef = useRef<GainNode | null>(null);
const noiseGateRafRef = useRef<number>(0);
const noiseGateTrackRef = useRef<MediaStreamTrack | null>(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 => {
// Skip local video if screen sharing is active
if (track.kind === 'video' && screenStreamRef.current) return;
pc.addTrack(track, localStreamRef.current!);
});
}
// Add screen track if screen sharing is active
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(track => {
pc.addTrack(track, screenStreamRef.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; });
const newMuted = !isMuted;
setIsMuted(newMuted);
const socket = getSocket();
socket?.emit('group_call_status', { chatId, isMuted: newMuted, isVideoOff });
}
}, [isMuted, isVideoOff, chatId]);
// Toggle video
const toggleVideo = useCallback(async () => {
let newVideoOff = isVideoOff;
if (!isVideoOff) {
// Turn off video
if (localStreamRef.current) {
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = false; });
}
newVideoOff = true;
setIsVideoOff(true);
} else {
// Turn on video
if (localStreamRef.current?.getVideoTracks().some(t => t.readyState === 'live')) {
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = true; });
newVideoOff = false;
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) {
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
await transceiver.sender.replaceTrack(videoTrack).catch(() => {});
if (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive') {
transceiver.direction = 'sendrecv';
}
} else {
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 });
}
newVideoOff = false;
setIsVideoOff(false);
}
} catch { console.warn('Camera unavailable'); }
}
}
const socket = getSocket();
socket?.emit('group_call_status', { chatId, isMuted, isVideoOff: newVideoOff });
}, [isVideoOff, isMuted, chatId]);
// Stop screensharing helper
const stopScreenShareCore = useCallback(async () => {
if (screenStreamRef.current) {
screenStreamRef.current.getTracks().forEach(t => t.stop());
screenStreamRef.current = null;
}
// Replace screen track with camera track on all peers
for (const [targetUserId, peer] of peersRef.current) {
const transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
const camTrack = localStreamRef.current?.getVideoTracks().find(t => t.enabled);
await transceiver.sender.replaceTrack(camTrack || null).catch(() => {});
if (!camTrack) {
transceiver.direction = 'recvonly';
} else {
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 });
}
}
setMaximizedUserId(null);
setIsScreenSharing(false);
// Check if we actually have an active camera track
const hasLiveCam = !!localStreamRef.current?.getVideoTracks().some(t => t.enabled);
setIsVideoOff(!hasLiveCam);
const socket = getSocket();
socket?.emit('screen_share_stopped', chatId);
socket?.emit('group_call_status', { chatId, isMuted, isVideoOff: !hasLiveCam });
}, [chatId, isMuted]);
// Toggle screen share
const toggleScreenShare = useCallback(async () => {
if (isScreenSharing) {
await stopScreenShareCore();
} 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 transceiver = peer.pc.getTransceivers().find(t => t.receiver?.track?.kind === 'video' || t.sender?.track?.kind === 'video');
if (transceiver) {
await transceiver.sender.replaceTrack(screenTrack).catch(() => {});
if (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive') {
transceiver.direction = 'sendrecv';
}
} 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 = () => {
stopScreenShareCore(); // use the helper to avoid stale state issues!
};
setMaximizedUserId('self');
setIsScreenSharing(true);
setIsVideoOff(true);
const socket = getSocket();
socket?.emit('screen_share_started', chatId);
} 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<string, ParticipantInfo>();
for (const p of data.participants) {
if (p.id === useAuthStore.getState().user?.id) continue;
newMap.set(p.id, p);
if (p.isSharingScreen && p.id !== useAuthStore.getState().user?.id) {
setSharingUserId(p.id);
}
// 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 || data.userId === useAuthStore.getState().user?.id) 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));
}
};
const onScreenShareStarted = (data: { chatId: string; userId: string }) => {
if (data.chatId !== chatId) return;
const isMe = data.userId === useAuthStore.getState().user?.id;
if (!isMe) {
setSharingUserId(data.userId);
setMaximizedUserId(data.userId);
}
setParticipants(prev => {
const next = new Map(prev);
const p = next.get(data.userId);
if (p) next.set(data.userId, { ...p, isSharingScreen: true });
return next;
});
};
const onScreenShareStopped = (data: { chatId: string; userId: string }) => {
if (data.chatId !== chatId) return;
const isMe = data.userId === useAuthStore.getState().user?.id;
if (!isMe && sharingUserId === data.userId) {
setSharingUserId(null);
} else if (isMe) {
// Just in case
}
// If the person who stopped was the sharing user, clear it
setSharingUserId(current => current === data.userId ? null : current);
setMaximizedUserId(current => current === data.userId ? null : current);
setParticipants(prev => {
const next = new Map(prev);
const p = next.get(data.userId);
if (p) next.set(data.userId, { ...p, isSharingScreen: false });
return next;
});
};
const onStatusUpdated = (data: { chatId: string; userId: string; isMuted: boolean; isVideoOff: boolean }) => {
if (data.chatId !== chatId) return;
setParticipants(prev => {
const next = new Map(prev);
const p = next.get(data.userId);
if (p) next.set(data.userId, { ...p, isMuted: data.isMuted, isVideoOff: data.isVideoOff });
return next;
});
};
const onRequestStatus = (data: { chatId: string; requestedBy: string }) => {
if (data.chatId !== chatId) return;
socket.emit('group_call_status', { chatId, isMuted, isVideoOff });
};
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);
socket.on('screen_share_started', onScreenShareStarted);
socket.on('screen_share_stopped', onScreenShareStopped);
socket.on('group_call_status_updated', onStatusUpdated);
socket.on('group_call_request_status', onRequestStatus);
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);
socket.off('screen_share_started', onScreenShareStarted);
socket.off('screen_share_stopped', onScreenShareStopped);
socket.off('group_call_status_updated', onStatusUpdated);
socket.off('group_call_request_status', onRequestStatus);
};
}, [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 (
<AnimatePresence>
{/* Hidden audio elements for each remote participant */}
{participantList.map(p => (
<audio
key={`audio-${p.id}`}
ref={el => { if (el) remoteAudioRefs.current.set(p.id, el); }}
autoPlay
playsInline
/>
))}
{isMinimized && joined ? (
<motion.div
key="group-call-minimized"
initial={{ opacity: 0, y: 50, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 50, scale: 0.8 }}
className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-2xl glass-strong shadow-2xl shadow-black/50 border border-white/10 cursor-pointer select-none"
onClick={() => setIsMinimized(false)}
>
<div className="relative">
<div className="absolute inset-0 rounded-full bg-emerald-500/30 animate-call-wave" />
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-sm">
{participantList.length + 1}
</div>
</div>
<div className="min-w-0">
<p className="text-sm text-white font-medium truncate max-w-[120px]">{chatName}</p>
<p className="text-xs text-zinc-400 font-mono">{formatDuration(duration)}</p>
</div>
<div className="flex items-center gap-2 ml-2" onClick={e => e.stopPropagation()}>
<button
onClick={toggleMic}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isMuted ? <MicOff size={14} /> : <Mic size={14} />}
</button>
<button
onClick={leaveCall}
className="w-8 h-8 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors"
>
<PhoneOff size={14} />
</button>
</div>
</motion.div>
) : (
<motion.div
key="group-call-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center bg-surface/90 backdrop-blur-xl overflow-hidden"
onClick={() => setShowVolumeSlider(false)}
>
<div className="absolute inset-0 pointer-events-none opacity-40">
<div className="absolute top-[10%] left-[20%] w-[50vh] h-[50vh] bg-emerald-500/30 rounded-full blur-[120px] animate-float" />
<div className="absolute bottom-[10%] right-[20%] w-[50vh] h-[50vh] bg-knot-500/20 rounded-full blur-[120px] animate-float-delayed" />
</div>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="relative w-full max-w-5xl mx-4 rounded-[2.5rem] glass-strong shadow-2xl shadow-black/50 overflow-hidden border border-white/5"
>
{/* Volume slider popup */}
{showVolumeSlider && (
<>
<div className="fixed inset-0 z-[200]" onClick={() => setShowVolumeSlider(false)} />
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[250px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl p-4" onClick={e => e.stopPropagation()}>
<div className="text-xs text-zinc-400 uppercase tracking-wider mb-3">{t('volume')}</div>
<div className="flex items-center gap-3">
<Volume2 size={16} className="text-zinc-400 shrink-0" />
<input
type="range"
min="0"
max="1"
step="0.05"
value={remoteVolume}
onChange={e => handleVolumeChange(parseFloat(e.target.value))}
className="w-full h-1.5 rounded-full appearance-none bg-zinc-600 accent-knot-500 cursor-pointer"
/>
<span className="text-xs text-zinc-300 w-8 text-right">{Math.round(remoteVolume * 100)}%</span>
</div>
</div>
</>
)}
{/* Mic selector popup */}
{showMicMenu && microphones.length > 0 && (
<>
<div className="fixed inset-0 z-[200]" onClick={() => setShowMicMenu(false)} />
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[320px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl py-2">
<div className="px-3 py-1.5 text-xs text-zinc-400 uppercase tracking-wider border-b border-zinc-700 mb-1">{t('selectMicrophone')}</div>
{microphones.map((mic, i) => (
<button
key={mic.deviceId}
onClick={() => switchMicrophone(mic.deviceId)}
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${activeMicId === mic.deviceId
? 'text-knot-400 bg-knot-500/20 font-medium'
: 'text-zinc-200 hover:bg-zinc-700'
}`}
>
{mic.label || `${t('microphone')} ${i + 1}`}
</button>
))}
</div>
</>
)}
{/* Header */}
<div className="flex items-center justify-between px-8 py-4 border-b border-white/5">
<div>
<h3 className="text-lg font-bold text-white">{chatName}</h3>
<p className="text-xs text-zinc-400">{participantList.length + 1} {t('participants') || 'участников'} · {formatDuration(duration)}</p>
</div>
<button
onClick={() => setIsMinimized(true)}
className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/70 hover:text-white transition-colors"
title={t('minimize')}
>
<Minimize2 size={16} />
</button>
</div>
{/* Participant grid */}
<div className="p-4" style={{ minHeight: '300px', maxHeight: '65vh', overflowY: 'auto' }}>
{(() => {
const allParticipants = [
{ id: 'self', isSelf: true, displayName: t('you'), isMuted, isVideoOff, hasVideo: hasLocalVideo },
...participantList.map(p => ({ ...p, isSelf: false, hasVideo: peersRef.current.get(p.id)?.hasVideo, peer: peersRef.current.get(p.id) }))
];
const mainId = maximizedUserId;
const theMaximized = mainId ? allParticipants.find(p => p.id === mainId) : null;
const renderParticipantBlock = (p: any, isMaximized = false) => {
const initials = p.isSelf
? (t('you')?.charAt(0).toUpperCase() || 'Я')
: (p.displayName || p.username).split(' ').map((w: string) => w[0]).join('').slice(0, 2).toUpperCase();
return (
<div
key={p.id}
className={`relative bg-zinc-900 rounded-2xl overflow-hidden flex items-center justify-center border border-white/5 cursor-pointer flex-shrink-0 transition-all ${isMaximized ? 'w-full h-[55vh] aspect-auto' : 'aspect-video'}`}
onClick={() => setMaximizedUserId(p.id === maximizedUserId ? null : p.id)}
onContextMenu={(e) => { if (!p.isSelf) { e.preventDefault(); setShowVolumeSlider(true); } }}
title={p.isSelf ? '' : t('rightClickVolume') as string}
style={!isMaximized && theMaximized ? { width: '200px', height: '112px' } : {}}
>
{p.isSelf ? (
p.hasVideo ? <video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" /> : <div className="flex flex-col items-center"><div className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
) : (
p.hasVideo && (!p.isVideoOff || p.isSharingScreen) ? <video id={`video-${p.id}`} autoPlay playsInline muted ref={el => { if (el && p.peer?.remoteStream && el.srcObject !== p.peer.remoteStream) el.srcObject = p.peer.remoteStream; }} className="w-full h-full object-contain" /> : <div className="flex flex-col items-center">{p.avatar ? <img src={getMediaUrl(p.avatar)} alt="" className="w-16 h-16 rounded-full object-cover mb-2" /> : <div className="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-xl mb-2">{initials}</div>}{p.isMuted && <MicOff size={14} className="text-red-400" />}</div>
)}
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%] flex items-center gap-1">
{p.isSelf ? t('you') : (p.displayName || p.username)} {p.isMuted ? <MicOff size={10} className="text-red-400" /> : ''}
</div>
{isMaximized && p.hasVideo && (!p.isVideoOff || p.isSelf) && (
<button
onClick={(e) => {
e.stopPropagation();
const v = p.isSelf ? localVideoRef.current : document.getElementById(`video-${p.id}`);
if (v && v.requestFullscreen) v.requestFullscreen();
}}
className="absolute top-2 right-2 w-8 h-8 rounded-lg bg-black/60 hover:bg-black/80 flex items-center justify-center text-white/80 hover:text-white transition-colors"
title="Развернуть на весь экран"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/></svg>
</button>
)}
</div>
);
};
if (theMaximized) {
const carouselParticipants = allParticipants.filter(p => p.id !== mainId);
return (
<div className="flex flex-col gap-3 h-full">
{renderParticipantBlock(theMaximized, true)}
{carouselParticipants.length > 0 && (
<div className="flex gap-3 overflow-x-auto pb-2 custom-scrollbar">
{carouselParticipants.map(p => renderParticipantBlock(p, false))}
</div>
)}
</div>
);
}
return (
<div className="grid gap-3" style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}>
{allParticipants.map(p => renderParticipantBlock(p, false))}
</div>
);
})()}
</div>
{/* Screen share indicator */}
{sharingUserId && (
<div className="absolute top-6 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 px-3 py-1.5 rounded-full bg-blue-500/20 border border-blue-500/30 backdrop-blur-md">
<Monitor size={14} className="text-blue-400 animate-pulse" />
<span className="text-xs font-medium text-blue-100">
{participants.get(sharingUserId)?.displayName || 'Кто-то'} транслирует экран
</span>
</div>
)}
{/* Controls */}
<div className="px-8 pb-8 pt-4 flex items-center justify-center gap-4 flex-wrap">
{/* Mic with dropdown */}
<div className="relative">
<button
onClick={toggleMic}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
title={isMuted ? t('unmute') : t('mute')}
>
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
</button>
<button
onClick={async (e) => { e.stopPropagation(); await refreshMicrophones(); setShowMicMenu(!showMicMenu); setShowVolumeSlider(false); }}
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-zinc-700 hover:bg-zinc-600 flex items-center justify-center text-white/70 hover:text-white transition-colors border border-zinc-600"
>
<ChevronUp size={10} />
</button>
</div>
{/* Camera toggle */}
<button
onClick={toggleVideo}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
>
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
</button>
<button
onClick={toggleScreenShare}
disabled={!!sharingUserId && !isScreenSharing}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-all ${isScreenSharing
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/40'
: 'bg-white/10 text-white hover:bg-white/15'
} ${!!sharingUserId && !isScreenSharing ? 'opacity-30 cursor-not-allowed' : ''}`}
title={sharingUserId ? 'Кто-то уже транслирует экран' : ''}
>
{isScreenSharing ? <MonitorOff size={20} /> : <Monitor size={20} />}
</button>
<button
onClick={e => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
title={t('volume')}
>
<Volume2 size={18} />
</button>
<button
onClick={toggleNoiseSuppression}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${noiseSuppression ? 'bg-emerald-500/20 text-emerald-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
title={noiseSuppression ? t('noiseSuppressionOn') : t('noiseSuppressionOff')}
>
{noiseSuppression ? <ShieldCheck size={18} /> : <ShieldOff size={18} />}
</button>
<button
onClick={leaveCall}
className="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white shadow-xl shadow-red-500/30 transition-all hover:scale-105 ml-2"
>
<PhoneOff size={22} />
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}