import { Device } from 'mediasoup-client'; import type { RtpCapabilities, Transport, Producer } from 'mediasoup-client/lib/types'; interface SFUMessage { type: string; data?: any; } class SFUPublisher { private ws: WebSocket | null = null; private device: Device | null = null; private producerTransport: Transport | null = null; private videoProducer: Producer | null = null; private audioProducer: Producer | null = null; private localStream: MediaStream | null = null; private isStreaming = false; // UI Elements private statusElement: HTMLElement; private subscribersCountElement: HTMLElement; private producersCountElement: HTMLElement; private startButton: HTMLButtonElement; private stopButton: HTMLButtonElement; private localVideo: HTMLVideoElement; constructor() { this.statusElement = document.getElementById('status')!; this.subscribersCountElement = document.getElementById('subscribersCount')!; this.producersCountElement = document.getElementById('producersCount')!; this.startButton = document.getElementById('startBtn') as HTMLButtonElement; this.stopButton = document.getElementById('stopBtn') as HTMLButtonElement; this.localVideo = document.getElementById('localVideo') as HTMLVideoElement; this.setupEventHandlers(); } private setupEventHandlers(): void { this.startButton.addEventListener('click', () => this.startBroadcasting()); this.stopButton.addEventListener('click', () => this.stopBroadcasting()); } private async startBroadcasting(): Promise { try { this.updateStatus('Connecting to SFU server...', 'waiting'); this.setButtonStates(false, false); await this.connectToServer(); await this.initializeDevice(); await this.createTransport(); await this.getLocalStream(); await this.startProducing(); this.isStreaming = true; this.updateStatus('Broadcasting via SFU - Scalable for many viewers!', 'connected'); this.setButtonStates(false, true); this.updateStats(); } catch (error) { console.error('Error starting broadcast:', error); this.updateStatus('Failed to start broadcast', 'disconnected'); this.setButtonStates(true, false); } } private async stopBroadcasting(): Promise { this.isStreaming = false; if (this.videoProducer) { this.videoProducer.close(); this.videoProducer = null; } if (this.audioProducer) { this.audioProducer.close(); this.audioProducer = null; } if (this.producerTransport) { this.producerTransport.close(); this.producerTransport = null; } if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); this.localStream = null; } if (this.localVideo) { this.localVideo.srcObject = null; } if (this.ws) { this.ws.close(); this.ws = null; } this.updateStatus('Broadcast stopped', 'disconnected'); this.setButtonStates(true, false); this.resetStats(); } private async connectToServer(): Promise { return new Promise((resolve, reject) => { this.ws = new WebSocket('ws://localhost:3001?role=publisher'); this.ws.onopen = () => resolve(); this.ws.onerror = (error) => reject(error); this.ws.onmessage = (event) => { try { const message: SFUMessage = JSON.parse(event.data); this.handleServerMessage(message); } catch (error) { console.error('Failed to parse message:', error); } }; this.ws.onclose = () => { if (this.isStreaming) { this.updateStatus('Connection to server lost', 'disconnected'); } }; }); } private async handleServerMessage(message: SFUMessage): Promise { switch (message.type) { case 'join': console.log('Joined as publisher:', message.data); break; case 'routerRtpCapabilities': if (this.device) { await this.device.load({ routerRtpCapabilities: message.data.rtpCapabilities }); } break; case 'webRtcTransportCreated': if (this.producerTransport) { await this.producerTransport.connect({ dtlsParameters: message.data.dtlsParameters }); } break; case 'produced': console.log('Producer created:', message.data.producerId); break; default: console.log('Unknown message type:', message.type); } } private async initializeDevice(): Promise { this.device = new Device(); // Get router RTP capabilities this.sendMessage({ type: 'getRouterRtpCapabilities' }); // Wait for router capabilities return new Promise((resolve) => { const checkDevice = () => { if (this.device?.loaded) { resolve(); } else { setTimeout(checkDevice, 100); } }; checkDevice(); }); } private async createTransport(): Promise { this.sendMessage({ type: 'createWebRtcTransport' }); // Wait for transport creation response return new Promise((resolve) => { const originalHandler = this.handleServerMessage.bind(this); this.handleServerMessage = async (message: SFUMessage) => { if (message.type === 'webRtcTransportCreated') { this.producerTransport = this.device!.createSendTransport({ id: message.data.id, iceParameters: message.data.iceParameters, iceCandidates: message.data.iceCandidates, dtlsParameters: message.data.dtlsParameters }); this.producerTransport.on('connect', async ({ dtlsParameters }, callback, errback) => { try { this.sendMessage({ type: 'connectWebRtcTransport', data: { dtlsParameters } }); callback(); } catch (error) { errback(error); } }); this.producerTransport.on('produce', async (parameters, callback, errback) => { try { this.sendMessage({ type: 'produce', data: { kind: parameters.kind, rtpParameters: parameters.rtpParameters } }); // Wait for producer ID response const waitForProducer = () => { if (this.ws) { const tempHandler = this.ws.onmessage; this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'produced') { callback({ id: msg.data.producerId }); this.ws!.onmessage = tempHandler; } }; } }; waitForProducer(); } catch (error) { errback(error); } }); this.handleServerMessage = originalHandler; resolve(); } else { originalHandler(message); } }; }); } private async getLocalStream(): Promise { this.localStream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280, max: 1920 }, height: { ideal: 720, max: 1080 }, frameRate: { ideal: 30, max: 60 } }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); this.localVideo.srcObject = this.localStream; } private async startProducing(): Promise { if (!this.localStream || !this.producerTransport) return; const videoTrack = this.localStream.getVideoTracks()[0]; const audioTrack = this.localStream.getAudioTracks()[0]; if (videoTrack) { this.videoProducer = await this.producerTransport.produce({ track: videoTrack, encodings: [ { maxBitrate: 100000, scaleResolutionDownBy: 4 }, { maxBitrate: 300000, scaleResolutionDownBy: 2 }, { maxBitrate: 900000, scaleResolutionDownBy: 1 } ] }); } if (audioTrack) { this.audioProducer = await this.producerTransport.produce({ track: audioTrack }); } } private sendMessage(message: SFUMessage): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify(message)); } } private updateStatus(status: string, className: string): void { this.statusElement.textContent = status; this.statusElement.className = `status ${className}`; } private setButtonStates(startEnabled: boolean, stopEnabled: boolean): void { this.startButton.disabled = !startEnabled; this.stopButton.disabled = !stopEnabled; } private updateStats(): void { // This would be updated by server stats in a real implementation this.subscribersCountElement.textContent = '0'; this.producersCountElement.textContent = this.isStreaming ? '2' : '0'; // Video + Audio } private resetStats(): void { this.subscribersCountElement.textContent = '0'; this.producersCountElement.textContent = '0'; } } new SFUPublisher();