import { useState, useEffect, useRef, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Maximize, Minimize, SwitchCamera, Minimize2, Maximize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react'; import { getSocket } from '../lib/socket'; import { api } from '../lib/api'; import { useLang } from '../lib/i18n'; import { playCallRingtone, stopCallRingtone, playUnavailableSound } from '../lib/sounds'; type CallState = 'idle' | 'calling' | 'incoming' | 'connected' | 'ended'; interface CallModalProps { isOpen: boolean; onClose: () => void; targetUser: { id: string; displayName?: string; username: string; avatar?: string | null } | null; callType: 'voice' | 'video'; incoming?: { from: string; offer: RTCSessionDescriptionInit; callType: 'voice' | 'video'; callerInfo?: { displayName?: string; username?: string; avatar?: string | null } | null; } | null; } // ICE servers cache (fetched from server) let cachedIceConfig: RTCConfiguration | null = null; let iceCacheFetchedAt = 0; const ICE_CACHE_TTL = 3600_000; // 1 hour const FALLBACK_ICE: RTCConfiguration = { iceServers: [ { urls: 'stun:stun.yandex.ru:3478' }, { urls: 'stun:stun.sipnet.net:3478' }, { urls: 'stun:stun.l.google.com:19302' }, ], }; async function getIceServers(): Promise { if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) { return cachedIceConfig; } try { const data = await api.getIceServers(); console.log('[WebRTC] Received ICE config:', data); if (data.iceServers && data.iceServers.length > 0) { cachedIceConfig = { iceServers: data.iceServers }; iceCacheFetchedAt = Date.now(); return cachedIceConfig; } } catch (e) { console.warn('Failed to fetch ICE servers, using fallback STUN:', e); } return FALLBACK_ICE; } // Try to get video+audio, falling back to audio-only // If preferDeviceId is given, try that camera first async function getMediaWithCameraFallback( wantVideo: boolean, preferDeviceId?: string ): Promise<{ stream: MediaStream; hasVideo: boolean }> { if (!wantVideo) { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); return { stream, hasVideo: false }; } // 1) If we have a preferred camera, try it first if (preferDeviceId) { try { console.log('[getMedia] Trying preferred camera:', preferDeviceId.slice(0, 12)); const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: { deviceId: { exact: preferDeviceId } }, }); console.log('[getMedia] Preferred camera success'); return { stream, hasVideo: true }; } catch (e) { console.warn('[getMedia] Preferred camera failed:', e); } } // 2) Try default audio+video try { console.log('[getMedia] Requesting audio+video (default camera)...'); const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: true }); console.log('[getMedia] Success —', stream.getVideoTracks().map(t => `${t.label}:${t.readyState}`)); return { stream, hasVideo: true }; } catch (e) { console.warn('[getMedia] audio+video failed:', e); } // 3) Final fallback: audio only console.warn('[getMedia] Camera unavailable, falling back to audio only'); const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); return { stream, hasVideo: false }; } // Find a video sender on the peer connection, even if sender.track is null (recvonly transceiver) function findVideoSender(pc: RTCPeerConnection): RTCRtpSender | undefined { // First try sender with an active video track const withTrack = pc.getSenders().find(s => s.track?.kind === 'video'); if (withTrack) return withTrack; // Fall back to sender from a video transceiver (may have null track) const videoTransceiver = pc.getTransceivers().find( t => t.receiver?.track?.kind === 'video' ); return videoTransceiver?.sender; } export default function CallModal({ isOpen, onClose, targetUser, callType: initialCallType, incoming }: CallModalProps) { const { t } = useLang(); const [callState, setCallState] = useState('idle'); const [callType, setCallType] = useState<'voice' | 'video'>(initialCallType); const [isMuted, setIsMuted] = useState(false); const [isVideoOff, setIsVideoOff] = useState(false); const [isScreenSharing, setIsScreenSharing] = useState(false); const [remoteScreenSharing, setRemoteScreenSharing] = useState(false); const [hasRemoteVideo, setHasRemoteVideo] = useState(false); const hasRemoteVideoRef = useRef(false); const [isFullscreen, setIsFullscreen] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [duration, setDuration] = useState(0); const [cameras, setCameras] = useState([]); const [showCameraMenu, setShowCameraMenu] = useState(false); const [activeCameraId, setActiveCameraId] = useState(''); const [remoteVolume, setRemoteVolume] = useState(1); const [showVolumeSlider, setShowVolumeSlider] = useState(false); const [needsInteraction, setNeedsInteraction] = useState(false); const [noiseSuppression, setNoiseSuppression] = useState(false); const [microphones, setMicrophones] = useState([]); const [activeMicId, setActiveMicId] = useState(''); const [showMicMenu, setShowMicMenu] = useState(false); const peerRef = useRef(null); const localStreamRef = useRef(null); const screenStreamRef = useRef(null); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const remoteAudioRef = useRef(null); const timerRef = useRef>(undefined); const callTimeoutRef = useRef>(undefined); const closeTimeoutRef = useRef>(undefined); const targetUserIdRef = useRef(''); const iceCandidateBufferRef = useRef([]); const callEndedRef = useRef(false); const videoContainerRef = useRef(null); const remoteStreamRef = useRef(null); // Track whether video was active before screen share to decide restoration behavior const hadVideoBeforeScreenShareRef = useRef(false); // 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 (callTimeoutRef.current) clearTimeout(callTimeoutRef.current); if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); stopCallRingtone(); if (localStreamRef.current) { localStreamRef.current.getTracks().forEach(track => track.stop()); localStreamRef.current = null; } if (screenStreamRef.current) { screenStreamRef.current.getTracks().forEach(track => track.stop()); screenStreamRef.current = null; } if (peerRef.current) { peerRef.current.onconnectionstatechange = null; peerRef.current.onicecandidate = null; peerRef.current.ontrack = null; peerRef.current.close(); peerRef.current = null; } iceCandidateBufferRef.current = []; remoteStreamRef.current = null; // 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; setDuration(0); setIsMuted(false); setIsVideoOff(false); setIsScreenSharing(false); setHasRemoteVideo(false); setRemoteScreenSharing(false); setIsFullscreen(false); setIsMinimized(false); setShowCameraMenu(false); setShowVolumeSlider(false); setShowMicMenu(false); setNoiseSuppression(false); }, []); // Schedule a tracked close timeout const scheduleClose = useCallback((delay = 1500) => { if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = setTimeout(() => { closeTimeoutRef.current = undefined; onClose(); }, delay); }, [onClose]); // End call (safe to call multiple times) const endCallSafe = useCallback(() => { if (callEndedRef.current) return; callEndedRef.current = true; const socket = getSocket(); socket?.emit('call_end', { targetUserId: targetUserIdRef.current }); stopCallRingtone(); setCallState('ended'); cleanup(); scheduleClose(); }, [cleanup, scheduleClose]); // Ensure remote stream is bound to video element when it becomes available useEffect(() => { if (callState === 'connected' && remoteVideoRef.current && remoteStreamRef.current) { const video = remoteVideoRef.current as any; const stream = remoteStreamRef.current; const syncAndPlay = async (retryCount = 0) => { if (video._playPromise) return; try { if (video.srcObject !== stream) { console.log('[WebRTC] Sync: Binding stream source'); video.srcObject = stream; } video.muted = false; if (stream.getTracks().length > 0) { video._playPromise = video.play(); await video._playPromise; video._playPromise = null; setNeedsInteraction(false); console.log('[WebRTC] Sync: Playback successful'); } } catch (e: any) { video._playPromise = null; if (e.name === 'AbortError' && retryCount < 10) { console.log(`[WebRTC] Sync: Aborted, retrying (${retryCount + 1})...`); setTimeout(() => syncAndPlay(retryCount + 1), 200); } else if (e.name === 'NotAllowedError') { setNeedsInteraction(true); } } }; syncAndPlay(); } }, [hasRemoteVideo, callState]); // Setup common peer connection event handlers const setupPeerHandlers = useCallback((pc: RTCPeerConnection) => { pc.onicecandidate = (e) => { if (e.candidate) { const socket = getSocket(); socket?.emit('ice_candidate', { targetUserId: targetUserIdRef.current, candidate: e.candidate, }); } }; pc.ontrack = (event) => { const track = event.track; const streams = event.streams; console.log(`[WebRTC] Received track: ${track.kind} state: ${track.readyState} streams: ${streams.length}`); // Ensure we have a stable remote stream object // Ensure we have a stable remote stream object if (!remoteStreamRef.current) { remoteStreamRef.current = streams[0] || new MediaStream(); console.log('[WebRTC] Created new remoteStreamRef.current'); } if (!remoteStreamRef.current.getTracks().includes(track)) { console.log(`[WebRTC] Adding track ${track.kind} to remoteStreamRef`); remoteStreamRef.current.addTrack(track); } else { console.log(`[WebRTC] Track ${track.kind} already exists in remoteStreamRef`); } const stream = remoteStreamRef.current; // Helper to update video UI state const checkVideo = () => { const stream = remoteStreamRef.current; if (!stream) { setHasRemoteVideo(false); return; } const videoTracks = stream.getVideoTracks(); // Be more lenient: if track is 'live', trust it even if it reports 'muted' initially. // Some browsers report muted while the buffer is filling. const hasVideo = videoTracks.length > 0 && videoTracks.some( t => t.readyState === 'live' && t.enabled ); console.log('[checkVideo] remote videoTracks:', videoTracks.map(t => `${t.label} state=${t.readyState} enabled=${t.enabled} muted=${t.muted}` ), '→ hasRemoteVideo:', hasVideo); if (hasVideo !== hasRemoteVideoRef.current) { console.log(`[checkVideo] hasRemoteVideo changed: ${hasRemoteVideoRef.current} -> ${hasVideo}`); hasRemoteVideoRef.current = hasVideo; setHasRemoteVideo(hasVideo); } // Auto-switch UI mode if remote starts sending video or STOPS sending it if (hasVideo && callType === 'voice') { console.log('[checkVideo] Remote started video, switching UI to video mode'); setCallType('video'); } else if (!hasVideo && callType === 'video') { // If we were in video mode but tracks are gone, revert to voice console.log('[checkVideo] Remote stopped video, reverting UI to voice mode'); setCallType('voice'); } }; // Force binding to the SINGLE stable video element // Manage playback via startPlayback helper if (remoteVideoRef.current) { const video = remoteVideoRef.current as any; const startPlayback = async (retryCount = 0) => { if (video._playPromise) { console.log('[WebRTC] Play in progress, skipping startPlayback call'); return; } try { if (video.srcObject !== stream) { video.srcObject = stream; } video.muted = false; console.log('[WebRTC] ontrack: calling play()...'); video._playPromise = video.play(); await video._playPromise; video._playPromise = null; setNeedsInteraction(false); } catch (e: any) { video._playPromise = null; if (e.name === 'AbortError' && retryCount < 10) { console.log(`[WebRTC] ontrack: Aborted, retrying (${retryCount + 1})...`); setTimeout(() => startPlayback(retryCount + 1), 200); } else if (e.name === 'NotAllowedError') { setNeedsInteraction(true); } } }; startPlayback(); } track.onunmute = checkVideo; track.onmute = checkVideo; track.onended = checkVideo; checkVideo(); setTimeout(checkVideo, 500); setTimeout(checkVideo, 2000); }; pc.onconnectionstatechange = () => { console.log('[WebRTC] Connection state:', pc.connectionState); if (callEndedRef.current) return; if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') { endCallSafe(); } }; pc.oniceconnectionstatechange = () => { console.log('[WebRTC] ICE Connection state:', pc.iceConnectionState); }; }, [endCallSafe]); // REMOVED callType dependency to prevent handler reset during call // Start outgoing call const startCall = useCallback(async () => { if (!targetUser) return; targetUserIdRef.current = targetUser.id; callEndedRef.current = false; setCallState('calling'); try { // Security context check (critical for KeenDNS) if (!window.isSecureContext) { alert('Критическая ошибка: Совершение аудио/видео звонков требует безопасного соединения (HTTPS).\n\nЗайдите в настройки KeenDNS и убедитесь, что включено HTTPS (SSL), а в браузере используйте https:// вместо http://'); setCallState('ended'); return; } // Get media (video with camera enumeration fallback) console.log('[startCall] Getting media, wantVideo:', callType === 'video'); const { stream, hasVideo } = await getMediaWithCameraFallback(callType === 'video'); console.log('[startCall] Got media — hasVideo:', hasVideo, 'audioTracks:', stream.getAudioTracks().length, 'videoTracks:', stream.getVideoTracks().length); let effectiveCallType = callType; if (callType === 'video' && !hasVideo) { effectiveCallType = 'voice'; setCallType('voice'); } if (callEndedRef.current) { stream.getTracks().forEach(t => t.stop()); return; } localStreamRef.current = stream; // Track active camera & mic const videoTrackSettings = stream.getVideoTracks()[0]?.getSettings(); if (videoTrackSettings?.deviceId) setActiveCameraId(videoTrackSettings.deviceId); const audioTrackSettings = stream.getAudioTracks()[0]?.getSettings(); if (audioTrackSettings?.deviceId) setActiveMicId(audioTrackSettings.deviceId); refreshCameras(); refreshMicrophones(); const iceConfig = await getIceServers(); const pc = new RTCPeerConnection(iceConfig); peerRef.current = pc; // Add all tracks from the stream stream.getTracks().forEach(track => pc.addTrack(track, stream)); // Always add a video transceiver (even for voice calls) // This ensures we have a video m-line for future camera/screen share, // making renegotiation much more stable across browsers. const hasVideoTransceiver = pc.getTransceivers().some(t => t.receiver?.track?.kind === 'video'); if (!hasVideoTransceiver) { pc.addTransceiver('video', { direction: 'recvonly' }); } setupPeerHandlers(pc); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('call_offer', { targetUserId: targetUser.id, offer: pc.localDescription, callType: effectiveCallType, }); // 15 second no-answer timeout callTimeoutRef.current = setTimeout(async () => { if (callEndedRef.current) return; callEndedRef.current = true; stopCallRingtone(); const s = getSocket(); s?.emit('call_end', { targetUserId: targetUserIdRef.current }); cleanup(); setCallState('ended'); await playUnavailableSound(); onClose(); }, 15000); } catch (err: any) { console.error('Error starting call:', err); if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') { alert(callType === 'video' ? 'Разрешите доступ к камере и микрофону в настройках браузера для совершения звонков' : 'Разрешите доступ к микрофону в настройках браузера для совершения звонков'); } setCallState('ended'); cleanup(); } }, [targetUser, callType, cleanup, onClose, setupPeerHandlers]); // Accept incoming call const acceptCall = useCallback(async () => { if (!incoming) return; targetUserIdRef.current = incoming.from; callEndedRef.current = false; stopCallRingtone(); try { console.log('[acceptCall] Getting media, wantVideo:', incoming.callType === 'video'); const { stream, hasVideo } = await getMediaWithCameraFallback(incoming.callType === 'video'); console.log('[acceptCall] Got media — hasVideo:', hasVideo, 'audioTracks:', stream.getAudioTracks().length, 'videoTracks:', stream.getVideoTracks().length, 'videoEnabled:', stream.getVideoTracks().map(t => t.enabled)); let effectiveCallType = incoming.callType; if (incoming.callType === 'video' && !hasVideo) { effectiveCallType = 'voice'; } if (callEndedRef.current) { stream.getTracks().forEach(t => t.stop()); return; } localStreamRef.current = stream; // Track active camera & mic const videoTrackSettings = stream.getVideoTracks()[0]?.getSettings(); if (videoTrackSettings?.deviceId) setActiveCameraId(videoTrackSettings.deviceId); const audioTrackSettings = stream.getAudioTracks()[0]?.getSettings(); if (audioTrackSettings?.deviceId) setActiveMicId(audioTrackSettings.deviceId); refreshCameras(); refreshMicrophones(); const iceConfig = await getIceServers(); const pc = new RTCPeerConnection(iceConfig); peerRef.current = pc; // Setup handlers first so ontrack is ready when setRemoteDescription fires setupPeerHandlers(pc); // Standard answerer pattern: set remote description FIRST // This creates transceivers from the offer's m-lines await pc.setRemoteDescription(new RTCSessionDescription(incoming.offer)); // Now add local tracks — they reuse the transceivers created from the offer, // changing direction from recvonly to sendrecv stream.getTracks().forEach(track => { pc.addTrack(track, stream); }); // Always ensure we have a video transceiver. // After setRemoteDescription, one usually exists from the offer if it was a video call or if the caller added one. const hasVideoTransceiver = pc.getTransceivers().some( t => t.receiver?.track?.kind === 'video' ); if (!hasVideoTransceiver) { pc.addTransceiver('video', { direction: 'recvonly' }); } console.log('[acceptCall] Transceivers after setup:', pc.getTransceivers().map(t => ({ mid: t.mid, direction: t.direction, senderTrack: t.sender?.track?.kind ?? null, receiverTrack: t.receiver?.track?.kind ?? null, }))); if (callEndedRef.current) { pc.onconnectionstatechange = null; pc.close(); peerRef.current = null; stream.getTracks().forEach(t => t.stop()); localStreamRef.current = null; return; } // Flush buffered ICE candidates for (const candidate of iceCandidateBufferRef.current) { pc.addIceCandidate(new RTCIceCandidate(candidate)).catch(console.error); } iceCandidateBufferRef.current = []; const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); console.log('[acceptCall] Answer created, transceivers:', pc.getTransceivers().map(t => ({ mid: t.mid, direction: t.direction, currentDirection: t.currentDirection, senderTrack: t.sender?.track?.kind ?? null, }))); if (callEndedRef.current) { pc.onconnectionstatechange = null; pc.close(); peerRef.current = null; stream.getTracks().forEach(t => t.stop()); localStreamRef.current = null; return; } const socket = getSocket(); socket?.emit('call_answer', { targetUserId: incoming.from, answer: pc.localDescription, }); setCallType(effectiveCallType); setCallState('connected'); console.log('[acceptCall] Call connected, effectiveCallType:', effectiveCallType); timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000); } catch (err: any) { console.error('Error accepting call:', err); if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') { alert('Разрешите доступ к микрофону в настройках браузера для совершения звонков'); } if (!callEndedRef.current) { setCallState('ended'); cleanup(); } } }, [incoming, cleanup, setupPeerHandlers]); // Decline incoming call const declineCall = useCallback(() => { if (incoming) { const socket = getSocket(); socket?.emit('call_decline', { targetUserId: incoming.from }); } callEndedRef.current = true; stopCallRingtone(); setCallState('ended'); cleanup(); scheduleClose(); }, [incoming, cleanup, scheduleClose]); // Toggle mic const toggleMic = () => { if (localStreamRef.current) { localStreamRef.current.getAudioTracks().forEach((t) => { t.enabled = !t.enabled; }); setIsMuted(!isMuted); } }; // Volume control for remote audio const handleVolumeChange = useCallback((vol: number) => { const v = Math.max(0, Math.min(1, vol)); setRemoteVolume(v); if (remoteAudioRef.current) remoteAudioRef.current.volume = v; }, []); // Noise gate with look-ahead: analyse undelayed signal, gate delayed signal // This prevents clipping word beginnings and rejects short transients (clicks) const applyNoiseGate = useCallback(async () => { const pc = peerRef.current; if (!pc || !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; // dB in voice band to trigger pending const CLOSE_THRESHOLD = -48; // dB hysteresis / transient rejection const CONFIRM_MS = 22; // ms energy must persist to confirm voice (rejects clicks) const HOLD_TIME = 150; // ms keep gate open after speech stops const ATTACK = 0.002; // fast open (gate already decided ahead of time) const RELEASE = 0.02; // smooth close // State machine: 0=closed, 1=pending (waiting to confirm voice), 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) { // closed if (db > OPEN_THRESHOLD) { state = 1; // pending pendingSince = now; } } else if (state === 1) { // pending — wait to confirm sustained energy (voice) vs transient (click) if (db <= CLOSE_THRESHOLD) { state = 0; // energy dropped fast → was a click, stay closed } else if (now - pendingSince >= CONFIRM_MS) { state = 2; // sustained → voice confirmed, open gate holdUntil = now + HOLD_TIME; gainNode.gain.setTargetAtTime(1, ctx.currentTime, ATTACK); } } else { // open 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 in PeerConnection const sender = 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 () => { const pc = peerRef.current; // Restore raw mic track directly to PeerConnection if (pc && localStreamRef.current) { const rawTrack = localStreamRef.current.getAudioTracks()[0]; if (rawTrack) { const sender = 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) => { const pc = peerRef.current; if (!pc) return; 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; // Remove noise gate if active (will re-apply after switch if needed) const wasGated = noiseSuppression; if (wasGated) await removeNoiseGate(); // Replace in local stream if (localStreamRef.current) { localStreamRef.current.getAudioTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); localStreamRef.current.addTrack(newTrack); } // Replace in PeerConnection const sender = pc.getSenders().find(s => s.track?.kind === 'audio'); if (sender) await sender.replaceTrack(newTrack); setActiveMicId(deviceId); // Re-apply noise gate if it was active if (wasGated) await applyNoiseGate(); } catch (err) { console.error('Switch mic failed:', err); } }, [isMuted, noiseSuppression, removeNoiseGate, applyNoiseGate]); // Enumerate cameras const refreshCameras = useCallback(async () => { try { const devices = await navigator.mediaDevices.enumerateDevices(); const videoInputs = devices.filter(d => d.kind === 'videoinput'); setCameras(videoInputs); return videoInputs; } catch (e) { console.warn('Could not enumerate cameras:', e); return []; } }, []); // Open camera menu (await enumeration, then show) const openCameraMenu = useCallback(async () => { if (showCameraMenu) { setShowCameraMenu(false); return; } const cams = await refreshCameras(); if (cams.length > 0) { setShowCameraMenu(true); } }, [showCameraMenu, refreshCameras]); // Switch to a specific camera by deviceId const switchCamera = useCallback(async (deviceId: string) => { const pc = peerRef.current; if (!pc) return; setShowCameraMenu(false); const prevCameraId = activeCameraId; try { // Strategy: try to acquire new camera FIRST without stopping the old one. // If that fails (some devices can't have 2 cameras open), stop old then retry. let newStream: MediaStream | null = null; try { newStream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } }, }); } catch (e1) { console.warn('Could not open new camera while old is active, releasing old first:', e1); // Stop old camera tracks, then retry const currentSender = findVideoSender(pc); if (currentSender?.track) currentSender.track.stop(); if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); } newStream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } }, }); } const newTrack = newStream.getVideoTracks()[0]; if (!newTrack) { console.error('No video track from selected camera'); return; } // Now stop old tracks (if they weren't stopped already in the fallback path above) const currentSender = findVideoSender(pc); if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { if (t !== newTrack) { localStreamRef.current!.removeTrack(t); t.stop(); } }); } if (currentSender?.track && currentSender.track !== newTrack) { currentSender.track.stop(); } // Replace track on sender or add new one if (currentSender) { await currentSender.replaceTrack(newTrack); // If direction was recvonly, change to sendrecv const transceiver = pc.getTransceivers().find(t => t.sender === currentSender); if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { transceiver.direction = 'sendrecv'; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } } else { pc.addTrack(newTrack, localStreamRef.current || newStream); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } // Update local stream if (localStreamRef.current) { localStreamRef.current.addTrack(newTrack); } else { localStreamRef.current = new MediaStream([newTrack]); } setActiveCameraId(deviceId); setIsVideoOff(false); setCallType('video'); } catch (err) { console.error('Switch camera failed:', err); // Try to restore previous camera so video isn't permanently lost try { let restoreStream: MediaStream | undefined; // Try the exact previous camera first if (prevCameraId) { try { restoreStream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: prevCameraId } }, }); } catch { /* previous camera also unavailable */ } } // Fall back to any camera if (!restoreStream) { try { restoreStream = await navigator.mediaDevices.getUserMedia({ video: true }); } catch { /* no camera at all */ } } if (restoreStream) { const restoreTrack = restoreStream.getVideoTracks()[0]; if (restoreTrack) { const sender = findVideoSender(pc); if (sender) { await sender.replaceTrack(restoreTrack); // Ensure direction allows sending + renegotiate const transceiver = pc.getTransceivers().find(t => t.sender === sender); if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { transceiver.direction = 'sendrecv'; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } } if (localStreamRef.current) { localStreamRef.current.addTrack(restoreTrack); } else { localStreamRef.current = restoreStream; } setIsVideoOff(false); } } } catch { /* nothing we can do */ } } }, [activeCameraId]); // Toggle video: enable/disable video track, or get camera if none const toggleVideo = useCallback(async () => { const pc = peerRef.current; if (!pc) return; if (!isVideoOff) { // Turn off: disable video track on sender and revert to voice const sender = findVideoSender(pc); if (sender?.track) { sender.track.enabled = false; } setIsVideoOff(true); setCallType('voice'); const socket = getSocket(); socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice' }); } else { // Turn on: re-enable existing track or get new camera const sender = findVideoSender(pc); if (sender?.track) { sender.track.enabled = true; setIsVideoOff(false); } else { try { const { stream: camStream, hasVideo } = await getMediaWithCameraFallback(true); if (!hasVideo) { console.warn('No camera available'); return; } const videoTrack = camStream.getVideoTracks()[0]; // Find a video sender (may have null track from transceiver) const existingSender = findVideoSender(pc); if (existingSender) { await existingSender.replaceTrack(videoTrack); // If direction was recvonly, change to sendrecv and renegotiate const transceiver = pc.getTransceivers().find(t => t.sender === existingSender); if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { transceiver.direction = 'sendrecv'; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } } else { pc.addTrack(videoTrack, localStreamRef.current || camStream); // Renegotiate since new track added const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } if (localStreamRef.current) { localStreamRef.current.addTrack(videoTrack); } setIsVideoOff(false); setCallType('video'); const socket = getSocket(); socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video' }); } catch (err) { console.error('Could not start camera:', err); } } } }, [isVideoOff]); // Screen sharing const toggleScreenShare = useCallback(async () => { const pc = peerRef.current; if (!pc) return; if (isScreenSharing) { // Stop screen share if (screenStreamRef.current) { screenStreamRef.current.getTracks().forEach(t => t.stop()); screenStreamRef.current = null; } const hadVideo = hadVideoBeforeScreenShareRef.current; if (hadVideo) { // Restore camera — request video only (no audio) to avoid interfering with existing mic let cameraRestored = false; try { const constraints: MediaStreamConstraints = activeCameraId ? { video: { deviceId: { exact: activeCameraId } } } : { video: true }; const camStream = await navigator.mediaDevices.getUserMedia(constraints); const cameraTrack = camStream.getVideoTracks()[0]; if (cameraTrack) { const sender = findVideoSender(pc); if (sender) { await sender.replaceTrack(cameraTrack); } if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); localStreamRef.current.addTrack(cameraTrack); } cameraRestored = true; } } catch (err) { console.error('Error restoring camera:', err); } if (!cameraRestored) { // Null out sender and go recvonly const sender = findVideoSender(pc); if (sender) { await sender.replaceTrack(null); const transceiver = pc.getTransceivers().find(t => t.sender === sender); if (transceiver && transceiver.direction !== 'recvonly') { transceiver.direction = 'recvonly'; try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } catch (e) { console.error('Renegotiation after screen share stop failed:', e); } } } if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); } setIsVideoOff(true); } } else { // Was voice call — don't restore camera, just null out the video sender const sender = findVideoSender(pc); if (sender) { await sender.replaceTrack(null); const transceiver = pc.getTransceivers().find(t => t.sender === sender); if (transceiver && transceiver.direction !== 'recvonly') { transceiver.direction = 'recvonly'; try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } catch (e) { console.error('Renegotiation after screen share stop failed:', e); } } } if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); } setCallType('voice'); setIsVideoOff(false); const socket = getSocket(); socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false }); } setIsScreenSharing(false); } else { // Start screen share — remember current video state hadVideoBeforeScreenShareRef.current = callType === 'video' && !isVideoOff; 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]; // Find video sender and replace track const sender = findVideoSender(pc); if (sender) { await sender.replaceTrack(screenTrack); const transceiver = pc.getTransceivers().find(t => t.sender === sender); if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) { transceiver.direction = 'sendrecv'; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } } else { // No video sender — add track associated with localStream to keep audio on same stream pc.addTrack(screenTrack, localStreamRef.current || screenStream); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: pc.localDescription, }); } screenTrack.onended = async () => { setIsScreenSharing(false); if (screenStreamRef.current) { screenStreamRef.current.getTracks().forEach(t => t.stop()); screenStreamRef.current = null; } const hadVideo = hadVideoBeforeScreenShareRef.current; const snd = peerRef.current ? findVideoSender(peerRef.current) : undefined; if (hadVideo) { // Restore camera — video only, no audio let cameraRestored = false; try { const constraints: MediaStreamConstraints = activeCameraId ? { video: { deviceId: { exact: activeCameraId } } } : { video: true }; const camStream = await navigator.mediaDevices.getUserMedia(constraints); const camTrack = camStream.getVideoTracks()[0]; if (camTrack) { if (snd) await snd.replaceTrack(camTrack); if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); localStreamRef.current.addTrack(camTrack); } cameraRestored = true; } } catch (e) { console.error('Error restoring camera:', e); } if (!cameraRestored && snd) { await snd.replaceTrack(null); const currentPc = peerRef.current; if (currentPc) { const transceiver = currentPc.getTransceivers().find(t => t.sender === snd); if (transceiver && transceiver.direction !== 'recvonly') { transceiver.direction = 'recvonly'; try { const offer = await currentPc.createOffer(); await currentPc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: currentPc.localDescription, }); } catch (e) { console.error('Renegotiation after screen share onended failed:', e); } } } if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); } } } else { // Was voice call — just null out video sender if (snd) { await snd.replaceTrack(null); const currentPc = peerRef.current; if (currentPc) { const transceiver = currentPc.getTransceivers().find(t => t.sender === snd); if (transceiver && transceiver.direction !== 'recvonly') { transceiver.direction = 'recvonly'; try { const offer = await currentPc.createOffer(); await currentPc.setLocalDescription(offer); const socket = getSocket(); socket?.emit('renegotiate', { targetUserId: targetUserIdRef.current, offer: currentPc.localDescription, }); } catch (e) { console.error('Renegotiation after screen share onended failed:', e); } } } } if (localStreamRef.current) { localStreamRef.current.getVideoTracks().forEach(t => { localStreamRef.current!.removeTrack(t); t.stop(); }); } setCallType('voice'); const socket = getSocket(); socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'voice', isScreenSharing: false }); } }; setIsScreenSharing(true); setCallType('video'); const socket = getSocket(); socket?.emit('call_type_changed', { targetUserId: targetUserIdRef.current, callType: 'video', isScreenSharing: true }); } catch (err) { console.error('Error starting screen share:', err); } } }, [isScreenSharing, activeCameraId, callType, isVideoOff]); // Fullscreen toggle const toggleFullscreen = useCallback(async () => { if (!videoContainerRef.current) return; try { if (!document.fullscreenElement) { await videoContainerRef.current.requestFullscreen(); setIsFullscreen(true); } else { await document.exitFullscreen(); setIsFullscreen(false); } } catch (e) { console.error('Fullscreen error:', e); } }, []); useEffect(() => { const onFsChange = () => setIsFullscreen(!!document.fullscreenElement); document.addEventListener('fullscreenchange', onFsChange); return () => document.removeEventListener('fullscreenchange', onFsChange); }, []); // Socket event listeners useEffect(() => { const socket = getSocket(); if (!socket) return; const onCallAnswered = async (data: { from: string; answer: RTCSessionDescriptionInit }) => { if (!peerRef.current || data.from !== targetUserIdRef.current) return; if (callTimeoutRef.current) clearTimeout(callTimeoutRef.current); stopCallRingtone(); console.log('[onCallAnswered] Setting remote description (answer)'); await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.answer)); console.log('[onCallAnswered] Transceivers after answer:', peerRef.current.getTransceivers().map(t => ({ mid: t.mid, direction: t.direction, currentDirection: t.currentDirection, senderTrack: t.sender?.track?.kind ?? null, receiverTrack: t.receiver?.track?.kind ?? null, }))); for (const candidate of iceCandidateBufferRef.current) { peerRef.current?.addIceCandidate(new RTCIceCandidate(candidate)).catch(console.error); } iceCandidateBufferRef.current = []; setCallState('connected'); timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000); }; const onIceCandidate = (data: { from: string; candidate: RTCIceCandidateInit }) => { if (peerRef.current && peerRef.current.remoteDescription) { peerRef.current.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(console.error); } else { iceCandidateBufferRef.current.push(data.candidate); } }; const onCallEnded = (data: { from: string }) => { if (callEndedRef.current) return; if (data.from !== targetUserIdRef.current) return; callEndedRef.current = true; stopCallRingtone(); setCallState('ended'); cleanup(); scheduleClose(); }; const onCallDeclined = (data: { from: string }) => { if (callEndedRef.current) return; if (data.from !== targetUserIdRef.current) return; callEndedRef.current = true; stopCallRingtone(); setCallState('ended'); cleanup(); scheduleClose(); }; const onCallUnavailable = () => { if (callEndedRef.current) return; callEndedRef.current = true; stopCallRingtone(); setCallState('ended'); cleanup(); scheduleClose(); }; // Renegotiation (e.g. when remote adds screen share to audio-only call) const onRenegotiate = async (data: { from: string; offer: RTCSessionDescriptionInit }) => { if (!peerRef.current || data.from !== targetUserIdRef.current) return; try { console.log('[onRenegotiate] Received offer, setting remote description'); await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.offer)); const answer = await peerRef.current.createAnswer(); await peerRef.current.setLocalDescription(answer); socket.emit('renegotiate_answer', { targetUserId: targetUserIdRef.current, answer: peerRef.current.localDescription, }); // Nudge video check after renegotiation setTimeout(() => { console.log('[onRenegotiate] Nudging video check'); // pc.ontrack should have fired by now, calling checkVideo via setHasRemoteVideo // but we can trigger it manually if we find the stream if (remoteStreamRef.current) { const hasVideo = remoteStreamRef.current.getVideoTracks().some(t => t.readyState === 'live'); console.log('[onRenegotiate] Manually found video tracks:', hasVideo); if (hasVideo && !hasRemoteVideoRef.current) { hasRemoteVideoRef.current = true; setHasRemoteVideo(true); setCallType('video'); } } }, 1000); } catch (err) { console.error('Renegotiation error:', err); } }; const onRenegotiateAnswer = async (data: { from: string; answer: RTCSessionDescriptionInit }) => { if (!peerRef.current || data.from !== targetUserIdRef.current) return; try { await peerRef.current.setRemoteDescription(new RTCSessionDescription(data.answer)); } catch (err) { console.error('Renegotiation answer error:', err); } }; const onCallTypeChanged = (data: { from: string; callType: 'voice' | 'video'; isScreenSharing?: boolean }) => { if (data.from !== targetUserIdRef.current) return; console.log('[onCallTypeChanged] New call type:', data.callType, 'isRemoteScreenSharing:', data.isScreenSharing); setCallType(data.callType); setRemoteScreenSharing(!!data.isScreenSharing); // If remote switched to voice, clear their video flag so we collapse the UI if (data.callType === 'voice') { hasRemoteVideoRef.current = false; setHasRemoteVideo(false); } // If remote switched to video, nudge the video element if (data.callType === 'video' || data.callType === 'voice') { setTimeout(() => { if (remoteVideoRef.current && remoteStreamRef.current) { console.log(`[onCallTypeChanged] Nudging playback for ${data.callType}`); remoteVideoRef.current.srcObject = remoteStreamRef.current; remoteVideoRef.current.play().catch(e => { if (e.name === 'NotAllowedError') setNeedsInteraction(true); console.warn('Auto-play failed:', e); }); } }, 1000); } }; socket.on('call_answered', onCallAnswered); socket.on('ice_candidate', onIceCandidate); socket.on('call_ended', onCallEnded); socket.on('call_declined', onCallDeclined); socket.on('call_unavailable', onCallUnavailable); socket.on('renegotiate', onRenegotiate); socket.on('renegotiate_answer', onRenegotiateAnswer); socket.on('call_type_changed', onCallTypeChanged); return () => { socket.off('call_answered', onCallAnswered); socket.off('ice_candidate', onIceCandidate); socket.off('call_ended', onCallEnded); socket.off('call_declined', onCallDeclined); socket.off('call_unavailable', onCallUnavailable); socket.off('renegotiate', onRenegotiate); socket.off('renegotiate_answer', onRenegotiateAnswer); socket.off('call_type_changed', onCallTypeChanged); }; }, [cleanup, scheduleClose]); // Start call on mount (outgoing) useEffect(() => { if (isOpen && !incoming && targetUser && callState === 'idle') { startCall(); } if (isOpen && incoming && callState === 'idle') { targetUserIdRef.current = incoming.from; setCallState('incoming'); setCallType(incoming.callType); playCallRingtone(); } }, [isOpen, incoming, targetUser, callState, startCall]); // Sync local video ref with stream (only when srcObject actually changes) useEffect(() => { if (!localVideoRef.current) return; const desired = isScreenSharing && screenStreamRef.current ? screenStreamRef.current : localStreamRef.current; if (desired && localVideoRef.current.srcObject !== desired) { localVideoRef.current.srcObject = desired; } }); // Sync remote video/audio ref with remote stream useEffect(() => { if (!remoteStreamRef.current) return; const stream = remoteStreamRef.current; const bindAndPlay = async () => { // 1) Bind to video element (primary for video, also handles audio) if (remoteVideoRef.current) { const video = remoteVideoRef.current as any; if (video.srcObject !== stream) { console.log('[useEffect] Binding stream to remoteVideoRef'); video.srcObject = stream; } video.muted = false; video.volume = remoteVolume; if (stream.getTracks().length > 0) { video.play().catch((e: any) => { if (e.name === 'NotAllowedError') setNeedsInteraction(true); }); } } // 2) Bind to audio element (dedicated fallback for sound, especially in voice calls) if (remoteAudioRef.current) { const audio = remoteAudioRef.current; if (audio.srcObject !== stream) { console.log('[useEffect] Binding stream to remoteAudioRef'); audio.srcObject = stream; } audio.muted = false; audio.volume = remoteVolume; if (stream.getAudioTracks().length > 0) { audio.play().catch(() => {}); } } }; bindAndPlay(); }, [hasRemoteVideo, callType, callState, remoteVolume]); // Cleanup on unmount useEffect(() => { return () => { stopCallRingtone(); if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current); cleanup(); }; }, [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')}`; }; const displayName = incoming ? incoming.callerInfo?.displayName || incoming.callerInfo?.username || '...' : targetUser?.displayName || targetUser?.username || '...'; const displayAvatar = incoming ? incoming.callerInfo?.avatar : targetUser?.avatar; const initials = displayName .split(' ') .map((w: string) => w[0]) .join('') .slice(0, 2) .toUpperCase(); if (!isOpen) return null; const showVideoArea = callState === 'connected' && callType === 'video' && (hasRemoteVideo || !isVideoOff || isScreenSharing); const hasLocalVideo = !!( localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing ); return ( {/* Remote audio is handled by the video element below */} {/* === MINIMIZED VIEW === */} {isMinimized && callState === 'connected' ? ( setIsMinimized(false)} > {/* Avatar */}
{displayAvatar ? ( ) : (
{initials}
)}

