feat: implement WebRTC P2P file transfer with DataChannel

Add complete WebRTC peer-to-peer file transfer functionality:

Backend changes:
- Add WebRTC signaling events to Socket.IO (offer, answer, ICE candidates)
- Implement authorization checks for match participants
- Add signaling relay between matched users

Frontend changes:
- Create useWebRTC hook for RTCPeerConnection management
- Implement RTCDataChannel with 16KB chunking for large files
- Add real-time progress monitoring for sender and receiver
- Implement automatic file download on receiver side
- Add connection state tracking and error handling
- Integrate WebRTC with MatchChatPage (replace mockup)

Configuration:
- Add Vite allowed hosts configuration via VITE_ALLOWED_HOSTS env var
- Support comma-separated host list or 'all' for development
- Add .env.example with configuration examples
- Update docker-compose.yml with default allowed hosts

Documentation:
- Add comprehensive WebRTC testing guide with troubleshooting
- Add quick test checklist for manual testing
- Document WebRTC flow, requirements, and success criteria

Features:
- End-to-end encrypted P2P transfer (DTLS)
- 16KB chunk size optimized for DataChannel
- Buffer management to prevent overflow
- Automatic connection establishment with 30s timeout
- Support for files of any size
- Real-time progress tracking
- Clean connection lifecycle management
This commit is contained in:
Radosław Gierwiało
2025-11-15 14:12:51 +01:00
parent 6948efeef9
commit 664a2865b9
8 changed files with 998 additions and 59 deletions

7
.env.example Normal file
View File

@@ -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

71
QUICK_TEST.md Normal file
View File

@@ -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!**

300
WEBRTC_TESTING_GUIDE.md Normal file
View File

@@ -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! 🚀**

View File

@@ -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 // Handle disconnection
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log(`❌ User disconnected: ${socket.user.username} (${socket.id})`); console.log(`❌ User disconnected: ${socket.user.username} (${socket.id})`);

View File

@@ -59,6 +59,7 @@ services:
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- VITE_HOST=0.0.0.0 - VITE_HOST=0.0.0.0
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS:-localhost,spotlight.cam,.spotlight.cam}
stdin_open: true stdin_open: true
tty: true tty: true
command: npm run dev command: npm run dev

View File

@@ -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,
};
};

View File

