fixed tests
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user