import { useState, useRef, useEffect, useCallback } from 'react'; import { getSocket } from '../lib/socket.ts'; import { FiMic, FiMicOff, FiVideo, FiVideoOff, FiPhone, FiPhoneOff, FiLoader } from 'react-icons/fi'; interface VideoCallProps { roomId: string; userName: string; } interface CallPeer { id: string; stream: MediaStream; name: string; } const FALLBACK_ICE: RTCIceServer[] = [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, ]; // Native WebRTC mesh. Signaling rides the existing Socket.IO connection (the // same transport chat/sync already use successfully), so there's no separate // PeerJS broker to fail. Each pair negotiates one RTCPeerConnection; the peer // with the lower socket id makes the offer (avoids glare). export default function VideoCall({ roomId, userName }: VideoCallProps) { const [peers, setPeers] = useState([]); const [inCall, setInCall] = useState(false); const [connecting, setConnecting] = useState(false); const [callError, setCallError] = useState(''); const [audioMuted, setAudioMuted] = useState(false); const [videoOff, setVideoOff] = useState(false); const myVideoRef = useRef(null); const localStreamRef = useRef(null); const pcsRef = useRef>(new Map()); const peersRef = useRef>(new Map()); const namesRef = useRef>(new Map()); const pendingIceRef = useRef>(new Map()); const iceRef = useRef(FALLBACK_ICE); const socket = getSocket(); const refresh = useCallback(() => setPeers(Array.from(peersRef.current.values())), []); // Pull ICE servers (incl. any env-configured TURN) from the server once. useEffect(() => { fetch('/api/ice') .then((r) => (r.ok ? r.json() : null)) .then((j) => { if (j && Array.isArray(j.iceServers) && j.iceServers.length) iceRef.current = j.iceServers; }) .catch(() => { /* keep STUN fallback */ }); }, []); useEffect(() => { if (!inCall) return; let cancelled = false; setConnecting(true); setCallError(''); const myId = socket.id; const removePeer = (peerId: string) => { const pc = pcsRef.current.get(peerId); if (pc) { try { pc.close(); } catch { /* ignore */ } pcsRef.current.delete(peerId); } peersRef.current.delete(peerId); pendingIceRef.current.delete(peerId); refresh(); }; const createPC = (peerId: string, name: string): RTCPeerConnection => { const existing = pcsRef.current.get(peerId); if (existing) return existing; const pc = new RTCPeerConnection({ iceServers: iceRef.current }); pcsRef.current.set(peerId, pc); namesRef.current.set(peerId, name); const local = localStreamRef.current; if (local) local.getTracks().forEach((t) => pc.addTrack(t, local)); pc.onicecandidate = (e) => { if (e.candidate) socket.emit('signal', { type: 'call-ice', to: peerId, candidate: e.candidate }); }; pc.ontrack = (e) => { const stream = e.streams[0]; if (!stream) return; peersRef.current.set(peerId, { id: peerId, stream, name: namesRef.current.get(peerId) || name }); refresh(); }; pc.onconnectionstatechange = () => { if (pc.connectionState === 'failed') { try { pc.restartIce(); } catch { /* best effort */ } } else if (pc.connectionState === 'closed') removePeer(peerId); }; return pc; }; const maybeOffer = async (peerId: string, name: string) => { if (pcsRef.current.has(peerId)) return; const pc = createPC(peerId, name); if (myId && myId < peerId) { try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); socket.emit('signal', { type: 'call-offer', to: peerId, sdp: pc.localDescription, name: userName }); } catch { /* ignore */ } } // else: the lower-id peer offers; we answer in the call-offer handler. }; const drainIce = async (peerId: string, pc: RTCPeerConnection) => { const q = pendingIceRef.current.get(peerId); if (!q) return; pendingIceRef.current.delete(peerId); for (const c of q) { try { await pc.addIceCandidate(c); } catch { /* ignore */ } } }; const onSignal = async (data: any) => { if (!data || !data.from || data.from === myId) return; const from = data.from; if (data.name) namesRef.current.set(from, data.name); const name = namesRef.current.get(from) || 'Friend'; switch (data.type) { case 'call-join': // Tell the newcomer we're here, then negotiate. socket.emit('signal', { type: 'call-here', to: from, name: userName }); maybeOffer(from, name); break; case 'call-here': maybeOffer(from, name); break; case 'call-offer': { const pc = createPC(from, name); try { await pc.setRemoteDescription(data.sdp); await drainIce(from, pc); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); socket.emit('signal', { type: 'call-answer', to: from, sdp: pc.localDescription, name: userName }); } catch { /* ignore */ } break; } case 'call-answer': { const pc = pcsRef.current.get(from); if (pc) { try { await pc.setRemoteDescription(data.sdp); await drainIce(from, pc); } catch { /* ignore */ } } break; } case 'call-ice': { const pc = pcsRef.current.get(from); if (pc && pc.remoteDescription && pc.remoteDescription.type) { try { await pc.addIceCandidate(data.candidate); } catch { /* ignore */ } } else { const q = pendingIceRef.current.get(from) || []; q.push(data.candidate); pendingIceRef.current.set(from, q); } break; } case 'call-leave': removePeer(from); break; } }; const onUserLeft = (d: any) => { if (d?.userId) removePeer(d.userId); }; navigator.mediaDevices.getUserMedia({ video: true, audio: true }) .then((stream) => { if (cancelled) { stream.getTracks().forEach((t) => t.stop()); return; } localStreamRef.current = stream; setConnecting(false); if (myVideoRef.current) myVideoRef.current.srcObject = stream; socket.on('signal', onSignal); socket.on('user-left', onUserLeft); socket.emit('signal', { type: 'call-join', roomId, name: userName }); }) .catch(() => { if (cancelled) return; setConnecting(false); setCallError('Camera/mic access denied. Grant permission and try again.'); setInCall(false); }); return () => { cancelled = true; socket.off('signal', onSignal); socket.off('user-left', onUserLeft); socket.emit('signal', { type: 'call-leave', roomId }); pcsRef.current.forEach((pc) => { try { pc.close(); } catch { /* ignore */ } }); pcsRef.current.clear(); peersRef.current.clear(); pendingIceRef.current.clear(); namesRef.current.clear(); if (localStreamRef.current) { localStreamRef.current.getTracks().forEach((t) => t.stop()); localStreamRef.current = null; } setPeers([]); setConnecting(false); }; }, [inCall, roomId, socket, userName, refresh]); // Bind the local preview once the call UI has mounted its