370 lines
9.2 KiB
TypeScript
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;
|
|
}
|