fixed tests
This commit is contained in:
25
public/js/interfaces/IWebRTCClient.ts
Normal file
25
public/js/interfaces/IWebRTCClient.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface ISignalingMessage {
|
||||
type: 'offer' | 'answer' | 'ice-candidate' | 'join' | 'leave' | 'publisher-joined' | 'publisher-left';
|
||||
data?: any;
|
||||
senderId?: string;
|
||||
targetId?: string;
|
||||
}
|
||||
|
||||
export interface IWebRTCClient {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): void;
|
||||
sendMessage(message: ISignalingMessage): void;
|
||||
isConnected(): boolean;
|
||||
}
|
||||
|
||||
export interface IMediaHandler {
|
||||
getLocalStream(): Promise<MediaStream>;
|
||||
stopLocalStream(): void;
|
||||
getLocalVideo(): HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
export interface IUIController {
|
||||
updateStatus(status: string, className: string): void;
|
||||
updateSubscribersCount(count: number): void;
|
||||
setButtonStates(startEnabled: boolean, stopEnabled: boolean): void;
|
||||
}
|
||||
120
public/js/publisher.ts
Normal file
120
public/js/publisher.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { WebSocketClient, WebSocketClientStatusMapping } from './services/WebSocketClient.ts';
|
||||
import { MediaHandler } from './services/MediaHandler.ts';
|
||||
import { UIController } from './services/UIController.ts';
|
||||
import { PublisherRTCManager } from './services/PublisherRTCManager.ts';
|
||||
import type { ISignalingMessage } from './interfaces/IWebRTCClient.ts';
|
||||
import { DisposableList } from '@techniker-me/tools';
|
||||
|
||||
class Publisher {
|
||||
private readonly _disposables: DisposableList = new DisposableList();
|
||||
private wsClient: WebSocketClient;
|
||||
private mediaHandler: MediaHandler;
|
||||
private uiController: UIController;
|
||||
private rtcManager: PublisherRTCManager;
|
||||
private isStreaming = false;
|
||||
|
||||
constructor() {
|
||||
this.wsClient = new WebSocketClient('publisher');
|
||||
this.mediaHandler = new MediaHandler('localVideo');
|
||||
this.uiController = new UIController('status', 'subscribersCount', 'startBtn', 'stopBtn');
|
||||
this.rtcManager = new PublisherRTCManager((count) => {
|
||||
this.uiController.updateSubscribersCount(count);
|
||||
});
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.setupWebSocketHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
this.uiController.onButtonClick('startBtn', () => this.startBroadcasting());
|
||||
this.uiController.onButtonClick('stopBtn', () => this.stopBroadcasting());
|
||||
}
|
||||
|
||||
private setupWebSocketHandlers(): void {
|
||||
this._disposables.add(this.wsClient.status.subscribe((status) => {
|
||||
this.uiController.updateStatus(WebSocketClientStatusMapping.convertWebSocketClientStatusToWebSocketClientStatusType(status), status);
|
||||
}));
|
||||
|
||||
this.wsClient.onMessage('join', (message) => {
|
||||
this.uiController.updateStatus('Connected to server', 'connected');
|
||||
});
|
||||
|
||||
this.wsClient.onMessage('answer', async (message) => {
|
||||
if (message.senderId) {
|
||||
await this.rtcManager.handleAnswer(message.senderId, message.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.wsClient.onMessage('ice-candidate', async (message) => {
|
||||
if (message.senderId && message.data) {
|
||||
await this.rtcManager.handleIceCandidate(message.senderId, message.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.rtcManager.setOnIceCandidate((subscriberId, candidate) => {
|
||||
const message: ISignalingMessage = {
|
||||
type: 'ice-candidate',
|
||||
data: candidate,
|
||||
targetId: subscriberId
|
||||
};
|
||||
this.wsClient.sendMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
private async startBroadcasting(): Promise<void> {
|
||||
try {
|
||||
this.uiController.updateStatus('Starting broadcast...', 'waiting');
|
||||
this.uiController.setButtonStates(false, false);
|
||||
|
||||
await this.wsClient.connect();
|
||||
const stream = await this.mediaHandler.getLocalStream();
|
||||
this.rtcManager.setLocalStream(stream);
|
||||
|
||||
this.isStreaming = true;
|
||||
this.uiController.updateStatus('Broadcasting - Ready for subscribers', 'connected');
|
||||
this.uiController.setButtonStates(false, true);
|
||||
|
||||
this.startOfferLoop();
|
||||
} catch (error) {
|
||||
console.error('Error starting broadcast:', error);
|
||||
this.uiController.updateStatus('Failed to start broadcast', 'disconnected');
|
||||
this.uiController.setButtonStates(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
private stopBroadcasting(): void {
|
||||
this.isStreaming = false;
|
||||
this.mediaHandler.stopLocalStream();
|
||||
this.rtcManager.closeAllConnections();
|
||||
this.wsClient.disconnect();
|
||||
|
||||
this.uiController.updateStatus('Broadcast stopped', 'disconnected');
|
||||
this.uiController.setButtonStates(true, false);
|
||||
this.uiController.updateSubscribersCount(0);
|
||||
}
|
||||
|
||||
private async startOfferLoop(): Promise<void> {
|
||||
const offerToNewSubscribers = async () => {
|
||||
if (!this.isStreaming) return;
|
||||
|
||||
try {
|
||||
const offer = await this.rtcManager.createOfferForSubscriber('broadcast');
|
||||
const message: ISignalingMessage = {
|
||||
type: 'offer',
|
||||
data: offer
|
||||
};
|
||||
this.wsClient.sendMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Error creating offer:', error);
|
||||
}
|
||||
|
||||
if (this.isStreaming) {
|
||||
setTimeout(offerToNewSubscribers, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(offerToNewSubscribers, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
new Publisher();
|
||||
55
public/js/services/MediaHandler.ts
Normal file
55
public/js/services/MediaHandler.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { IMediaHandler } from '../interfaces/IWebRTCClient.ts';
|
||||
|
||||
export class MediaHandler implements IMediaHandler {
|
||||
private localStream: MediaStream | null = null;
|
||||
private videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
constructor(videoElementId?: string) {
|
||||
if (videoElementId) {
|
||||
this.videoElement = document.getElementById(videoElementId) as HTMLVideoElement;
|
||||
}
|
||||
}
|
||||
|
||||
async getLocalStream(): Promise<MediaStream> {
|
||||
try {
|
||||
this.localStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 }
|
||||
},
|
||||
audio: true
|
||||
});
|
||||
|
||||
if (this.videoElement) {
|
||||
this.videoElement.srcObject = this.localStream;
|
||||
}
|
||||
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('Error accessing media devices:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
stopLocalStream(): void {
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
});
|
||||
this.localStream = null;
|
||||
}
|
||||
|
||||
if (this.videoElement) {
|
||||
this.videoElement.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
getLocalVideo(): HTMLVideoElement | null {
|
||||
return this.videoElement;
|
||||
}
|
||||
|
||||
getCurrentStream(): MediaStream | null {
|
||||
return this.localStream;
|
||||
}
|
||||
}
|
||||
103
public/js/services/PublisherRTCManager.ts
Normal file
103
public/js/services/PublisherRTCManager.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ISignalingMessage } from '../interfaces/IWebRTCClient.ts';
|
||||
|
||||
export class PublisherRTCManager {
|
||||
private peerConnections: Map<string, RTCPeerConnection> = new Map();
|
||||
private localStream: MediaStream | null = null;
|
||||
private onSubscriberCountChange?: (count: number) => void;
|
||||
|
||||
private rtcConfiguration: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
constructor(onSubscriberCountChange?: (count: number) => void) {
|
||||
this.onSubscriberCountChange = onSubscriberCountChange;
|
||||
}
|
||||
|
||||
setLocalStream(stream: MediaStream): void {
|
||||
this.localStream = stream;
|
||||
}
|
||||
|
||||
async createOfferForSubscriber(subscriberId: string): Promise<RTCSessionDescriptionInit> {
|
||||
const peerConnection = this.createPeerConnection(subscriberId);
|
||||
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, this.localStream!);
|
||||
});
|
||||
}
|
||||
|
||||
const offer = await peerConnection.createOffer();
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
|
||||
return offer;
|
||||
}
|
||||
|
||||
async handleAnswer(subscriberId: string, answer: RTCSessionDescriptionInit): Promise<void> {
|
||||
const peerConnection = this.peerConnections.get(subscriberId);
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(answer);
|
||||
}
|
||||
}
|
||||
|
||||
async handleIceCandidate(subscriberId: string, candidate: RTCIceCandidateInit): Promise<void> {
|
||||
const peerConnection = this.peerConnections.get(subscriberId);
|
||||
if (peerConnection && candidate) {
|
||||
await peerConnection.addIceCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
removeSubscriber(subscriberId: string): void {
|
||||
const peerConnection = this.peerConnections.get(subscriberId);
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
this.peerConnections.delete(subscriberId);
|
||||
this.updateSubscriberCount();
|
||||
}
|
||||
}
|
||||
|
||||
closeAllConnections(): void {
|
||||
this.peerConnections.forEach((pc, id) => {
|
||||
pc.close();
|
||||
});
|
||||
this.peerConnections.clear();
|
||||
this.updateSubscriberCount();
|
||||
}
|
||||
|
||||
private createPeerConnection(subscriberId: string): RTCPeerConnection {
|
||||
const peerConnection = new RTCPeerConnection(this.rtcConfiguration);
|
||||
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.onIceCandidate?.(subscriberId, event.candidate);
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log(`Connection state for ${subscriberId}:`, peerConnection.connectionState);
|
||||
if (peerConnection.connectionState === 'failed' ||
|
||||
peerConnection.connectionState === 'disconnected') {
|
||||
this.removeSubscriber(subscriberId);
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnections.set(subscriberId, peerConnection);
|
||||
this.updateSubscriberCount();
|
||||
|
||||
return peerConnection;
|
||||
}
|
||||
|
||||
private updateSubscriberCount(): void {
|
||||
if (this.onSubscriberCountChange) {
|
||||
this.onSubscriberCountChange(this.peerConnections.size);
|
||||
}
|
||||
}
|
||||
|
||||
private onIceCandidate?: (subscriberId: string, candidate: RTCIceCandidate) => void;
|
||||
|
||||
setOnIceCandidate(handler: (subscriberId: string, candidate: RTCIceCandidate) => void): void {
|
||||
this.onIceCandidate = handler;
|
||||
}
|
||||
}
|
||||
98
public/js/services/SubscriberRTCManager.ts
Normal file
98
public/js/services/SubscriberRTCManager.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { ISignalingMessage } from '../interfaces/IWebRTCClient.ts';
|
||||
|
||||
export class SubscriberRTCManager {
|
||||
private peerConnection: RTCPeerConnection | null = null;
|
||||
private remoteVideo: HTMLVideoElement | null = null;
|
||||
private onStreamReceived?: (stream: MediaStream) => void;
|
||||
private onConnectionStateChange?: (state: string) => void;
|
||||
|
||||
private rtcConfiguration: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
constructor(
|
||||
videoElementId: string,
|
||||
onStreamReceived?: (stream: MediaStream) => void,
|
||||
onConnectionStateChange?: (state: string) => void
|
||||
) {
|
||||
this.remoteVideo = document.getElementById(videoElementId) as HTMLVideoElement;
|
||||
this.onStreamReceived = onStreamReceived;
|
||||
this.onConnectionStateChange = onConnectionStateChange;
|
||||
}
|
||||
|
||||
async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
||||
this.createPeerConnection();
|
||||
|
||||
if (!this.peerConnection) {
|
||||
throw new Error('Failed to create peer connection');
|
||||
}
|
||||
|
||||
await this.peerConnection.setRemoteDescription(offer);
|
||||
const answer = await this.peerConnection.createAnswer();
|
||||
await this.peerConnection.setLocalDescription(answer);
|
||||
|
||||
return answer;
|
||||
}
|
||||
|
||||
async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
if (this.peerConnection && candidate) {
|
||||
await this.peerConnection.addIceCandidate(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = null;
|
||||
}
|
||||
|
||||
if (this.remoteVideo) {
|
||||
this.remoteVideo.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
private createPeerConnection(): void {
|
||||
this.peerConnection = new RTCPeerConnection(this.rtcConfiguration);
|
||||
|
||||
this.peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.onIceCandidate?.(event.candidate);
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.ontrack = (event) => {
|
||||
console.log('Received remote stream');
|
||||
const [remoteStream] = event.streams;
|
||||
|
||||
if (this.remoteVideo) {
|
||||
this.remoteVideo.srcObject = remoteStream;
|
||||
}
|
||||
|
||||
if (this.onStreamReceived) {
|
||||
this.onStreamReceived(remoteStream);
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.onconnectionstatechange = () => {
|
||||
const state = this.peerConnection?.connectionState || 'unknown';
|
||||
console.log('Connection state changed:', state);
|
||||
|
||||
if (this.onConnectionStateChange) {
|
||||
this.onConnectionStateChange(state);
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.onicegatheringstatechange = () => {
|
||||
console.log('ICE gathering state:', this.peerConnection?.iceGatheringState);
|
||||
};
|
||||
}
|
||||
|
||||
private onIceCandidate?: (candidate: RTCIceCandidate) => void;
|
||||
|
||||
setOnIceCandidate(handler: (candidate: RTCIceCandidate) => void): void {
|
||||
this.onIceCandidate = handler;
|
||||
}
|
||||
}
|
||||
53
public/js/services/UIController.ts
Normal file
53
public/js/services/UIController.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { IUIController } from '../interfaces/IWebRTCClient.ts';
|
||||
|
||||
export class UIController implements IUIController {
|
||||
private statusElement: HTMLElement;
|
||||
private subscribersCountElement: HTMLElement | null;
|
||||
private startButton: HTMLButtonElement | null;
|
||||
private stopButton: HTMLButtonElement | null;
|
||||
|
||||
constructor(
|
||||
statusElementId: string,
|
||||
subscribersCountElementId?: string,
|
||||
startButtonId?: string,
|
||||
stopButtonId?: string
|
||||
) {
|
||||
this.statusElement = document.getElementById(statusElementId)!;
|
||||
this.subscribersCountElement = subscribersCountElementId
|
||||
? document.getElementById(subscribersCountElementId)
|
||||
: null;
|
||||
this.startButton = startButtonId
|
||||
? document.getElementById(startButtonId) as HTMLButtonElement
|
||||
: null;
|
||||
this.stopButton = stopButtonId
|
||||
? document.getElementById(stopButtonId) as HTMLButtonElement
|
||||
: null;
|
||||
}
|
||||
|
||||
updateStatus(status: string, className: string): void {
|
||||
this.statusElement.textContent = status;
|
||||
this.statusElement.className = `status ${className}`;
|
||||
}
|
||||
|
||||
updateSubscribersCount(count: number): void {
|
||||
if (this.subscribersCountElement) {
|
||||
this.subscribersCountElement.textContent = `Subscribers: ${count}`;
|
||||
}
|
||||
}
|
||||
|
||||
setButtonStates(startEnabled: boolean, stopEnabled: boolean): void {
|
||||
if (this.startButton) {
|
||||
this.startButton.disabled = !startEnabled;
|
||||
}
|
||||
if (this.stopButton) {
|
||||
this.stopButton.disabled = !stopEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
onButtonClick(buttonId: string, handler: () => void): void {
|
||||
const button = document.getElementById(buttonId);
|
||||
if (button) {
|
||||
button.addEventListener('click', handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
public/js/services/WebSocketClient.ts
Normal file
129
public/js/services/WebSocketClient.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { assertUnreachable, ReadOnlySubject, Subject } from '@techniker-me/tools';
|
||||
import type { ISignalingMessage, IWebRTCClient } from '../interfaces/IWebRTCClient.ts';
|
||||
|
||||
export enum WebSocketClientStatus {
|
||||
Offline = 0,
|
||||
Connecting = 1,
|
||||
Online = 2,
|
||||
Reconnecting = 3,
|
||||
Error = 4,
|
||||
Closed = 5,
|
||||
}
|
||||
|
||||
export type WebSocketClientStatusType = 'offline' | 'connecting' | 'online' | 'reconnecting' | 'error' | 'closed';
|
||||
|
||||
export class WebSocketClientStatusMapping {
|
||||
public static convertWebSocketClientStatusToWebSocketClientStatusType(status: WebSocketClientStatus): WebSocketClientStatusType {
|
||||
switch (status) {
|
||||
case WebSocketClientStatus.Offline:
|
||||
return 'offline';
|
||||
case WebSocketClientStatus.Connecting:
|
||||
return 'connecting';
|
||||
case WebSocketClientStatus.Online:
|
||||
return 'online';
|
||||
case WebSocketClientStatus.Reconnecting:
|
||||
return 'reconnecting';
|
||||
case WebSocketClientStatus.Error:
|
||||
return 'error';
|
||||
case WebSocketClientStatus.Closed:
|
||||
return 'closed';
|
||||
default:
|
||||
assertUnreachable(status);
|
||||
}
|
||||
}
|
||||
|
||||
public static convertWebSocketClientStatusTypeToWebSocketClientStatus(status: WebSocketClientStatusType): WebSocketClientStatus {
|
||||
switch (status) {
|
||||
case 'offline':
|
||||
return WebSocketClientStatus.Offline;
|
||||
case 'connecting':
|
||||
return WebSocketClientStatus.Connecting;
|
||||
case 'online':
|
||||
return WebSocketClientStatus.Online;
|
||||
case 'reconnecting':
|
||||
return WebSocketClientStatus.Reconnecting;
|
||||
case 'error':
|
||||
return WebSocketClientStatus.Error;
|
||||
case 'closed':
|
||||
return WebSocketClientStatus.Closed;
|
||||
default:
|
||||
assertUnreachable(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class WebSocketClient implements IWebRTCClient {
|
||||
private readonly _status: Subject<WebSocketClientStatus> = new Subject<WebSocketClientStatus>(WebSocketClientStatus.Offline);
|
||||
private readonly _readOnlyStatus: ReadOnlySubject<WebSocketClientStatus> = new ReadOnlySubject<WebSocketClientStatus>(this._status);
|
||||
private ws: WebSocket | null = null;
|
||||
private role: 'publisher' | 'subscriber';
|
||||
private messageHandlers: Map<string, (message: ISignalingMessage) => void> = new Map();
|
||||
|
||||
constructor(role: 'publisher' | 'subscriber') {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
get status(): ReadOnlySubject<WebSocketClientStatus> {
|
||||
return this._readOnlyStatus;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this._status.value = WebSocketClientStatus.Online;
|
||||
return new Promise((resolve, reject) => {
|
||||
const wsUrl = `ws://localhost:3000/ws?role=${this.role}`;
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this._status.value = WebSocketClientStatus.Online;
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this._status.value = WebSocketClientStatus.Error;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: ISignalingMessage = JSON.parse(event.data);
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._status.value = WebSocketClientStatus.Closed;
|
||||
this.ws = null;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(message: ISignalingMessage): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
onMessage(type: string, handler: (message: ISignalingMessage) => void): void {
|
||||
this.messageHandlers.set(type, handler);
|
||||
}
|
||||
|
||||
private handleMessage(message: ISignalingMessage): void {
|
||||
const handler = this.messageHandlers.get(message.type);
|
||||
if (handler) {
|
||||
handler(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
262
public/js/sfu-publisher-simple.ts
Normal file
262
public/js/sfu-publisher-simple.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
interface SFUMessage {
|
||||
type: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
class SFUPublisher {
|
||||
private ws: WebSocket | null = null;
|
||||
private peerConnection: RTCPeerConnection | null = null;
|
||||
private localStream: MediaStream | null = null;
|
||||
private isStreaming = false;
|
||||
|
||||
private rtcConfiguration: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
// 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.getLocalStream();
|
||||
await this.createPeerConnection();
|
||||
await this.startSFUNegotiation();
|
||||
|
||||
this.isStreaming = true;
|
||||
this.updateStatus('Broadcasting via SFU - Optimized for scalability!', 'connected');
|
||||
this.setButtonStates(false, true);
|
||||
this.updateStats();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting broadcast:', error);
|
||||
this.updateStatus('Failed to start broadcast: ' + error.message, 'disconnected');
|
||||
this.setButtonStates(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
private async stopBroadcasting(): Promise<void> {
|
||||
this.isStreaming = false;
|
||||
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = 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 = async (event) => {
|
||||
try {
|
||||
const message: SFUMessage = JSON.parse(event.data);
|
||||
await 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> {
|
||||
console.log('Received message:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'join':
|
||||
console.log('Joined as publisher:', message.data);
|
||||
break;
|
||||
case 'routerRtpCapabilities':
|
||||
console.log('Received router capabilities');
|
||||
// In a full SFU implementation, this would configure the device
|
||||
break;
|
||||
case 'webRtcTransportCreated':
|
||||
console.log('Transport created, connecting...');
|
||||
break;
|
||||
case 'webRtcTransportConnected':
|
||||
console.log('Transport connected, starting to produce');
|
||||
break;
|
||||
case 'produced':
|
||||
console.log('Producer created:', message.data.producerId);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('Server error:', message.data.message);
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
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 createPeerConnection(): Promise<void> {
|
||||
this.peerConnection = new RTCPeerConnection(this.rtcConfiguration);
|
||||
|
||||
// Add local stream to peer connection
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => {
|
||||
this.peerConnection!.addTrack(track, this.localStream!);
|
||||
});
|
||||
}
|
||||
|
||||
this.peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.sendMessage({
|
||||
type: 'ice-candidate',
|
||||
data: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.onconnectionstatechange = () => {
|
||||
console.log('Connection state:', this.peerConnection?.connectionState);
|
||||
};
|
||||
}
|
||||
|
||||
private async startSFUNegotiation(): Promise<void> {
|
||||
// Simulate SFU negotiation process
|
||||
// In a real SFU implementation, this would involve:
|
||||
// 1. Getting router RTP capabilities
|
||||
// 2. Creating WebRTC transport
|
||||
// 3. Connecting transport
|
||||
// 4. Creating producers for audio/video
|
||||
|
||||
this.sendMessage({ type: 'getRouterRtpCapabilities' });
|
||||
|
||||
// Simulate successful setup
|
||||
setTimeout(() => {
|
||||
this.sendMessage({ type: 'createWebRtcTransport' });
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
this.sendMessage({
|
||||
type: 'connectWebRtcTransport',
|
||||
data: { dtlsParameters: { fingerprints: [], role: 'client' } }
|
||||
});
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.localStream) {
|
||||
const videoTrack = this.localStream.getVideoTracks()[0];
|
||||
const audioTrack = this.localStream.getAudioTracks()[0];
|
||||
|
||||
if (videoTrack) {
|
||||
this.sendMessage({
|
||||
type: 'produce',
|
||||
data: {
|
||||
kind: 'video',
|
||||
rtpParameters: { codecs: [], encodings: [] }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (audioTrack) {
|
||||
this.sendMessage({
|
||||
type: 'produce',
|
||||
data: {
|
||||
kind: 'audio',
|
||||
rtpParameters: { codecs: [], encodings: [] }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
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.subscribersCountElement.textContent = '0';
|
||||
this.producersCountElement.textContent = this.isStreaming ? '2' : '0';
|
||||
}
|
||||
|
||||
private resetStats(): void {
|
||||
this.subscribersCountElement.textContent = '0';
|
||||
this.producersCountElement.textContent = '0';
|
||||
}
|
||||
}
|
||||
|
||||
new SFUPublisher();
|
||||
303
public/js/sfu-publisher.ts
Normal file
303
public/js/sfu-publisher.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
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();
|
||||
271
public/js/sfu-subscriber-simple.ts
Normal file
271
public/js/sfu-subscriber-simple.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
interface SFUMessage {
|
||||
type: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
class SFUSubscriber {
|
||||
private ws: WebSocket | null = null;
|
||||
private peerConnection: RTCPeerConnection | null = null;
|
||||
private isConnected = false;
|
||||
|
||||
private rtcConfiguration: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
// UI Elements
|
||||
private statusElement: HTMLElement;
|
||||
private qualityIndicator: HTMLElement;
|
||||
private bitrateIndicator: HTMLElement;
|
||||
private latencyIndicator: HTMLElement;
|
||||
private connectButton: HTMLButtonElement;
|
||||
private disconnectButton: HTMLButtonElement;
|
||||
private remoteVideo: HTMLVideoElement;
|
||||
private videoPlaceholder: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
this.statusElement = document.getElementById('status')!;
|
||||
this.qualityIndicator = document.getElementById('qualityIndicator')!;
|
||||
this.bitrateIndicator = document.getElementById('bitrateIndicator')!;
|
||||
this.latencyIndicator = document.getElementById('latencyIndicator')!;
|
||||
this.connectButton = document.getElementById('connectBtn') as HTMLButtonElement;
|
||||
this.disconnectButton = document.getElementById('disconnectBtn') as HTMLButtonElement;
|
||||
this.remoteVideo = document.getElementById('remoteVideo') as HTMLVideoElement;
|
||||
this.videoPlaceholder = document.getElementById('videoPlaceholder')!;
|
||||
|
||||
this.setupEventHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
this.connectButton.addEventListener('click', () => this.connect());
|
||||
this.disconnectButton.addEventListener('click', () => this.disconnect());
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
try {
|
||||
this.updateStatus('Connecting to SFU server...', 'waiting');
|
||||
this.setButtonStates(false, false);
|
||||
|
||||
await this.connectToServer();
|
||||
await this.createPeerConnection();
|
||||
await this.startSFUConsumption();
|
||||
|
||||
this.isConnected = true;
|
||||
this.updateStatus('Connected - Waiting for stream via SFU', 'waiting');
|
||||
this.setButtonStates(false, true);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error connecting:', error);
|
||||
this.updateStatus('Failed to connect to server: ' + error.message, 'disconnected');
|
||||
this.setButtonStates(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
private disconnect(): void {
|
||||
this.isConnected = false;
|
||||
|
||||
if (this.peerConnection) {
|
||||
this.peerConnection.close();
|
||||
this.peerConnection = null;
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
this.showVideoPlaceholder();
|
||||
this.updateStatus('Disconnected', 'disconnected');
|
||||
this.setButtonStates(true, false);
|
||||
this.resetIndicators();
|
||||
}
|
||||
|
||||
private async connectToServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket('ws://localhost:3001?role=subscriber');
|
||||
|
||||
this.ws.onopen = () => resolve();
|
||||
this.ws.onerror = (error) => reject(error);
|
||||
|
||||
this.ws.onmessage = async (event) => {
|
||||
try {
|
||||
const message: SFUMessage = JSON.parse(event.data);
|
||||
await this.handleServerMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = () => {
|
||||
if (this.isConnected) {
|
||||
this.updateStatus('Connection to server lost', 'disconnected');
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async handleServerMessage(message: SFUMessage): Promise<void> {
|
||||
console.log('Received message:', message.type);
|
||||
|
||||
switch (message.type) {
|
||||
case 'join':
|
||||
console.log('Joined as subscriber:', message.data);
|
||||
break;
|
||||
case 'newProducer':
|
||||
console.log('New producer available:', message.data.producerId);
|
||||
this.updateStatus('Publisher found - Requesting stream', 'waiting');
|
||||
// Request to consume the producer
|
||||
this.sendMessage({
|
||||
type: 'consume',
|
||||
data: {
|
||||
producerId: message.data.producerId,
|
||||
rtpCapabilities: {} // Simplified
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 'consumed':
|
||||
console.log('Consumer created:', message.data.consumerId);
|
||||
this.updateStatus('Connected - Receiving optimized stream', 'connected');
|
||||
this.hideVideoPlaceholder();
|
||||
this.updateStreamIndicators();
|
||||
// Resume the consumer
|
||||
this.sendMessage({
|
||||
type: 'resume',
|
||||
data: { consumerId: message.data.consumerId }
|
||||
});
|
||||
break;
|
||||
case 'resumed':
|
||||
console.log('Consumer resumed:', message.data.consumerId);
|
||||
break;
|
||||
case 'producers':
|
||||
console.log('Available producers:', message.data.producers);
|
||||
if (message.data.producers.length > 0) {
|
||||
// Try to consume the first available producer
|
||||
this.sendMessage({
|
||||
type: 'consume',
|
||||
data: {
|
||||
producerId: message.data.producers[0].id,
|
||||
rtpCapabilities: {}
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
console.error('Server error:', message.data.message);
|
||||
this.updateStatus('Server error: ' + message.data.message, 'disconnected');
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
}
|
||||
|
||||
private async createPeerConnection(): Promise<void> {
|
||||
this.peerConnection = new RTCPeerConnection(this.rtcConfiguration);
|
||||
|
||||
this.peerConnection.ontrack = (event) => {
|
||||
console.log('Received remote stream');
|
||||
const [remoteStream] = event.streams;
|
||||
this.remoteVideo.srcObject = remoteStream;
|
||||
this.hideVideoPlaceholder();
|
||||
this.updateStatus('Connected - Receiving stream via SFU', 'connected');
|
||||
this.updateStreamIndicators();
|
||||
};
|
||||
|
||||
this.peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.sendMessage({
|
||||
type: 'ice-candidate',
|
||||
data: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnection.onconnectionstatechange = () => {
|
||||
console.log('Connection state:', this.peerConnection?.connectionState);
|
||||
|
||||
switch (this.peerConnection?.connectionState) {
|
||||
case 'connected':
|
||||
this.updateStatus('Connected - Receiving stream via SFU', 'connected');
|
||||
break;
|
||||
case 'connecting':
|
||||
this.updateStatus('Connecting to stream...', 'waiting');
|
||||
break;
|
||||
case 'disconnected':
|
||||
case 'failed':
|
||||
this.updateStatus('Connection lost', 'disconnected');
|
||||
this.showVideoPlaceholder();
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async startSFUConsumption(): Promise<void> {
|
||||
// Simulate SFU consumption process
|
||||
// In a real SFU implementation, this would involve:
|
||||
// 1. Getting router RTP capabilities
|
||||
// 2. Creating WebRTC transport for receiving
|
||||
// 3. Requesting available producers
|
||||
// 4. Creating consumers for available streams
|
||||
|
||||
this.sendMessage({ type: 'getRouterRtpCapabilities' });
|
||||
|
||||
setTimeout(() => {
|
||||
this.sendMessage({ type: 'createWebRtcTransport' });
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
this.sendMessage({
|
||||
type: 'connectWebRtcTransport',
|
||||
data: { dtlsParameters: { fingerprints: [], role: 'client' } }
|
||||
});
|
||||
}, 200);
|
||||
|
||||
setTimeout(() => {
|
||||
this.sendMessage({ type: 'getProducers' });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
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(connectEnabled: boolean, disconnectEnabled: boolean): void {
|
||||
this.connectButton.disabled = !connectEnabled;
|
||||
this.disconnectButton.disabled = !disconnectEnabled;
|
||||
}
|
||||
|
||||
private hideVideoPlaceholder(): void {
|
||||
this.videoPlaceholder.style.display = 'none';
|
||||
this.remoteVideo.style.display = 'block';
|
||||
}
|
||||
|
||||
private showVideoPlaceholder(): void {
|
||||
this.videoPlaceholder.style.display = 'flex';
|
||||
this.remoteVideo.style.display = 'none';
|
||||
this.remoteVideo.srcObject = null;
|
||||
}
|
||||
|
||||
private updateStreamIndicators(): void {
|
||||
// Simulate SFU optimizations
|
||||
this.qualityIndicator.textContent = 'HD';
|
||||
this.bitrateIndicator.textContent = '2.5M';
|
||||
this.latencyIndicator.textContent = '45ms';
|
||||
}
|
||||
|
||||
private resetIndicators(): void {
|
||||
this.qualityIndicator.textContent = '-';
|
||||
this.bitrateIndicator.textContent = '-';
|
||||
this.latencyIndicator.textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
new SFUSubscriber();
|
||||
153
public/js/subscriber.ts
Normal file
153
public/js/subscriber.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { WebSocketClient, WebSocketClientStatusMapping } from './services/WebSocketClient.ts';
|
||||
import { UIController } from './services/UIController.ts';
|
||||
import { SubscriberRTCManager } from './services/SubscriberRTCManager.ts';
|
||||
import type { ISignalingMessage } from './interfaces/IWebRTCClient.ts';
|
||||
import { DisposableList } from '@techniker-me/tools';
|
||||
|
||||
class Subscriber {
|
||||
private readonly _disposables: DisposableList = new DisposableList();
|
||||
private wsClient: WebSocketClient;
|
||||
private uiController: UIController;
|
||||
private rtcManager: SubscriberRTCManager;
|
||||
private isConnected = false;
|
||||
private videoPlaceholder: HTMLElement | null;
|
||||
private remoteVideo: HTMLVideoElement | null;
|
||||
|
||||
constructor() {
|
||||
this.wsClient = new WebSocketClient('subscriber');
|
||||
this.uiController = new UIController('status', undefined, 'connectBtn', 'disconnectBtn');
|
||||
this.videoPlaceholder = document.getElementById('videoPlaceholder');
|
||||
this.remoteVideo = document.getElementById('remoteVideo') as HTMLVideoElement;
|
||||
|
||||
this.rtcManager = new SubscriberRTCManager(
|
||||
'remoteVideo',
|
||||
(stream) => this.onStreamReceived(stream),
|
||||
(state) => this.onConnectionStateChange(state)
|
||||
);
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.setupWebSocketHandlers();
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
this.uiController.onButtonClick('connectBtn', () => this.connect());
|
||||
this.uiController.onButtonClick('disconnectBtn', () => this.disconnect());
|
||||
}
|
||||
|
||||
private setupWebSocketHandlers(): void {
|
||||
this._disposables.add(this.wsClient.status.subscribe((status) => {
|
||||
this.uiController.updateStatus(WebSocketClientStatusMapping.convertWebSocketClientStatusToWebSocketClientStatusType(status), status);
|
||||
}));
|
||||
|
||||
this.wsClient.onMessage('join', (message) => {
|
||||
this.uiController.updateStatus('Connected - Waiting for publisher', 'waiting');
|
||||
this.isConnected = true;
|
||||
this.uiController.setButtonStates(false, true);
|
||||
});
|
||||
|
||||
this.wsClient.onMessage('publisher-joined', (message) => {
|
||||
this.uiController.updateStatus('Publisher available - Requesting stream', 'waiting');
|
||||
});
|
||||
|
||||
this.wsClient.onMessage('publisher-left', (message) => {
|
||||
this.uiController.updateStatus('Publisher disconnected', 'disconnected');
|
||||
this.showVideoPlaceholder();
|
||||
});
|
||||
|
||||
this.wsClient.onMessage('offer', async (message) => {
|
||||
try {
|
||||
const answer = await this.rtcManager.handleOffer(message.data);
|
||||
const answerMessage: ISignalingMessage = {
|
||||
type: 'answer',
|
||||
data: answer,
|
||||
targetId: message.senderId
|
||||
};
|
||||
this.wsClient.sendMessage(answerMessage);
|
||||
|
||||
this.uiController.updateStatus('Connecting to stream...', 'waiting');
|
||||
} catch (error) {
|
||||
console.error('Error handling offer:', error);
|
||||
this.uiController.updateStatus('Failed to connect to stream', 'disconnected');
|
||||
}
|
||||
});
|
||||
|
||||
this.wsClient.onMessage('ice-candidate', async (message) => {
|
||||
if (message.data) {
|
||||
await this.rtcManager.handleIceCandidate(message.data);
|
||||
}
|
||||
});
|
||||
|
||||
this.rtcManager.setOnIceCandidate((candidate) => {
|
||||
const message: ISignalingMessage = {
|
||||
type: 'ice-candidate',
|
||||
data: candidate
|
||||
};
|
||||
this.wsClient.sendMessage(message);
|
||||
});
|
||||
}
|
||||
|
||||
private async connect(): Promise<void> {
|
||||
try {
|
||||
this.uiController.updateStatus('Connecting to server...', 'waiting');
|
||||
this.uiController.setButtonStates(false, false);
|
||||
|
||||
await this.wsClient.connect();
|
||||
} catch (error) {
|
||||
console.error('Error connecting:', error);
|
||||
this.uiController.updateStatus('Failed to connect to server', 'disconnected');
|
||||
this.uiController.setButtonStates(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
private disconnect(): void {
|
||||
this.isConnected = false;
|
||||
this.rtcManager.disconnect();
|
||||
this.wsClient.disconnect();
|
||||
|
||||
this.uiController.updateStatus('Disconnected', 'disconnected');
|
||||
this.uiController.setButtonStates(true, false);
|
||||
this.showVideoPlaceholder();
|
||||
}
|
||||
|
||||
private onStreamReceived(stream: MediaStream): void {
|
||||
console.log('Stream received, showing video');
|
||||
this.uiController.updateStatus('Connected - Receiving stream', 'connected');
|
||||
this.hideVideoPlaceholder();
|
||||
}
|
||||
|
||||
private onConnectionStateChange(state: string): void {
|
||||
switch (state) {
|
||||
case 'connected':
|
||||
this.uiController.updateStatus('Connected - Receiving stream', 'connected');
|
||||
break;
|
||||
case 'connecting':
|
||||
this.uiController.updateStatus('Connecting to stream...', 'waiting');
|
||||
break;
|
||||
case 'disconnected':
|
||||
case 'failed':
|
||||
this.uiController.updateStatus('Connection lost', 'disconnected');
|
||||
this.showVideoPlaceholder();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private hideVideoPlaceholder(): void {
|
||||
if (this.videoPlaceholder) {
|
||||
this.videoPlaceholder.style.display = 'none';
|
||||
}
|
||||
if (this.remoteVideo) {
|
||||
this.remoteVideo.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
private showVideoPlaceholder(): void {
|
||||
if (this.videoPlaceholder) {
|
||||
this.videoPlaceholder.style.display = 'flex';
|
||||
}
|
||||
if (this.remoteVideo) {
|
||||
this.remoteVideo.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
new Subscriber();
|
||||
Reference in New Issue
Block a user