@@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext';
import { matchesAPI } from '../services/api'; import { matchesAPI } from '../services/api';
import { Send, Video, Upload, X, Check, Link as LinkIcon, Loader2 } from 'lucide-react'; import { Send, Video, Upload, X, Check, Link as LinkIcon, Loader2 } from 'lucide-react';
import { connectSocket, getSocket } from '../services/socket'; import { connectSocket, getSocket } from '../services/socket';
import { useWebRTC } from '../hooks/useWebRTC';
const MatchChatPage = () => { const MatchChatPage = () => {
const { slug } = useParams(); const { slug } = useParams();
@@ -15,15 +16,23 @@ const MatchChatPage = () => {
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const [selectedFile, setSelectedFile] = useState(null); 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 [showLinkInput, setShowLinkInput] = useState(false);
const [videoLink, setVideoLink] = useState(''); const [videoLink, setVideoLink] = useState('');
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
// WebRTC hook
const {
connectionState,
isTransferring,
transferProgress,
receivingFile,
createOffer,
sendFile,
cleanupConnection,
} = useWebRTC(match?.id, user?.id);
// Fetch match data // Fetch match data
useEffect(() => { useEffect(() => {
const loadMatch = async () => { const loadMatch = async () => {
@@ -128,61 +137,60 @@ const MatchChatPage = () => {
if (file && file.type.startsWith('video/')) { if (file && file.type.startsWith('video/')) {
setSelectedFile(file); setSelectedFile(file);
} else { } else {
alert('Proszę wybrać plik wideo'); alert('Please select a video file');
} }
}; };
const simulateWebRTCConnection = () => { const handleStartTransfer = async () => {
setWebrtcStatus('connecting');
setTimeout(() => {
setWebrtcStatus('connected');
}, 1500);
};
const handleStartTransfer = () => {
if (!selectedFile) return; if (!selectedFile) return;
// Simulate WebRTC connection // If not connected, initiate connection first
simulateWebRTCConnection(); if (connectionState !== 'connected') {
console.log('Creating WebRTC offer...');
await createOffer();
setTimeout(() => { // Wait for connection
setIsTransferring(true); const waitForConnection = new Promise((resolve, reject) => {
setTransferProgress(0); const timeout = setTimeout(() => reject(new Error('Connection timeout')), 30000);
const checkConnection = setInterval(() => {
// Simulate transfer progress if (connectionState === 'connected') {
const interval = setInterval(() => { clearInterval(checkConnection);
setTransferProgress((prev) => { clearTimeout(timeout);
if (prev >= 100) { resolve();
clearInterval(interval); } else if (connectionState === 'failed') {
setIsTransferring(false); clearInterval(checkConnection);
setSelectedFile(null); clearTimeout(timeout);
setWebrtcStatus('disconnected'); reject(new Error('Connection failed'));
// 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;
} }
return prev + 5; }, 100);
}); });
}, 200);
}, 2000); 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 = () => { const handleCancelTransfer = () => {
setIsTransferring(false);
setTransferProgress(0);
setSelectedFile(null); setSelectedFile(null);
setWebrtcStatus('disconnected'); cleanupConnection();
}; };
const handleSendLink = (e) => { const handleSendLink = (e) => {
@@ -209,7 +217,7 @@ const MatchChatPage = () => {
}; };
const getWebRTCStatusColor = () => { const getWebRTCStatusColor = () => {
switch (webrtcStatus) { switch (connectionState) {
case 'connected': case 'connected':
return 'text-green-600'; return 'text-green-600';
case 'connecting': case 'connecting':
@@ -222,7 +230,7 @@ const MatchChatPage = () => {
}; };
const getWebRTCStatusText = () => { const getWebRTCStatusText = () => {
switch (webrtcStatus) { switch (connectionState) {
case 'connected': case 'connected':
return 'Connected (P2P)'; return 'Connected (P2P)';
case 'connecting': case 'connecting':
@@ -230,7 +238,7 @@ const MatchChatPage = () => {
case 'failed': case 'failed':
return 'Connection failed'; return 'Connection failed';
default: default:
return 'Disconnected'; return 'Ready to connect';
} }
}; };
@@ -289,14 +297,21 @@ const MatchChatPage = () => {
{/* WebRTC Status Bar */} {/* WebRTC Status Bar */}
<div className="bg-gray-50 border-b px-4 py-2 flex items-center justify-between"> <div className="bg-gray-50 border-b px-4 py-2 flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${webrtcStatus === 'connected' ? 'bg-green-500' : 'bg-gray-300'}`} /> <div className={`w-2 h-2 rounded-full ${connectionState === 'connected' ? 'bg-green-500' : 'bg-gray-300'}`} />
<span className={`text-sm font-medium ${getWebRTCStatusColor()}`}> <span className={`text-sm font-medium ${getWebRTCStatusColor()}`}>
{getWebRTCStatusText()} {getWebRTCStatusText()}
</span> </span>
</div> </div>
<span className="text-xs text-gray-500"> <div className="flex items-center space-x-4">
{webrtcStatus === 'connected' ? '🔒 E2E Encrypted (DTLS/SRTP)' : 'WebRTC ready to connect'} {receivingFile && (
</span> <span className="text-xs text-blue-600 font-medium">
📥 Receiving: {receivingFile.name}
</span>
)}
<span className="text-xs text-gray-500">
{connectionState === 'connected' ? '🔒 E2E Encrypted (DTLS)' : 'WebRTC P2P Ready'}
</span>
</div>
</div> </div>
<div className="flex flex-col h-[calc(100vh-320px)]"> <div className="flex flex-col h-[calc(100vh-320px)]">
@@ -495,11 +510,11 @@ const MatchChatPage = () => {
</div> </div>
{/* Info Box */} {/* Info Box */}
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg"> <div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-sm text-blue-800"> <p className="text-sm text-green-800">
<strong>🚀 WebRTC P2P Functionality Mockup:</strong> In the full version, videos will be transferred directly <strong>🚀 WebRTC P2P File Transfer Active!</strong> Videos are transferred directly between users via
between users via RTCDataChannel, with chunking and progress monitoring. RTCDataChannel with 16KB chunking and real-time progress monitoring. The server is only used for
The server is only used for SDP/ICE exchange (signaling). SDP/ICE exchange (signaling). Connection is end-to-end encrypted (DTLS).
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,12 +1,31 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// Parse allowed hosts from environment variable
const getAllowedHosts = () => {
const hosts = process.env.VITE_ALLOWED_HOSTS;
// If set to 'all', allow all hosts
if (hosts === 'all') {
return 'all';
}
// If set, parse comma-separated list
if (hosts) {
return hosts.split(',').map(h => h.trim());
}
// Default: localhost only
return ['localhost'];
};
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
allowedHosts: getAllowedHosts(),
watch: { watch: {
usePolling: true, usePolling: true,
}, },