532 lines
14 KiB
JavaScript
532 lines
14 KiB
JavaScript
/**
|
|
* WebRTC Manager
|
|
* Handles all WebRTC peer connection logic for voice and video calls
|
|
*/
|
|
|
|
class WebRTCManager {
|
|
constructor(socket) {
|
|
this.socket = socket;
|
|
this.peerConnections = new Map(); // Map of userId -> RTCPeerConnection
|
|
this.localStream = null;
|
|
this.screenStream = null;
|
|
this.remoteStreams = new Map(); // Map of userId -> MediaStream
|
|
this.currentCallId = null;
|
|
this.isInitiator = false;
|
|
|
|
// WebRTC configuration with STUN/TURN servers
|
|
this.configuration = {
|
|
iceServers: [
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
]
|
|
};
|
|
|
|
// Media constraints
|
|
this.audioConstraints = {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
};
|
|
|
|
this.videoConstraints = {
|
|
width: { ideal: 1280 },
|
|
height: { ideal: 720 },
|
|
frameRate: { ideal: 30 }
|
|
};
|
|
|
|
// Event handlers
|
|
this.onRemoteStream = null;
|
|
this.onRemoteStreamRemoved = null;
|
|
this.onConnectionStateChange = null;
|
|
this.onIceConnectionStateChange = null;
|
|
|
|
this.setupSocketListeners();
|
|
}
|
|
|
|
/**
|
|
* Setup Socket.io listeners for WebRTC signaling
|
|
*/
|
|
setupSocketListeners() {
|
|
this.socket.on('call:offer', async (data) => {
|
|
await this.handleOffer(data);
|
|
});
|
|
|
|
this.socket.on('call:answer', async (data) => {
|
|
await this.handleAnswer(data);
|
|
});
|
|
|
|
this.socket.on('call:ice-candidate', async (data) => {
|
|
await this.handleIceCandidate(data);
|
|
});
|
|
|
|
this.socket.on('call:ended', () => {
|
|
this.cleanup();
|
|
});
|
|
|
|
this.socket.on('call:participant-joined', async (data) => {
|
|
console.log('Participant joined:', data);
|
|
// For group calls, establish connection with new participant
|
|
if (this.isInitiator) {
|
|
await this.initiateCallToUser(data.userId);
|
|
}
|
|
});
|
|
|
|
this.socket.on('call:participant-left', (data) => {
|
|
console.log('Participant left:', data);
|
|
this.removePeerConnection(data.userId);
|
|
});
|
|
|
|
this.socket.on('call:media-state-changed', (data) => {
|
|
console.log('Media state changed:', data);
|
|
// Update UI to reflect remote user's media state
|
|
if (this.onMediaStateChanged) {
|
|
this.onMediaStateChanged(data);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set TURN server credentials
|
|
*/
|
|
async setTurnCredentials(turnCredentials) {
|
|
if (turnCredentials && turnCredentials.urls) {
|
|
const turnServer = {
|
|
urls: turnCredentials.urls,
|
|
username: turnCredentials.username,
|
|
credential: turnCredentials.credential
|
|
};
|
|
|
|
this.configuration.iceServers.push(turnServer);
|
|
console.log('TURN server configured');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize local media stream (audio and/or video)
|
|
*/
|
|
async initializeLocalStream(audioEnabled = true, videoEnabled = true) {
|
|
try {
|
|
const constraints = {
|
|
audio: audioEnabled ? this.audioConstraints : false,
|
|
video: videoEnabled ? this.videoConstraints : false
|
|
};
|
|
|
|
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
console.log('Local stream initialized:', {
|
|
audio: audioEnabled,
|
|
video: videoEnabled,
|
|
tracks: this.localStream.getTracks().length
|
|
});
|
|
|
|
return this.localStream;
|
|
} catch (error) {
|
|
console.error('Error accessing media devices:', error);
|
|
throw new Error(`Failed to access camera/microphone: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a peer connection for a user
|
|
*/
|
|
createPeerConnection(userId) {
|
|
if (this.peerConnections.has(userId)) {
|
|
return this.peerConnections.get(userId);
|
|
}
|
|
|
|
const peerConnection = new RTCPeerConnection(this.configuration);
|
|
|
|
// Add local stream tracks to peer connection
|
|
if (this.localStream) {
|
|
this.localStream.getTracks().forEach(track => {
|
|
peerConnection.addTrack(track, this.localStream);
|
|
});
|
|
}
|
|
|
|
// Handle incoming remote stream
|
|
peerConnection.ontrack = (event) => {
|
|
console.log('Received remote track from', userId, event.track.kind);
|
|
|
|
const [remoteStream] = event.streams;
|
|
this.remoteStreams.set(userId, remoteStream);
|
|
|
|
if (this.onRemoteStream) {
|
|
this.onRemoteStream(userId, remoteStream);
|
|
}
|
|
};
|
|
|
|
// Handle ICE candidates
|
|
peerConnection.onicecandidate = (event) => {
|
|
if (event.candidate) {
|
|
console.log('Sending ICE candidate to', userId);
|
|
this.socket.emit('call:ice-candidate', {
|
|
callId: this.currentCallId,
|
|
targetUserId: userId,
|
|
candidate: event.candidate
|
|
});
|
|
}
|
|
};
|
|
|
|
// Handle connection state changes
|
|
peerConnection.onconnectionstatechange = () => {
|
|
console.log(`Connection state with ${userId}:`, peerConnection.connectionState);
|
|
|
|
if (this.onConnectionStateChange) {
|
|
this.onConnectionStateChange(userId, peerConnection.connectionState);
|
|
}
|
|
|
|
// Cleanup if connection fails or closes
|
|
if (peerConnection.connectionState === 'failed' ||
|
|
peerConnection.connectionState === 'closed') {
|
|
this.removePeerConnection(userId);
|
|
}
|
|
};
|
|
|
|
// Handle ICE connection state changes
|
|
peerConnection.oniceconnectionstatechange = () => {
|
|
console.log(`ICE connection state with ${userId}:`, peerConnection.iceConnectionState);
|
|
|
|
if (this.onIceConnectionStateChange) {
|
|
this.onIceConnectionStateChange(userId, peerConnection.iceConnectionState);
|
|
}
|
|
};
|
|
|
|
this.peerConnections.set(userId, peerConnection);
|
|
return peerConnection;
|
|
}
|
|
|
|
/**
|
|
* Initiate a call to a user (create offer)
|
|
*/
|
|
async initiateCallToUser(userId) {
|
|
try {
|
|
const peerConnection = this.createPeerConnection(userId);
|
|
|
|
// Create offer
|
|
const offer = await peerConnection.createOffer({
|
|
offerToReceiveAudio: true,
|
|
offerToReceiveVideo: true
|
|
});
|
|
|
|
await peerConnection.setLocalDescription(offer);
|
|
|
|
// Send offer through signaling server
|
|
this.socket.emit('call:offer', {
|
|
callId: this.currentCallId,
|
|
targetUserId: userId,
|
|
offer: offer
|
|
});
|
|
|
|
console.log('Call offer sent to', userId);
|
|
} catch (error) {
|
|
console.error('Error initiating call:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming call offer
|
|
*/
|
|
async handleOffer(data) {
|
|
const { callId, fromUserId, offer } = data;
|
|
|
|
try {
|
|
console.log('Received call offer from', fromUserId);
|
|
|
|
this.currentCallId = callId;
|
|
const peerConnection = this.createPeerConnection(fromUserId);
|
|
|
|
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
|
|
|
|
// Create answer
|
|
const answer = await peerConnection.createAnswer();
|
|
await peerConnection.setLocalDescription(answer);
|
|
|
|
// Send answer back
|
|
this.socket.emit('call:answer', {
|
|
callId: callId,
|
|
targetUserId: fromUserId,
|
|
answer: answer
|
|
});
|
|
|
|
console.log('Call answer sent to', fromUserId);
|
|
} catch (error) {
|
|
console.error('Error handling offer:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming call answer
|
|
*/
|
|
async handleAnswer(data) {
|
|
const { fromUserId, answer } = data;
|
|
|
|
try {
|
|
console.log('Received call answer from', fromUserId);
|
|
|
|
const peerConnection = this.peerConnections.get(fromUserId);
|
|
if (!peerConnection) {
|
|
throw new Error(`No peer connection found for user ${fromUserId}`);
|
|
}
|
|
|
|
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
|
|
console.log('Remote description set for', fromUserId);
|
|
} catch (error) {
|
|
console.error('Error handling answer:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle incoming ICE candidate
|
|
*/
|
|
async handleIceCandidate(data) {
|
|
const { fromUserId, candidate } = data;
|
|
|
|
try {
|
|
const peerConnection = this.peerConnections.get(fromUserId);
|
|
if (!peerConnection) {
|
|
console.warn(`No peer connection found for user ${fromUserId}`);
|
|
return;
|
|
}
|
|
|
|
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
|
console.log('ICE candidate added for', fromUserId);
|
|
} catch (error) {
|
|
console.error('Error adding ICE candidate:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove peer connection for a user
|
|
*/
|
|
removePeerConnection(userId) {
|
|
const peerConnection = this.peerConnections.get(userId);
|
|
if (peerConnection) {
|
|
peerConnection.close();
|
|
this.peerConnections.delete(userId);
|
|
}
|
|
|
|
const remoteStream = this.remoteStreams.get(userId);
|
|
if (remoteStream) {
|
|
remoteStream.getTracks().forEach(track => track.stop());
|
|
this.remoteStreams.delete(userId);
|
|
|
|
if (this.onRemoteStreamRemoved) {
|
|
this.onRemoteStreamRemoved(userId);
|
|
}
|
|
}
|
|
|
|
console.log('Peer connection removed for', userId);
|
|
}
|
|
|
|
/**
|
|
* Toggle audio track enabled/disabled
|
|
*/
|
|
toggleAudio(enabled) {
|
|
if (this.localStream) {
|
|
const audioTrack = this.localStream.getAudioTracks()[0];
|
|
if (audioTrack) {
|
|
audioTrack.enabled = enabled;
|
|
console.log('Audio', enabled ? 'enabled' : 'disabled');
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Toggle video track enabled/disabled
|
|
*/
|
|
toggleVideo(enabled) {
|
|
if (this.localStream) {
|
|
const videoTrack = this.localStream.getVideoTracks()[0];
|
|
if (videoTrack) {
|
|
videoTrack.enabled = enabled;
|
|
console.log('Video', enabled ? 'enabled' : 'disabled');
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Start screen sharing
|
|
*/
|
|
async startScreenShare() {
|
|
try {
|
|
this.screenStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: {
|
|
cursor: 'always'
|
|
},
|
|
audio: false
|
|
});
|
|
|
|
const screenTrack = this.screenStream.getVideoTracks()[0];
|
|
|
|
// Replace video track in all peer connections
|
|
this.peerConnections.forEach((peerConnection) => {
|
|
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'video');
|
|
if (sender) {
|
|
sender.replaceTrack(screenTrack);
|
|
}
|
|
});
|
|
|
|
// Handle screen share stop
|
|
screenTrack.onended = () => {
|
|
this.stopScreenShare();
|
|
};
|
|
|
|
console.log('Screen sharing started');
|
|
return this.screenStream;
|
|
} catch (error) {
|
|
console.error('Error starting screen share:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop screen sharing and restore camera
|
|
*/
|
|
stopScreenShare() {
|
|
if (this.screenStream) {
|
|
this.screenStream.getTracks().forEach(track => track.stop());
|
|
this.screenStream = null;
|
|
|
|
// Restore camera track
|
|
if (this.localStream) {
|
|
const videoTrack = this.localStream.getVideoTracks()[0];
|
|
if (videoTrack) {
|
|
this.peerConnections.forEach((peerConnection) => {
|
|
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'video');
|
|
if (sender) {
|
|
sender.replaceTrack(videoTrack);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log('Screen sharing stopped');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get connection statistics
|
|
*/
|
|
async getConnectionStats(userId) {
|
|
const peerConnection = this.peerConnections.get(userId);
|
|
if (!peerConnection) {
|
|
return null;
|
|
}
|
|
|
|
const stats = await peerConnection.getStats();
|
|
const result = {
|
|
audio: {},
|
|
video: {},
|
|
connection: {}
|
|
};
|
|
|
|
stats.forEach(report => {
|
|
if (report.type === 'inbound-rtp') {
|
|
if (report.kind === 'audio') {
|
|
result.audio.bytesReceived = report.bytesReceived;
|
|
result.audio.packetsLost = report.packetsLost;
|
|
result.audio.jitter = report.jitter;
|
|
} else if (report.kind === 'video') {
|
|
result.video.bytesReceived = report.bytesReceived;
|
|
result.video.packetsLost = report.packetsLost;
|
|
result.video.framesDecoded = report.framesDecoded;
|
|
result.video.frameWidth = report.frameWidth;
|
|
result.video.frameHeight = report.frameHeight;
|
|
}
|
|
} else if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
|
result.connection.roundTripTime = report.currentRoundTripTime;
|
|
result.connection.availableOutgoingBitrate = report.availableOutgoingBitrate;
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Cleanup all connections and streams
|
|
*/
|
|
cleanup() {
|
|
console.log('Cleaning up WebRTC resources');
|
|
|
|
// Stop screen share if active
|
|
this.stopScreenShare();
|
|
|
|
// Close all peer connections
|
|
this.peerConnections.forEach((peerConnection, userId) => {
|
|
this.removePeerConnection(userId);
|
|
});
|
|
|
|
// Stop local stream
|
|
if (this.localStream) {
|
|
this.localStream.getTracks().forEach(track => track.stop());
|
|
this.localStream = null;
|
|
}
|
|
|
|
// Clear remote streams
|
|
this.remoteStreams.forEach((stream) => {
|
|
stream.getTracks().forEach(track => track.stop());
|
|
});
|
|
this.remoteStreams.clear();
|
|
|
|
this.currentCallId = null;
|
|
this.isInitiator = false;
|
|
}
|
|
|
|
/**
|
|
* Get local stream
|
|
*/
|
|
getLocalStream() {
|
|
return this.localStream;
|
|
}
|
|
|
|
/**
|
|
* Get remote stream for a user
|
|
*/
|
|
getRemoteStream(userId) {
|
|
return this.remoteStreams.get(userId);
|
|
}
|
|
|
|
/**
|
|
* Get all remote streams
|
|
*/
|
|
getAllRemoteStreams() {
|
|
return Array.from(this.remoteStreams.entries());
|
|
}
|
|
|
|
/**
|
|
* Check if audio is enabled
|
|
*/
|
|
isAudioEnabled() {
|
|
if (this.localStream) {
|
|
const audioTrack = this.localStream.getAudioTracks()[0];
|
|
return audioTrack ? audioTrack.enabled : false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if video is enabled
|
|
*/
|
|
isVideoEnabled() {
|
|
if (this.localStream) {
|
|
const videoTrack = this.localStream.getVideoTracks()[0];
|
|
return videoTrack ? videoTrack.enabled : false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if screen sharing is active
|
|
*/
|
|
isScreenSharing() {
|
|
return this.screenStream !== null;
|
|
}
|
|
}
|
|
|
|
export default WebRTCManager;
|