{displayName}

{formatDuration(duration)}

{/* Quick controls */}
e.stopPropagation()}>
{/* No hidden elements here - they are managed in the main content area */} ) : ( /* === FULL VIEW === */ { setShowCameraMenu(false); setShowVolumeSlider(false); setShowMicMenu(false); }} > {/* Ambient background glow for call modal */}
{/* Mic selector popup */} {showMicMenu && microphones.length > 0 && ( <>
setShowMicMenu(false)} />
{t('selectMicrophone')}
{microphones.map((mic, i) => ( ))}
)} {/* Camera selector popup - rendered outside overflow-hidden container */} {showCameraMenu && cameras.length > 0 && ( <> {/* Backdrop to close on click outside */}
setShowCameraMenu(false)} />
{t('switchCamera')}
{cameras.map((cam, i) => ( ))}
)} {/* Volume slider popup */} {showVolumeSlider && ( <>
setShowVolumeSlider(false)} />
e.stopPropagation()}>
{t('volume')}
handleVolumeChange(parseFloat(e.target.value))} className="w-full h-1.5 rounded-full appearance-none bg-zinc-600 accent-vortex-500 cursor-pointer" /> {Math.round(remoteVolume * 100)}%
)} {/* === Main Content Area === */}
{showVideoArea ? (