import { useState, useEffect, useRef, useCallback } from 'react'; import { getSocket } from '../services/socket'; // WebRTC configuration with STUN servers const rtcConfig = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' }, ], }; // 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); // 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; } const pc = new RTCPeerConnection(rtcConfig); peerConnectionRef.current = pc; // ICE candidate handler pc.onicecandidate = (event) => { if (event.candidate && socketRef.current) { socketRef.current.emit('webrtc_ice_candidate', { matchId, candidate: event.candidate, }); console.log('๐Ÿ“ค Sent ICE candidate'); } }; // 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'); return pc; }, [matchId]); /** * 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); socketRef.current.emit('webrtc_offer', { matchId, offer: pc.localDescription, }); console.log('๐Ÿ“ค Sent WebRTC offer'); } catch (error) { console.error('Failed to create offer:', error); setConnectionState('failed'); } }, [matchId, 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)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); socketRef.current.emit('webrtc_answer', { matchId, answer: pc.localDescription, }); console.log('๐Ÿ“ค Sent WebRTC answer'); } catch (error) { console.error('Failed to handle offer:', error); setConnectionState('failed'); } }, [matchId, 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'); } 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; // Listen for WebRTC signaling events socket.on('webrtc_offer', ({ from, offer }) => { console.log('๐Ÿ“ฅ Received WebRTC offer from:', from); if (from !== userId) { handleOffer(offer); } }); socket.on('webrtc_answer', ({ from, answer }) => { console.log('๐Ÿ“ฅ Received WebRTC answer from:', from); if (from !== userId) { handleAnswer(answer); } }); socket.on('webrtc_ice_candidate', ({ from, candidate }) => { console.log('๐Ÿ“ฅ Received ICE candidate from:', from); if (from !== userId) { handleIceCandidate(candidate); } }); // Cleanup on unmount return () => { socket.off('webrtc_offer'); socket.off('webrtc_answer'); socket.off('webrtc_ice_candidate'); cleanupConnection(); }; }, [userId, handleOffer, handleAnswer, handleIceCandidate, cleanupConnection]); return { connectionState, isTransferring, transferProgress, receivingFile, createOffer, sendFile, cleanupConnection, }; };