303 lines
9.1 KiB
TypeScript
303 lines
9.1 KiB
TypeScript
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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(); |