import { useState, useEffect, useRef, useCallback } from 'react'; import { getSocket } from '../services/socket'; // WebRTC configuration with STUN and TURN servers for NAT traversal const rtcConfig = { iceServers: [ // STUN servers for basic NAT traversal { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' }, // TURN servers for symmetric NAT and strict firewalls (public relay for testing) { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject', }, { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject', }, { urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject', }, ], }; // File chunk size (16KB recommended for WebRTC DataChannel) const CHUNK_SIZE = 16384; /** * Custom hook for managing WebRTC peer-to-peer connections and file transfers * * @param {number} matchId - The match ID for this connection * @param {number} userId - Current user's ID * @returns {Object} WebRTC state and control functions */ export const useWebRTC = (matchId, userId) => { const [connectionState, setConnectionState] = useState('disconnected'); // disconnected, connecting, connected, failed const [transferProgress, setTransferProgress] = useState(0); const [isTransferring, setIsTransferring] = useState(false); const [receivingFile, setReceivingFile] = useState(null); const peerConnectionRef = useRef(null); const dataChannelRef = useRef(null); const socketRef = useRef(null); const matchIdRef = useRef(matchId); const userIdRef = useRef(userId); // Update refs when props change useEffect(() => { matchIdRef.current = matchId; userIdRef.current = userId; }, [matchId, userId]); // File transfer state const fileTransferRef = useRef({ file: null, fileName: '', fileSize: 0, fileType: '', chunks: [], currentChunk: 0, totalChunks: 0, }); // Receiving file state const receivingBufferRef = useRef([]); const receivingMetadataRef = useRef(null); /** * Initialize peer connection */ const initializePeerConnection = useCallback(() => { if (peerConnectionRef.current) { return peerConnectionRef.current; } // Use full config with STUN servers for production const pc = new RTCPeerConnection(rtcConfig); peerConnectionRef.current = pc; console.log('๐Ÿ”ง Using rtcConfig with STUN servers for NAT traversal'); // ICE candidate handler pc.onicecandidate = (event) => { if (event.candidate) { console.log('๐ŸงŠ ICE candidate generated:', event.candidate.type); if (socketRef.current) { socketRef.current.emit('webrtc_ice_candidate', { matchId: matchIdRef.current, candidate: event.candidate, }); console.log('๐Ÿ“ค Sent ICE candidate'); } else { console.error('โŒ Socket not available to send ICE candidate'); } } else { console.log('๐ŸงŠ ICE gathering complete (candidate is null)'); } }; // ICE gathering state handler pc.onicegatheringstatechange = () => { console.log('๐ŸงŠ ICE gathering state:', pc.iceGatheringState); }; // Signaling state handler pc.onsignalingstatechange = () => { console.log('๐Ÿ“ก Signaling state:', pc.signalingState); }; // Connection state change handler pc.onconnectionstatechange = () => { console.log('๐Ÿ”„ Connection state:', pc.connectionState); setConnectionState(pc.connectionState); if (pc.connectionState === 'failed') { console.error('โŒ WebRTC connection failed'); cleanupConnection(); } }; // ICE connection state handler pc.oniceconnectionstatechange = () => { console.log('๐ŸงŠ ICE connection state:', pc.iceConnectionState); }; console.log('โœ… RTCPeerConnection initialized'); console.log('๐Ÿ” Initial states - Connection:', pc.connectionState, 'ICE:', pc.iceConnectionState, 'Signaling:', pc.signalingState); return pc; }, []); /** * Create data channel for file transfer */ const createDataChannel = useCallback((pc) => { const dc = pc.createDataChannel('fileTransfer', { ordered: true, }); setupDataChannelHandlers(dc); dataChannelRef.current = dc; console.log('โœ… DataChannel created'); return dc; }, []); /** * Setup data channel event handlers */ const setupDataChannelHandlers = useCallback((dc) => { dc.onopen = () => { console.log('โœ… DataChannel opened'); setConnectionState('connected'); }; dc.onclose = () => { console.log('โŒ DataChannel closed'); setConnectionState('disconnected'); }; dc.onerror = (error) => { console.error('โŒ DataChannel error:', error); setConnectionState('failed'); }; dc.onmessage = (event) => { handleDataChannelMessage(event.data); }; }, []); /** * Handle incoming data channel messages */ const handleDataChannelMessage = useCallback((data) => { // Check if it's metadata (JSON string) if (typeof data === 'string') { try { const metadata = JSON.parse(data); if (metadata.type === 'file_metadata') { // Receiving file metadata receivingMetadataRef.current = metadata; receivingBufferRef.current = []; setReceivingFile({ name: metadata.fileName, size: metadata.fileSize, type: metadata.fileType, }); setIsTransferring(true); setTransferProgress(0); console.log('๐Ÿ“ฅ Receiving file:', metadata.fileName); } else if (metadata.type === 'file_end') { // File transfer complete const blob = new Blob(receivingBufferRef.current, { type: receivingMetadataRef.current.fileType }); // Trigger download const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = receivingMetadataRef.current.fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); console.log('โœ… File received and downloaded:', receivingMetadataRef.current.fileName); // Reset state setIsTransferring(false); setTransferProgress(0); setReceivingFile(null); receivingBufferRef.current = []; receivingMetadataRef.current = null; } } catch (error) { console.error('Failed to parse metadata:', error); } } else { // Binary data - file chunk receivingBufferRef.current.push(data); const received = receivingBufferRef.current.length; const total = Math.ceil(receivingMetadataRef.current.fileSize / CHUNK_SIZE); const progress = Math.round((received / total) * 100); setTransferProgress(progress); console.log(`๐Ÿ“ฅ Received chunk ${received}/${total} (${progress}%)`); } }, []); /** * Create and send offer to peer */ const createOffer = useCallback(async () => { try { setConnectionState('connecting'); const pc = initializePeerConnection(); createDataChannel(pc); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); console.log('โœ… Local description set (offer). ICE gathering should start now...'); console.log('๐Ÿ” SDP has candidates:', pc.localDescription.sdp.includes('candidate:')); socketRef.current.emit('webrtc_offer', { matchId: matchIdRef.current, offer: pc.localDescription, }); console.log('๐Ÿ“ค Sent WebRTC offer'); } catch (error) { console.error('Failed to create offer:', error); setConnectionState('failed'); } }, [initializePeerConnection, createDataChannel]); /** * Handle incoming offer and create answer */ const handleOffer = useCallback(async (offer) => { try { setConnectionState('connecting'); const pc = initializePeerConnection(); // Setup data channel handler for incoming connections pc.ondatachannel = (event) => { console.log('โœ… DataChannel received'); dataChannelRef.current = event.channel; setupDataChannelHandlers(event.channel); }; await pc.setRemoteDescription(new RTCSessionDescription(offer)); console.log('โœ… Remote description set (offer)'); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); console.log('โœ… Local description set (answer). ICE gathering should start now...'); console.log('๐Ÿ” SDP has candidates:', pc.localDescription.sdp.includes('candidate:')); socketRef.current.emit('webrtc_answer', { matchId: matchIdRef.current, answer: pc.localDescription, }); console.log('๐Ÿ“ค Sent WebRTC answer'); } catch (error) { console.error('Failed to handle offer:', error); setConnectionState('failed'); } }, [initializePeerConnection, setupDataChannelHandlers]); /** * Handle incoming answer */ const handleAnswer = useCallback(async (answer) => { try { const pc = peerConnectionRef.current; if (!pc) { console.error('No peer connection found'); return; } await pc.setRemoteDescription(new RTCSessionDescription(answer)); console.log('โœ… Remote description set (answer). ICE should connect now...'); } catch (error) { console.error('Failed to handle answer:', error); setConnectionState('failed'); } }, []); /** * Handle incoming ICE candidate */ const handleIceCandidate = useCallback(async (candidate) => { try { const pc = peerConnectionRef.current; if (!pc) { console.error('No peer connection found'); return; } await pc.addIceCandidate(new RTCIceCandidate(candidate)); console.log('โœ… Added ICE candidate'); } catch (error) { console.error('Failed to add ICE candidate:', error); } }, []); /** * Send file via data channel */ const sendFile = useCallback(async (file) => { if (!file) { console.error('No file provided'); return; } const dc = dataChannelRef.current; if (!dc || dc.readyState !== 'open') { console.error('DataChannel is not open'); alert('Connection not established. Please wait and try again.'); return; } try { setIsTransferring(true); setTransferProgress(0); // Send file metadata const metadata = { type: 'file_metadata', fileName: file.name, fileSize: file.size, fileType: file.type, }; dc.send(JSON.stringify(metadata)); console.log('๐Ÿ“ค Sent file metadata:', metadata); // Read file and send in chunks const arrayBuffer = await file.arrayBuffer(); const totalChunks = Math.ceil(arrayBuffer.byteLength / CHUNK_SIZE); for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, arrayBuffer.byteLength); const chunk = arrayBuffer.slice(start, end); // Wait if buffer is getting full while (dc.bufferedAmount > CHUNK_SIZE * 10) { await new Promise(resolve => setTimeout(resolve, 10)); } dc.send(chunk); const progress = Math.round(((i + 1) / totalChunks) * 100); setTransferProgress(progress); console.log(`๐Ÿ“ค Sent chunk ${i + 1}/${totalChunks} (${progress}%)`); } // Send end marker dc.send(JSON.stringify({ type: 'file_end' })); console.log('โœ… File transfer complete:', file.name); setIsTransferring(false); setTransferProgress(0); } catch (error) { console.error('Failed to send file:', error); setIsTransferring(false); setTransferProgress(0); alert('Failed to send file: ' + error.message); } }, []); /** * Cleanup connection */ const cleanupConnection = useCallback(() => { if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; } if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; } setConnectionState('disconnected'); setIsTransferring(false); setTransferProgress(0); setReceivingFile(null); receivingBufferRef.current = []; receivingMetadataRef.current = null; console.log('๐Ÿงน WebRTC connection cleaned up'); }, []); /** * Setup Socket.IO event listeners */ useEffect(() => { const socket = getSocket(); if (!socket) { console.error('Socket not available'); return; } socketRef.current = socket; // Create stable handlers using current values from refs const onOffer = ({ from, offer }) => { console.log('๐Ÿ“ฅ Received WebRTC offer from:', from, 'My userId:', userIdRef.current); if (from !== userIdRef.current) { handleOffer(offer); } else { console.log('โญ๏ธ Ignoring offer from self'); } }; const onAnswer = ({ from, answer }) => { console.log('๐Ÿ“ฅ Received WebRTC answer from:', from, 'My userId:', userIdRef.current); if (from !== userIdRef.current) { handleAnswer(answer); } else { console.log('โญ๏ธ Ignoring answer from self'); } }; const onIceCandidate = ({ from, candidate }) => { console.log('๐Ÿ“ฅ Received ICE candidate from:', from); if (from !== userIdRef.current) { handleIceCandidate(candidate); } }; // Attach listeners const attachListeners = () => { socket.off('webrtc_offer', onOffer); socket.off('webrtc_answer', onAnswer); socket.off('webrtc_ice_candidate', onIceCandidate); socket.on('webrtc_offer', onOffer); socket.on('webrtc_answer', onAnswer); socket.on('webrtc_ice_candidate', onIceCandidate); console.log('โœ… WebRTC Socket.IO listeners attached (socketId:', socket.id, ')'); }; // Attach initially attachListeners(); // Reattach on reconnect const onReconnect = () => { console.log('๐Ÿ”„ Socket reconnected - reattaching WebRTC listeners'); attachListeners(); }; socket.on('connect', onReconnect); // Cleanup on unmount only return () => { console.log('๐Ÿงน Removing WebRTC Socket.IO listeners'); socket.off('connect', onReconnect); socket.off('webrtc_offer', onOffer); socket.off('webrtc_answer', onAnswer); socket.off('webrtc_ice_candidate', onIceCandidate); cleanupConnection(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [userId]); // Only re-run if userId changes return { connectionState, isTransferring, transferProgress, receivingFile, createOffer, sendFile, cleanupConnection, }; };