AeThex-Connect/packages/core/webrtc/WebRTCManager.ts

370 lines
9.2 KiB
TypeScript

/**
* WebRTC Manager
* Handles peer connections for voice and video calls
*/
import { io, Socket } from 'socket.io-client';
export interface PeerConnectionConfig {
iceServers?: RTCIceServer[];
audioConstraints?: MediaTrackConstraints;
videoConstraints?: MediaTrackConstraints;
}
export interface CallOptions {
audio: boolean;
video: boolean;
}
export class WebRTCManager {
private socket: Socket | null = null;
private peerConnections: Map<string, RTCPeerConnection> = new Map();
private localStream: MediaStream | null = null;
private config: RTCConfiguration;
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
constructor(serverUrl: string, config?: PeerConnectionConfig) {
this.config = {
iceServers: config?.iceServers || [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
],
};
this.socket = io(serverUrl, {
transports: ['websocket'],
});
this.setupSocketListeners();
}
/**
* Setup Socket.IO event listeners for signaling
*/
private setupSocketListeners(): void {
if (!this.socket) return;
this.socket.on('call:offer', async ({ from, offer }) => {
await this.handleOffer(from, offer);
});
this.socket.on('call:answer', async ({ from, answer }) => {
await this.handleAnswer(from, answer);
});
this.socket.on('call:ice-candidate', ({ from, candidate }) => {
this.handleIceCandidate(from, candidate);
});
this.socket.on('call:end', ({ from }) => {
this.closePeerConnection(from);
});
}
/**
* Initialize local media stream
*/
async initializeLocalStream(options: CallOptions): Promise<MediaStream> {
try {
const constraints: MediaStreamConstraints = {
audio: options.audio ? {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
} : false,
video: options.video ? {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30 },
} : false,
};
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
// Setup voice activity detection
if (options.audio) {
this.setupVoiceActivityDetection();
}
return this.localStream;
} catch (error) {
console.error('Failed to get user media:', error);
throw error;
}
}
/**
* Setup voice activity detection using Web Audio API
*/
private setupVoiceActivityDetection(): void {
if (!this.localStream) return;
this.audioContext = new AudioContext();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 2048;
const source = this.audioContext.createMediaStreamSource(this.localStream);
source.connect(this.analyser);
}
/**
* Check if user is speaking
*/
isSpeaking(threshold: number = 0.01): boolean {
if (!this.analyser) return false;
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
this.analyser.getByteFrequencyData(dataArray);
const average = dataArray.reduce((acc, val) => acc + val, 0) / dataArray.length;
return average / 255 > threshold;
}
/**
* Initiate a call to a peer
*/
async initiateCall(peerId: string, options: CallOptions): Promise<void> {
if (!this.socket) throw new Error('Socket not initialized');
// Get local stream
if (!this.localStream) {
await this.initializeLocalStream(options);
}
// Create peer connection
const pc = this.createPeerConnection(peerId);
// Add local tracks
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream!);
});
}
// Create and send offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.socket.emit('call:offer', {
to: peerId,
offer: offer,
});
}
/**
* Handle incoming call offer
*/
private async handleOffer(peerId: string, offer: RTCSessionDescriptionInit): Promise<void> {
if (!this.socket) return;
const pc = this.createPeerConnection(peerId);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
// Add local tracks if available
if (this.localStream) {
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream!);
});
}
// Create and send answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.socket.emit('call:answer', {
to: peerId,
answer: answer,
});
}
/**
* Handle answer to our offer
*/
private async handleAnswer(peerId: string, answer: RTCSessionDescriptionInit): Promise<void> {
const pc = this.peerConnections.get(peerId);
if (!pc) return;
await pc.setRemoteDescription(new RTCSessionDescription(answer));
}
/**
* Handle ICE candidate
*/
private async handleIceCandidate(peerId: string, candidate: RTCIceCandidateInit): Promise<void> {
const pc = this.peerConnections.get(peerId);
if (!pc) return;
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (error) {
console.error('Error adding ICE candidate:', error);
}
}
/**
* Create a new peer connection
*/
private createPeerConnection(peerId: string): RTCPeerConnection {
const pc = new RTCPeerConnection(this.config);
// ICE candidate event
pc.onicecandidate = (event) => {
if (event.candidate && this.socket) {
this.socket.emit('call:ice-candidate', {
to: peerId,
candidate: event.candidate,
});
}
};
// Track event (remote stream)
pc.ontrack = (event) => {
this.onRemoteTrack?.(peerId, event.streams[0]);
};
// Connection state change
pc.onconnectionstatechange = () => {
console.log(`Peer connection state: ${pc.connectionState}`);
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
this.closePeerConnection(peerId);
}
};
this.peerConnections.set(peerId, pc);
return pc;
}
/**
* Close peer connection
*/
private closePeerConnection(peerId: string): void {
const pc = this.peerConnections.get(peerId);
if (pc) {
pc.close();
this.peerConnections.delete(peerId);
this.onPeerDisconnected?.(peerId);
}
}
/**
* End call
*/
endCall(peerId: string): void {
if (this.socket) {
this.socket.emit('call:end', { to: peerId });
}
this.closePeerConnection(peerId);
}
/**
* Toggle audio
*/
toggleAudio(enabled: boolean): void {
if (!this.localStream) return;
this.localStream.getAudioTracks().forEach(track => {
track.enabled = enabled;
});
}
/**
* Toggle video
*/
toggleVideo(enabled: boolean): void {
if (!this.localStream) return;
this.localStream.getVideoTracks().forEach(track => {
track.enabled = enabled;
});
}
/**
* Get screen sharing stream
*/
async getScreenStream(): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
},
audio: false,
});
} catch (error) {
console.error('Failed to get screen stream:', error);
throw error;
}
}
/**
* Replace video track with screen share
*/
async startScreenShare(peerId: string): Promise<void> {
const screenStream = await this.getScreenStream();
const screenTrack = screenStream.getVideoTracks()[0];
const pc = this.peerConnections.get(peerId);
if (!pc) return;
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
await sender.replaceTrack(screenTrack);
}
// Stop screen share when track ends
screenTrack.onended = () => {
this.stopScreenShare(peerId);
};
}
/**
* Stop screen sharing
*/
async stopScreenShare(peerId: string): Promise<void> {
if (!this.localStream) return;
const videoTrack = this.localStream.getVideoTracks()[0];
const pc = this.peerConnections.get(peerId);
if (!pc) return;
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
if (sender && videoTrack) {
await sender.replaceTrack(videoTrack);
}
}
/**
* Cleanup
*/
destroy(): void {
// Close all peer connections
this.peerConnections.forEach((pc, peerId) => {
this.closePeerConnection(peerId);
});
// Stop local stream
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
// Close audio context
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
// Disconnect socket
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
// Event handlers (to be set by consumer)
onRemoteTrack?: (peerId: string, stream: MediaStream) => void;
onPeerDisconnected?: (peerId: string) => void;
}