diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4df7220 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Frontend - Vite Allowed Hosts +# Comma-separated list of allowed hostnames +# Use 'all' to allow all hosts (NOT recommended for production) +VITE_ALLOWED_HOSTS=localhost,spotlight.cam,.spotlight.cam + +# Alternative: Allow all hosts (development only) +# VITE_ALLOWED_HOSTS=all diff --git a/QUICK_TEST.md b/QUICK_TEST.md new file mode 100644 index 0000000..c9924c5 --- /dev/null +++ b/QUICK_TEST.md @@ -0,0 +1,71 @@ +# Quick WebRTC Test Checklist + +## Setup (2 browser windows) + +**Window 1:** Login as `john@example.com` / `Dance123!` +**Window 2:** Login as `sarah@example.com` / `Swing456!` + +## Test Steps + +### 1. Create Match +- [ ] User A: Go to event β Request match with User B +- [ ] User B: Accept match +- [ ] Both: Navigate to match chat + +### 2. Establish WebRTC Connection +- [ ] User A: Click "Send video (WebRTC)" +- [ ] User A: Select a small video file (~5-10MB) +- [ ] User A: Click "Send video (P2P)" +- [ ] Both: Status shows "Connecting..." β "Connected (P2P)" β + +### 3. File Transfer +- [ ] User A: See progress bar 0% β 100% +- [ ] User B: See "π₯ Receiving: [filename]" +- [ ] User B: File downloads automatically when complete +- [ ] Both: Chat message appears: "πΉ Video sent: [filename]" + +## Console Logs to Check (F12) + +**User A:** +``` +π€ Sent WebRTC offer +π€ Sent ICE candidate +β DataChannel opened +π€ Sent file metadata +π€ Sent chunk 1/X +β File transfer complete +``` + +**User B:** +``` +π₯ Received WebRTC offer +β DataChannel received +π₯ Receiving file +π₯ Received chunk 1/X +β File received and downloaded +``` + +## Success Criteria + +β Connection state: "Connected (P2P)" with green dot +β Transfer completes: 100% on both sides +β File downloads on receiver side +β File size matches original +β No errors in console + +## If Something Fails + +1. Check both users are in same match chat +2. Check Socket.IO is connected (message input not disabled) +3. Check browser console for errors +4. Try refreshing both windows +5. See WEBRTC_TESTING_GUIDE.md for detailed troubleshooting + +## Start Testing + +```bash +docker compose up --build +# Then open http://localhost:8080 in two browser windows +``` + +π **Ready to test!** diff --git a/WEBRTC_TESTING_GUIDE.md b/WEBRTC_TESTING_GUIDE.md new file mode 100644 index 0000000..d787030 --- /dev/null +++ b/WEBRTC_TESTING_GUIDE.md @@ -0,0 +1,300 @@ +# WebRTC P2P File Transfer - Testing Guide + +## Prerequisites + +You need **TWO browser windows/tabs** or **TWO different devices** to test P2P file transfer: +- User A (sender) +- User B (receiver) + +## Test Setup + +### 1. Start the application + +```bash +docker compose up --build +``` + +### 2. Login as two different users + +**Window/Tab 1 - User A:** +- Email: `john@example.com` +- Password: `Dance123!` + +**Window/Tab 2 - User B:** +- Email: `sarah@example.com` +- Password: `Swing456!` + +## Test Scenarios + +### Test 1: Create Match & Accept + +**User A (john_dancer):** +1. Go to Events page +2. Click on "Warsaw Dance Festival 2025" +3. In event chat, click on a user (e.g., sarah_swings) +4. Click "Request Match" +5. Wait for acceptance + +**User B (sarah_swings):** +1. You should receive a notification about match request +2. Go to Matches page (or click notification) +3. Accept the match request +4. Click on the match to open private chat + +**Both users should now see:** +- Private match chat room +- WebRTC status: "Ready to connect" +- "Send video (WebRTC)" button + +--- + +### Test 2: WebRTC Connection Establishment + +**User A (initiator):** +1. In match chat, click "Send video (WebRTC)" button +2. Select a video file (any size, but start with small ~5-10MB for testing) +3. Click "Send video (P2P)" button in the popup + +**Expected behavior:** +- User A: WebRTC status changes to "Connecting..." +- User A: Creates WebRTC offer and sends via Socket.IO +- User B: Receives offer and creates answer automatically +- Both: Exchange ICE candidates +- Both: WebRTC status changes to "Connected (P2P)" with green indicator +- Both: See "π E2E Encrypted (DTLS)" in status bar + +**Console logs to check (F12 Developer Tools):** +``` +User A: +π€ Sent WebRTC offer +π€ Sent ICE candidate (multiple times) +β DataChannel opened +β RTCPeerConnection initialized + +User B: +π₯ Received WebRTC offer from: [userId] +π€ Sent WebRTC answer +π₯ Received ICE candidate from: [userId] +β DataChannel received +β DataChannel opened +``` + +--- + +### Test 3: P2P File Transfer + +**User A (sender):** +1. After connection established, file transfer should start automatically +2. Watch progress bar (0% β 100%) +3. See console logs showing chunks being sent + +**User B (receiver):** +1. See "π₯ Receiving: [filename]" in status bar +2. Watch progress bar (0% β 100%) +3. File should **automatically download** when complete +4. Check Downloads folder for the file + +**Console logs:** +``` +User A (sender): +π€ Sent file metadata: {fileName: "...", fileSize: ..., fileType: "video/..."} +π€ Sent chunk 1/X (Y%) +π€ Sent chunk 2/X (Y%) +... +β File transfer complete: [filename] + +User B (receiver): +π₯ Receiving file: [filename] +π₯ Received chunk 1/X (Y%) +π₯ Received chunk 2/X (Y%) +... +β File received and downloaded: [filename] +``` + +**Expected behavior:** +- Progress updates in real-time for both users +- Transfer speed depends on connection (typically 1-5 MB/s on local network) +- After completion: + - User A: Progress resets, selected file cleared + - User B: File automatically downloads + - Chat message appears: "πΉ Video sent: [filename] ([size] MB)" + +--- + +### Test 4: Test Different File Sizes + +Try transferring: +- β Small video (~5-10 MB) - should take 5-15 seconds +- β Medium video (~50-100 MB) - should take 1-2 minutes +- β Large video (~500 MB) - should take 5-10 minutes + +**Note:** WebRTC DataChannel is reliable and will retry failed chunks automatically. + +--- + +### Test 5: Connection Recovery + +**Test scenario:** What happens if connection drops during transfer? + +1. Start a file transfer +2. During transfer, close User B's browser tab +3. Reopen User B's tab and login again +4. Go back to the match chat + +**Expected behavior:** +- Transfer fails on User A's side +- User needs to manually restart the transfer +- Connection can be re-established by clicking "Send video (WebRTC)" again + +--- + +### Test 6: Multiple Files + +**Test scenario:** Send multiple files in the same session + +1. Send first file (wait for completion) +2. Select and send second file +3. Repeat + +**Expected behavior:** +- Each file transfer works independently +- Connection stays open after first transfer (if both users stay in chat) +- Subsequent transfers are faster (no reconnection needed) + +--- + +## Troubleshooting + +### Problem: "Connection timeout" error + +**Possible causes:** +- Network firewall blocking WebRTC +- No STUN/TURN server reachable +- Both users behind symmetric NAT + +**Solution:** +- Check browser console for errors +- Try on different network +- Ensure both users are on same local network (easier for testing) + +### Problem: Connection stays "Connecting..." forever + +**Check:** +1. Both users are in the same match chat +2. Socket.IO is connected (check "Write a message..." field - should not be disabled) +3. Browser console for WebRTC errors +4. Try refreshing both browser windows + +### Problem: File doesn't download on receiver side + +**Check:** +1. Browser's download permissions +2. Console for errors +3. File might be blocked by popup blocker + +### Problem: "DataChannel is not open" error + +**Solution:** +- Wait for connection to show "Connected (P2P)" before clicking "Send video (P2P)" +- If connection fails, try refreshing and reconnecting + +--- + +## Network Requirements + +### Local Network Testing (Recommended for first test) +- Both users on same WiFi/LAN +- No special configuration needed +- STUN servers will find local network path + +### Internet Testing (Different networks) +- STUN servers help with most NAT types +- May fail with symmetric NAT on both sides +- Consider adding TURN server for production (relay fallback) + +--- + +## Success Indicators + +β **Connection successful if:** +- Status shows "Connected (P2P)" with green dot +- Console shows "DataChannel opened" +- No errors in console + +β **Transfer successful if:** +- Progress bar reaches 100% on both sides +- Receiver gets automatic download +- Chat message appears with file info +- File size matches original + +--- + +## Performance Metrics + +**Expected performance on local network:** +- Connection time: 2-5 seconds +- Transfer speed: 5-20 MB/s +- Chunk size: 16 KB +- Overhead: Minimal (<1% for metadata) + +**Expected performance over internet:** +- Connection time: 3-10 seconds +- Transfer speed: 1-10 MB/s (depends on uplink/downlink) +- More ICE candidates exchanged + +--- + +## Browser Compatibility + +β **Tested browsers:** +- Chrome 90+ (recommended) +- Firefox 88+ +- Edge 90+ +- Safari 14+ (may have limitations) + +β **Not supported:** +- Internet Explorer +- Very old browser versions + +--- + +## Advanced: Monitor WebRTC Stats + +Open browser console and run: + +```javascript +// Get peer connection stats (paste in console during active connection) +const pc = window.peerConnectionRef; // You'd need to expose this for debugging +if (pc) { + pc.getStats().then(stats => { + stats.forEach(stat => console.log(stat)); + }); +} +``` + +Look for: +- `candidate-pair` - shows selected ICE candidate pair +- `data-channel` - shows bytes sent/received +- `transport` - shows DTLS state + +--- + +## Next Steps After Testing + +If everything works: +1. β Mark WebRTC implementation as complete +2. Consider adding TURN server for production +3. Add UI improvements (connection retry button, transfer history) +4. Add file type validation (video only) +5. Add file size limits +6. Add analytics/telemetry for WebRTC success rate + +If issues found: +1. Document the exact error messages +2. Check network environment (NAT type, firewall) +3. Consider TURN server for problematic networks +4. Add more error handling and user feedback + +--- + +**Happy Testing! π** diff --git a/backend/src/socket/index.js b/backend/src/socket/index.js index 12c3d90..6c418f7 100644 --- a/backend/src/socket/index.js +++ b/backend/src/socket/index.js @@ -328,6 +328,104 @@ function initializeSocket(httpServer) { } }); + // WebRTC Signaling Events + + // Send WebRTC offer + socket.on('webrtc_offer', async ({ matchId, offer }) => { + try { + const roomName = `match_${matchId}`; + + // Verify user is part of this match + const match = await prisma.match.findUnique({ + where: { id: parseInt(matchId) }, + select: { user1Id: true, user2Id: true }, + }); + + if (!match) { + return socket.emit('error', { message: 'Match not found' }); + } + + if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) { + return socket.emit('error', { message: 'Not authorized for this match' }); + } + + // Forward offer to the other user in the match room + socket.to(roomName).emit('webrtc_offer', { + from: socket.user.id, + offer, + }); + + console.log(`π‘ WebRTC offer sent in match ${matchId} from ${socket.user.username}`); + } catch (error) { + console.error('WebRTC offer error:', error); + socket.emit('error', { message: 'Failed to send WebRTC offer' }); + } + }); + + // Send WebRTC answer + socket.on('webrtc_answer', async ({ matchId, answer }) => { + try { + const roomName = `match_${matchId}`; + + // Verify user is part of this match + const match = await prisma.match.findUnique({ + where: { id: parseInt(matchId) }, + select: { user1Id: true, user2Id: true }, + }); + + if (!match) { + return socket.emit('error', { message: 'Match not found' }); + } + + if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) { + return socket.emit('error', { message: 'Not authorized for this match' }); + } + + // Forward answer to the other user in the match room + socket.to(roomName).emit('webrtc_answer', { + from: socket.user.id, + answer, + }); + + console.log(`π‘ WebRTC answer sent in match ${matchId} from ${socket.user.username}`); + } catch (error) { + console.error('WebRTC answer error:', error); + socket.emit('error', { message: 'Failed to send WebRTC answer' }); + } + }); + + // Send ICE candidate + socket.on('webrtc_ice_candidate', async ({ matchId, candidate }) => { + try { + const roomName = `match_${matchId}`; + + // Verify user is part of this match + const match = await prisma.match.findUnique({ + where: { id: parseInt(matchId) }, + select: { user1Id: true, user2Id: true }, + }); + + if (!match) { + return socket.emit('error', { message: 'Match not found' }); + } + + if (match.user1Id !== socket.user.id && match.user2Id !== socket.user.id) { + return socket.emit('error', { message: 'Not authorized for this match' }); + } + + // Forward ICE candidate to the other user in the match room + socket.to(roomName).emit('webrtc_ice_candidate', { + from: socket.user.id, + candidate, + }); + + console.log(`π§ ICE candidate sent in match ${matchId} from ${socket.user.username}`); + } catch (error) { + console.error('ICE candidate error:', error); + socket.emit('error', { message: 'Failed to send ICE candidate' }); + } + }); + // Handle disconnection socket.on('disconnect', () => { console.log(`β User disconnected: ${socket.user.username} (${socket.id})`); diff --git a/docker-compose.yml b/docker-compose.yml index 75bfc24..ac1b6f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,6 +59,7 @@ services: environment: - NODE_ENV=development - VITE_HOST=0.0.0.0 + - VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-localhost,spotlight.cam,.spotlight.cam} stdin_open: true tty: true command: npm run dev diff --git a/frontend/src/hooks/useWebRTC.js b/frontend/src/hooks/useWebRTC.js new file mode 100644 index 0000000..5dd34aa --- /dev/null +++ b/frontend/src/hooks/useWebRTC.js @@ -0,0 +1,428 @@ +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, + }; +}; diff --git a/frontend/src/pages/MatchChatPage.jsx b/frontend/src/pages/MatchChatPage.jsx index 7c2a2b0..2c7c650 100644 --- a/frontend/src/pages/MatchChatPage.jsx +++ b/frontend/src/pages/MatchChatPage.jsx @@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext'; import { matchesAPI } from '../services/api'; import { Send, Video, Upload, X, Check, Link as LinkIcon, Loader2 } from 'lucide-react'; import { connectSocket, getSocket } from '../services/socket'; +import { useWebRTC } from '../hooks/useWebRTC'; const MatchChatPage = () => { const { slug } = useParams(); @@ -15,15 +16,23 @@ const MatchChatPage = () => { const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [selectedFile, setSelectedFile] = useState(null); - const [isTransferring, setIsTransferring] = useState(false); - const [transferProgress, setTransferProgress] = useState(0); - const [webrtcStatus, setWebrtcStatus] = useState('disconnected'); // disconnected, connecting, connected, failed const [showLinkInput, setShowLinkInput] = useState(false); const [videoLink, setVideoLink] = useState(''); const [isConnected, setIsConnected] = useState(false); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); + // WebRTC hook + const { + connectionState, + isTransferring, + transferProgress, + receivingFile, + createOffer, + sendFile, + cleanupConnection, + } = useWebRTC(match?.id, user?.id); + // Fetch match data useEffect(() => { const loadMatch = async () => { @@ -128,61 +137,60 @@ const MatchChatPage = () => { if (file && file.type.startsWith('video/')) { setSelectedFile(file); } else { - alert('ProszΔ wybraΔ plik wideo'); + alert('Please select a video file'); } }; - const simulateWebRTCConnection = () => { - setWebrtcStatus('connecting'); - setTimeout(() => { - setWebrtcStatus('connected'); - }, 1500); - }; - - const handleStartTransfer = () => { + const handleStartTransfer = async () => { if (!selectedFile) return; - // Simulate WebRTC connection - simulateWebRTCConnection(); + // If not connected, initiate connection first + if (connectionState !== 'connected') { + console.log('Creating WebRTC offer...'); + await createOffer(); - setTimeout(() => { - setIsTransferring(true); - setTransferProgress(0); - - // Simulate transfer progress - const interval = setInterval(() => { - setTransferProgress((prev) => { - if (prev >= 100) { - clearInterval(interval); - setIsTransferring(false); - setSelectedFile(null); - setWebrtcStatus('disconnected'); - - // Add message about completed transfer - const message = { - id: messages.length + 1, - room_id: 10, - user_id: user.id, - username: user.username, - content: `πΉ Video sent: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)} MB)`, - type: 'video', - created_at: new Date().toISOString(), - }; - setMessages((prev) => [...prev, message]); - - return 0; + // Wait for connection + const waitForConnection = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Connection timeout')), 30000); + const checkConnection = setInterval(() => { + if (connectionState === 'connected') { + clearInterval(checkConnection); + clearTimeout(timeout); + resolve(); + } else if (connectionState === 'failed') { + clearInterval(checkConnection); + clearTimeout(timeout); + reject(new Error('Connection failed')); } - return prev + 5; - }); - }, 200); - }, 2000); + }, 100); + }); + + try { + await waitForConnection; + } catch (error) { + alert('Failed to establish connection: ' + error.message); + return; + } + } + + // Send file + await sendFile(selectedFile); + + // Add message about completed transfer + const socket = getSocket(); + if (socket && socket.connected) { + socket.emit('send_match_message', { + matchId: match.id, + content: `πΉ Video sent: ${selectedFile.name} (${(selectedFile.size / 1024 / 1024).toFixed(2)} MB)`, + }); + } + + setSelectedFile(null); }; const handleCancelTransfer = () => { - setIsTransferring(false); - setTransferProgress(0); setSelectedFile(null); - setWebrtcStatus('disconnected'); + cleanupConnection(); }; const handleSendLink = (e) => { @@ -209,7 +217,7 @@ const MatchChatPage = () => { }; const getWebRTCStatusColor = () => { - switch (webrtcStatus) { + switch (connectionState) { case 'connected': return 'text-green-600'; case 'connecting': @@ -222,7 +230,7 @@ const MatchChatPage = () => { }; const getWebRTCStatusText = () => { - switch (webrtcStatus) { + switch (connectionState) { case 'connected': return 'Connected (P2P)'; case 'connecting': @@ -230,7 +238,7 @@ const MatchChatPage = () => { case 'failed': return 'Connection failed'; default: - return 'Disconnected'; + return 'Ready to connect'; } }; @@ -289,14 +297,21 @@ const MatchChatPage = () => { {/* WebRTC Status Bar */}
- π WebRTC P2P Functionality Mockup: In the full version, videos will be transferred directly - between users via RTCDataChannel, with chunking and progress monitoring. - The server is only used for SDP/ICE exchange (signaling). +
+ π WebRTC P2P File Transfer Active! Videos are transferred directly between users via + RTCDataChannel with 16KB chunking and real-time progress monitoring. The server is only used for + SDP/ICE exchange (signaling). Connection is end-to-end encrypted (DTLS).