Files
forkmessager/apps/web/src/components/GroupCallModal.tsx
Халимов Рустам 540ded7009 фикс багов
2026-03-10 23:16:44 +03:00

918 lines
38 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 { 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<RTCConfiguration> {
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<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 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 => 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<string, ParticipantInfo>();
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 (
<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-vortex-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-vortex-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-vortex-400 bg-vortex-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: '60vh', overflowY: 'auto' }}>
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}
>
{/* Self */}
<div className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5">
{hasLocalVideo ? (
<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-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
{t('you')?.charAt(0).toUpperCase() || 'Я'}
</div>
{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">
{t('you')} {isMuted ? '🔇' : ''}
</div>
</div>
{/* Remote participants */}
{participantList.map(p => {
const peer = peersRef.current.get(p.id);
const hasVid = peer?.hasVideo;
const initials = (p.displayName || p.username).split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
return (
<div key={p.id} className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5 cursor-pointer" title={t('rightClickVolume')} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}>
{hasVid ? (
<video
autoPlay
playsInline
muted
ref={el => {
if (el && peer?.remoteStream && el.srcObject !== peer.remoteStream) {
el.srcObject = peer.remoteStream;
}
}}
className="w-full h-full object-contain"
/>
) : (
<div className="flex flex-col items-center">
{p.avatar ? (
<img src={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-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
{initials}
</div>
)}
</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%]">
{p.displayName || p.username}
</div>
</div>
);
})}
</div>
</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}
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
title={isScreenSharing ? t('stopScreenShare') : t('screenShare')}
>
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
</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>
);
}