working poc
This commit is contained in:
@@ -14,14 +14,20 @@
|
||||
<video id="local-video" autoplay playsinline muted controls></video>
|
||||
<div class="controls-container">
|
||||
<button id="start-local-media">Start Local Media</button>
|
||||
<button id="create-and-send-offer" disabled>Create & Send Offer</button>
|
||||
<button id="send-offer" disabled>Send Offer</button>
|
||||
</div>
|
||||
<div>
|
||||
<p id="signaling-state">Signaling State: <span id="signaling-state-value">None</span></p>
|
||||
<p id="ice-connection-state">ICE Connection State: <span id="ice-connection-state-value">None</span></p>
|
||||
<p id="ice-gathering-state">ICE Gathering State: <span id="ice-gathering-state-value">None</span></p>
|
||||
<p id="connection-state">Connection State: <span id="connection-state-value">None</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="video-container">
|
||||
<h3 class="centered-text">Remote</h3>
|
||||
<video id="remote-video" autoplay playsinline muted></video>
|
||||
<div class="controls-container">
|
||||
<button id="create-and-send-answer" disabled>Create & Send Answer</button>
|
||||
<button id="send-answer" disabled>Send Answer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
9
frontend-web-vanilla/package-lock.json
generated
9
frontend-web-vanilla/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "frontend-web-vanilla",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@techniker-me/logger": "^0.0.15",
|
||||
"@techniker-me/tools": "^2025.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -576,6 +577,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@techniker-me/logger": {
|
||||
"version": "0.0.15",
|
||||
"resolved": "https://npm.techniker.me/@techniker-me/logger/-/logger-0.0.15.tgz",
|
||||
"integrity": "sha512-+6aB39lWTO2RDQLse2nZqfTXa7Kp78K7Xy7zobwBQlg01jR4zKmQAMkjQ4iduvnQYEU+1F2k6FDMco2E0mWZ4w==",
|
||||
"dependencies": {
|
||||
"@techniker-me/tools": "2025.0.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@techniker-me/tools": {
|
||||
"version": "2025.0.16",
|
||||
"resolved": "https://npm.techniker.me/@techniker-me/tools/-/tools-2025.0.16.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"vite": "npm:rolldown-vite@7.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@techniker-me/logger": "^0.0.15",
|
||||
"@techniker-me/tools": "^2025.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
170
frontend-web-vanilla/src/PeerConnection.ts
Normal file
170
frontend-web-vanilla/src/PeerConnection.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {Subject, ReadOnlySubject, Disposable, DisposableList, EventPublisher, type IEvent, type IDisposable} from '@techniker-me/tools';
|
||||
import type { IPeerConnectionOperations } from './interfaces/IPeerConnectionOperations';
|
||||
|
||||
export default class PeerConnection implements IPeerConnectionOperations {
|
||||
private readonly _disposables: DisposableList = new DisposableList();
|
||||
private readonly _peerConnection: RTCPeerConnection;
|
||||
private readonly _signalingState: Subject<RTCSignalingState>;
|
||||
private readonly _connectionState: Subject<RTCPeerConnectionState>;
|
||||
private readonly _iceGatheringState: Subject<RTCIceGatheringState>;
|
||||
private readonly _iceConnectionState: Subject<RTCIceConnectionState>;
|
||||
private readonly _readOnlySignalingState: ReadOnlySubject<RTCSignalingState>;
|
||||
private readonly _readOnlyConnectionState: ReadOnlySubject<RTCPeerConnectionState>;
|
||||
private readonly _readOnlyIceGatheringState: ReadOnlySubject<RTCIceGatheringState>;
|
||||
private readonly _readOnlyIceConnectionState: ReadOnlySubject<RTCIceConnectionState>;
|
||||
private readonly _eventEmitter: EventPublisher = new EventPublisher();
|
||||
|
||||
constructor(configuration: RTCConfiguration) {
|
||||
this._peerConnection = new RTCPeerConnection(configuration);
|
||||
this._signalingState = new Subject(this._peerConnection.signalingState);
|
||||
this._connectionState = new Subject(this._peerConnection.connectionState);
|
||||
this._iceGatheringState = new Subject(this._peerConnection.iceGatheringState);
|
||||
this._iceConnectionState = new Subject(this._peerConnection.iceConnectionState);
|
||||
this._readOnlySignalingState = new ReadOnlySubject(this._signalingState);
|
||||
this._readOnlyConnectionState = new ReadOnlySubject(this._connectionState);
|
||||
this._readOnlyIceGatheringState = new ReadOnlySubject(this._iceGatheringState);
|
||||
this._readOnlyIceConnectionState = new ReadOnlySubject(this._iceConnectionState);
|
||||
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
get signalingState(): ReadOnlySubject<RTCSignalingState> {
|
||||
return this._readOnlySignalingState;
|
||||
}
|
||||
|
||||
get connectionState(): ReadOnlySubject<RTCPeerConnectionState> {
|
||||
return this._readOnlyConnectionState;
|
||||
}
|
||||
|
||||
get iceGatheringState(): ReadOnlySubject<RTCIceGatheringState> {
|
||||
return this._readOnlyIceGatheringState;
|
||||
}
|
||||
|
||||
get iceConnectionState(): ReadOnlySubject<RTCIceConnectionState> {
|
||||
return this._readOnlyIceConnectionState;
|
||||
}
|
||||
|
||||
get currentRemoteDescription(): RTCSessionDescriptionInit | null {
|
||||
return this._peerConnection.remoteDescription;
|
||||
}
|
||||
|
||||
get currentLocalDescription(): RTCSessionDescriptionInit | null {
|
||||
return this._peerConnection.localDescription;
|
||||
}
|
||||
|
||||
public on<T>(event: string, callback: (event: IEvent<T>) => void | Promise<void>): IDisposable {
|
||||
return this._eventEmitter.subscribe(event, callback);
|
||||
}
|
||||
|
||||
public async createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
|
||||
try {
|
||||
const offer = await this._peerConnection.createOffer(options);
|
||||
return offer;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create offer [${error instanceof Error ? error.message : String(error)}]`);
|
||||
}
|
||||
}
|
||||
|
||||
public async createAnswer(options?: RTCAnswerOptions): Promise<RTCSessionDescriptionInit> {
|
||||
try {
|
||||
const answer = await this._peerConnection.createAnswer(options);
|
||||
|
||||
return answer;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to create answer [${error instanceof Error ? error.message : String(error)}]`);
|
||||
}
|
||||
}
|
||||
|
||||
public async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
await this._peerConnection.setRemoteDescription(description);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to set remote description: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async setLocalDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
await this._peerConnection.setLocalDescription(description);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to set local description: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async addIceCandidate(candidate: RTCIceCandidateInit | RTCIceCandidate): Promise<void> {
|
||||
try {
|
||||
await this._peerConnection.addIceCandidate(candidate);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to add ICE candidate: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
public addMediaStream(mediaStream: MediaStream): void {
|
||||
mediaStream.getTracks().forEach(track => {
|
||||
this._peerConnection.addTrack(track, mediaStream);
|
||||
});
|
||||
}
|
||||
|
||||
public getSignalingState(): RTCSignalingState {
|
||||
return this._peerConnection.signalingState;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
private setPeerConnectionStateEventListeners(peerConnection: RTCPeerConnection): void {
|
||||
peerConnection.onsignalingstatechange = () => {
|
||||
this._signalingState.value = peerConnection.signalingState;
|
||||
};
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
this._connectionState.value = peerConnection.connectionState;
|
||||
};
|
||||
peerConnection.onicegatheringstatechange = () => {
|
||||
this._iceGatheringState.value = peerConnection.iceGatheringState;
|
||||
};
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
this._iceConnectionState.value = peerConnection.iceConnectionState;
|
||||
};
|
||||
|
||||
this._disposables.add(new Disposable(() => {
|
||||
peerConnection.oniceconnectionstatechange = null;
|
||||
peerConnection.onicegatheringstatechange = null;
|
||||
peerConnection.onconnectionstatechange = null;
|
||||
peerConnection.onsignalingstatechange = null;
|
||||
}));
|
||||
}
|
||||
|
||||
private setPeerConnectionEventListeners(peerConnection: RTCPeerConnection): void {
|
||||
const iceCandidateHandler = (event: RTCPeerConnectionIceEvent) => {
|
||||
// Publish ICE candidate event so subscribers can send it via signaling
|
||||
// Note: event.candidate can be null when ICE gathering is complete
|
||||
this._eventEmitter.publish('icecandidate', {
|
||||
type: 'icecandidate',
|
||||
payload: event
|
||||
});
|
||||
};
|
||||
const trackHandler = (event: RTCTrackEvent) => {
|
||||
if (event.track) {
|
||||
// Publish the full RTCTrackEvent so subscribers can access streams, receiver, etc.
|
||||
this._eventEmitter.publish('track', {
|
||||
type: 'track',
|
||||
payload: event
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.onicecandidate = iceCandidateHandler;
|
||||
peerConnection.ontrack = trackHandler;
|
||||
|
||||
this._disposables.add(new Disposable(() => {
|
||||
peerConnection.onicecandidate = null;
|
||||
peerConnection.ontrack = null;
|
||||
}));
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
this.setPeerConnectionStateEventListeners(this._peerConnection);
|
||||
this.setPeerConnectionEventListeners(this._peerConnection);
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import {Subject, ReadOnlySubject, Disposable, DisposableList} from '@techniker-me/tools';
|
||||
|
||||
export default class Signaling {
|
||||
private readonly _peerConnection: RTCPeerConnection;
|
||||
private readonly _websocket: WebSocket = new WebSocket('ws://' + window.location.host + '/ws');
|
||||
private readonly _disposables: DisposableList = new DisposableList();
|
||||
|
||||
private readonly _signalingState: Subject<RTCSignalingState> = new Subject('closed');
|
||||
private readonly _connectionState: Subject<RTCPeerConnectionState> = new Subject('closed');
|
||||
private readonly _iceGatheringState: Subject<RTCIceGathererState> = new Subject('new');
|
||||
private readonly _iceConnectionState: Subject<RTCIceConnectionState> = new Subject('new');
|
||||
private readonly _readOnlySignalingState: ReadOnlySubject<RTCSignalingState> = new ReadOnlySubject(this._signalingState);
|
||||
private readonly _readOnlyConnectionState: ReadOnlySubject<RTCPeerConnectionState> = new ReadOnlySubject(this._connectionState);
|
||||
private readonly _readOnlyIceGatheringState: ReadOnlySubject<RTCIceGathererState> = new ReadOnlySubject(this._iceGatheringState);
|
||||
private readonly _readOnlyIceConnectionState: ReadOnlySubject<RTCIceConnectionState> = new ReadOnlySubject(this._iceConnectionState);
|
||||
|
||||
constructor(peerConnection: RTCPeerConnection) {
|
||||
this._peerConnection = peerConnection;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this.setPeerConnectionStateEventListeners(this._peerConnection);
|
||||
this.setPeerConnectionEventListeners(this._peerConnection);
|
||||
}
|
||||
|
||||
get signalingState(): ReadOnlySubject<RTCSignalingState> {
|
||||
return this._readOnlySignalingState;
|
||||
}
|
||||
|
||||
get connectionState(): ReadOnlySubject<RTCPeerConnectionState> {
|
||||
return this._readOnlyConnectionState;
|
||||
}
|
||||
|
||||
get iceGatheringState(): ReadOnlySubject<RTCIceGathererState> {
|
||||
return this._readOnlyIceGatheringState;
|
||||
}
|
||||
|
||||
get iceConnectionState(): ReadOnlySubject<RTCIceConnectionState> {
|
||||
return this._readOnlyIceConnectionState;
|
||||
}
|
||||
|
||||
get currentRemoteDescription(): RTCSessionDescriptionInit | null {
|
||||
return this._peerConnection.remoteDescription;
|
||||
}
|
||||
|
||||
get currentLocalDescription(): RTCSessionDescriptionInit | null {
|
||||
return this._peerConnection.localDescription;
|
||||
}
|
||||
|
||||
public async createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
|
||||
const offer = await this._peerConnection.createOffer(options);
|
||||
|
||||
await this._peerConnection.setLocalDescription(offer);
|
||||
|
||||
return offer;
|
||||
}
|
||||
|
||||
public async createAnswer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit> {
|
||||
const answer = await this._peerConnection.createAnswer(options);
|
||||
|
||||
await this._peerConnection.setLocalDescription(answer);
|
||||
|
||||
return answer;
|
||||
}
|
||||
|
||||
public async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
await this._peerConnection.setRemoteDescription(description);
|
||||
}
|
||||
|
||||
public async setLocalDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
await this._peerConnection.setLocalDescription(description);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._disposables.dispose();
|
||||
}
|
||||
|
||||
private setPeerConnectionStateEventListeners(peerConnection: RTCPeerConnection): void {
|
||||
peerConnection.onsignalingstatechange = () => {
|
||||
this._signalingState.value = peerConnection.signalingState;
|
||||
};
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
this._connectionState.value = peerConnection.connectionState;
|
||||
};
|
||||
peerConnection.onicegatheringstatechange = () => {
|
||||
this._iceGatheringState.value = peerConnection.iceGatheringState;
|
||||
};
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
this._iceConnectionState.value = peerConnection.iceConnectionState;
|
||||
};
|
||||
|
||||
this._disposables.add(new Disposable(() => {
|
||||
peerConnection.oniceconnectionstatechange = null;
|
||||
peerConnection.onicegatheringstatechange = null;
|
||||
peerConnection.onconnectionstatechange = null;
|
||||
peerConnection.onsignalingstatechange = null;
|
||||
}));
|
||||
}
|
||||
|
||||
private setPeerConnectionEventListeners(peerConnection: RTCPeerConnection): void {
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this._websocket.send(JSON.stringify({
|
||||
type: 'icecandidate',
|
||||
payload: event.candidate
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
48
frontend-web-vanilla/src/SignalingServer.ts
Normal file
48
frontend-web-vanilla/src/SignalingServer.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ICandidateMessage, IAnswerMessage, IOfferMessage, IMessage } from "./messaging/IMessage";
|
||||
import { MessageKind } from "./messaging/MessageKind";
|
||||
import type { ISignalingClient } from "./interfaces/ISignalingClient";
|
||||
|
||||
export default class SignalingServer implements ISignalingClient {
|
||||
private readonly _webSocket: WebSocket;
|
||||
|
||||
constructor(webSocket: WebSocket) {
|
||||
this._webSocket = webSocket;
|
||||
}
|
||||
|
||||
public on<T>(kind: MessageKind, callback: (message: IMessage<T>) => Promise<void>): void {
|
||||
this._webSocket.addEventListener('message', (event) => {
|
||||
const message = JSON.parse(event.data) as IMessage<T>;
|
||||
|
||||
if (message.type === kind) {
|
||||
callback(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async sendOffer(offer: RTCSessionDescriptionInit): Promise<void> {
|
||||
const message: IOfferMessage = {
|
||||
type: MessageKind.Offer,
|
||||
payload: offer
|
||||
};
|
||||
|
||||
this._webSocket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
public async sendAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
|
||||
const message: IAnswerMessage = {
|
||||
type: MessageKind.Answer,
|
||||
payload: answer
|
||||
};
|
||||
|
||||
this._webSocket.send(JSON.stringify(message));
|
||||
}
|
||||
|
||||
public async sendCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
const message: ICandidateMessage = {
|
||||
type: MessageKind.Candidate,
|
||||
payload: candidate
|
||||
};
|
||||
|
||||
this._webSocket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
75
frontend-web-vanilla/src/User.ts
Normal file
75
frontend-web-vanilla/src/User.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {ILogger} from '@techniker-me/logger';
|
||||
import {LoggerFactory} from '@techniker-me/logger';
|
||||
import {ReadOnlySubject, Subject, type IEvent} from '@techniker-me/tools';
|
||||
import PeerConnection from './PeerConnection';
|
||||
import type { ISignalingClient } from './interfaces/ISignalingClient';
|
||||
|
||||
export default class User {
|
||||
private readonly _logger: ILogger = LoggerFactory.getLogger('User');
|
||||
private readonly _localVideoElement: HTMLVideoElement;
|
||||
private readonly _remoteVideoElement: HTMLVideoElement;
|
||||
private readonly _peerConnection: PeerConnection;
|
||||
private readonly _signalingClient: ISignalingClient;
|
||||
private readonly _mediaStream: Subject<MediaStream | null> = new Subject(null);
|
||||
private readonly _readOnlyMediaStream: ReadOnlySubject<MediaStream | null> = new ReadOnlySubject(this._mediaStream);
|
||||
|
||||
constructor(localVideoElement: HTMLVideoElement, remoteVideoElement: HTMLVideoElement, peerConnection: PeerConnection, signalingClient: ISignalingClient) {
|
||||
this._localVideoElement = localVideoElement;
|
||||
this._remoteVideoElement = remoteVideoElement;
|
||||
this._peerConnection = peerConnection;
|
||||
this._signalingClient = signalingClient;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
get videoElement(): HTMLVideoElement {
|
||||
return this._localVideoElement;
|
||||
}
|
||||
|
||||
get peerConnection(): PeerConnection {
|
||||
return this._peerConnection;
|
||||
}
|
||||
|
||||
get mediaStream(): ReadOnlySubject<MediaStream | null> {
|
||||
return this._readOnlyMediaStream;
|
||||
}
|
||||
|
||||
public async startLocalMedia(): Promise<void> {
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||
|
||||
this._peerConnection.addMediaStream(mediaStream);
|
||||
|
||||
this._mediaStream.value = mediaStream;
|
||||
this._localVideoElement.srcObject = mediaStream;
|
||||
this._logger.info('Local media started');
|
||||
}
|
||||
|
||||
public async stopLocalMedia(): Promise<void> {
|
||||
this._mediaStream.value?.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
});
|
||||
this._mediaStream.value = null;
|
||||
this._localVideoElement.srcObject = null;
|
||||
}
|
||||
|
||||
private initialize(): void {
|
||||
this._peerConnection.on<RTCPeerConnectionIceEvent>('icecandidate', (event: IEvent<RTCPeerConnectionIceEvent>) => {
|
||||
// Only send candidate if it exists (null means ICE gathering is complete)
|
||||
if (event.payload?.candidate) {
|
||||
this._signalingClient.sendCandidate(event.payload.candidate);
|
||||
}
|
||||
});
|
||||
this._peerConnection.on<RTCTrackEvent>('track', (event: IEvent<RTCTrackEvent>) => {
|
||||
if (!event.payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Track event', event);
|
||||
|
||||
if (event.payload.streams?.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._remoteVideoElement.srcObject = event.payload.streams[0];
|
||||
});
|
||||
}
|
||||
}
|
||||
120
frontend-web-vanilla/src/call/CallCoordinator.ts
Normal file
120
frontend-web-vanilla/src/call/CallCoordinator.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Caller } from './Caller';
|
||||
import { Callee } from './Callee';
|
||||
import type { ISignalingClient } from '../interfaces/ISignalingClient';
|
||||
import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations';
|
||||
import { MessageKind } from '../messaging/MessageKind';
|
||||
import type { IMessage } from '../messaging/IMessage';
|
||||
import type { ILogger } from '@techniker-me/logger';
|
||||
import { LoggerFactory } from '@techniker-me/logger';
|
||||
|
||||
/**
|
||||
* CallCoordinator - coordinates between Caller and Callee roles
|
||||
* Follows Single Responsibility Principle - manages call flow coordination
|
||||
* Follows Open/Closed Principle - can be extended without modification
|
||||
*/
|
||||
export class CallCoordinator {
|
||||
private readonly _logger: ILogger = LoggerFactory.getLogger('CallCoordinator');
|
||||
private readonly _caller: Caller;
|
||||
private readonly _callee: Callee;
|
||||
private readonly _signalingClient: ISignalingClient;
|
||||
private readonly _peerConnection: IPeerConnectionOperations;
|
||||
private _lastReceivedOffer: RTCSessionDescriptionInit | null = null;
|
||||
|
||||
constructor(
|
||||
signalingClient: ISignalingClient,
|
||||
peerConnection: IPeerConnectionOperations
|
||||
) {
|
||||
this._signalingClient = signalingClient;
|
||||
this._peerConnection = peerConnection;
|
||||
this._caller = new Caller(signalingClient, peerConnection);
|
||||
this._callee = new Callee(signalingClient, peerConnection);
|
||||
|
||||
this.setupMessageHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set this instance as the caller and initiate a call
|
||||
*/
|
||||
public async initiateCall(): Promise<void> {
|
||||
await this._caller.createAndSendOffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually create and send an answer (for callee)
|
||||
* This is called when the callee clicks the "Send Answer" button
|
||||
* Following the sequence diagram:
|
||||
* 1. Create Answer (remote description already set when offer was received)
|
||||
* 2. Set local description to Answer
|
||||
* 3. Send Answer to signaling server
|
||||
*/
|
||||
public async createAndSendAnswer(): Promise<void> {
|
||||
const currentState = this._peerConnection.getSignalingState();
|
||||
|
||||
// Verify we have a remote offer
|
||||
if (currentState !== 'have-remote-offer' && currentState !== 'have-remote-pranswer') {
|
||||
throw new Error('Cannot send answer: no remote offer received. Current state: ' + currentState);
|
||||
}
|
||||
|
||||
if (!this._lastReceivedOffer) {
|
||||
throw new Error('Cannot send answer: no offer stored');
|
||||
}
|
||||
|
||||
this._logger.info('Creating and sending answer manually...');
|
||||
|
||||
// Step 1: Create Answer (remote description was already set when offer was received)
|
||||
const answer = await this._peerConnection.createAnswer();
|
||||
this._logger.info('Answer created');
|
||||
|
||||
// Step 2: Set local description to Answer
|
||||
await this._peerConnection.setLocalDescription(answer);
|
||||
this._logger.info('Local description set to answer');
|
||||
|
||||
// Step 3: Send Answer to signaling server
|
||||
await this._signalingClient.sendAnswer(answer);
|
||||
this._logger.info('Answer sent to signaling server');
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup message handlers for offer and answer
|
||||
*/
|
||||
private setupMessageHandlers(): void {
|
||||
// Handle incoming offers (when acting as callee)
|
||||
// Only set remote description - don't automatically create/send answer
|
||||
// The answer will be sent when the user clicks the "Send Answer" button
|
||||
this._signalingClient.on<RTCSessionDescriptionInit>(
|
||||
MessageKind.Offer,
|
||||
async (message: IMessage<RTCSessionDescriptionInit>) => {
|
||||
// Only handle offer if we're not the caller (don't have local offer)
|
||||
const currentState = this._peerConnection.getSignalingState();
|
||||
if (currentState === 'have-local-offer' || currentState === 'have-local-pranswer') {
|
||||
this._logger.info('Already have local offer, ignoring incoming offer');
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.info('Received offer, setting remote description (waiting for manual answer)');
|
||||
this._lastReceivedOffer = message.payload;
|
||||
|
||||
// Only set the remote description - don't create/send answer yet
|
||||
// This allows the callee to manually click "Send Answer" button
|
||||
await this._peerConnection.setRemoteDescription(message.payload);
|
||||
}
|
||||
);
|
||||
|
||||
// Handle incoming answers (when acting as caller)
|
||||
this._signalingClient.on<RTCSessionDescriptionInit>(
|
||||
MessageKind.Answer,
|
||||
async (message: IMessage<RTCSessionDescriptionInit>) => {
|
||||
// Only handle answer if we're the caller (have local offer)
|
||||
const currentState = this._peerConnection.getSignalingState();
|
||||
if (currentState !== 'have-local-offer' && currentState !== 'have-local-pranswer') {
|
||||
this._logger.info('Not in caller state, ignoring incoming answer');
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.info('Received answer, acting as caller');
|
||||
await this._caller.handleAnswer(message.payload);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
57
frontend-web-vanilla/src/call/Callee.ts
Normal file
57
frontend-web-vanilla/src/call/Callee.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ISignalingClient } from '../interfaces/ISignalingClient';
|
||||
import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations';
|
||||
import type { ILogger } from '@techniker-me/logger';
|
||||
import { LoggerFactory } from '@techniker-me/logger';
|
||||
|
||||
/**
|
||||
* Callee class - responsible for receiving and responding to WebRTC calls
|
||||
* Follows Single Responsibility Principle - only handles answer creation flow
|
||||
* Follows Dependency Inversion Principle - depends on abstractions (interfaces)
|
||||
*/
|
||||
export class Callee {
|
||||
private readonly _logger: ILogger = LoggerFactory.getLogger('Callee');
|
||||
private readonly _signalingClient: ISignalingClient;
|
||||
private readonly _peerConnection: IPeerConnectionOperations;
|
||||
|
||||
constructor(
|
||||
signalingClient: ISignalingClient,
|
||||
peerConnection: IPeerConnectionOperations
|
||||
) {
|
||||
this._signalingClient = signalingClient;
|
||||
this._peerConnection = peerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle receiving an offer from the caller
|
||||
* Following the sequence diagram:
|
||||
* 1. Set remote description to offer
|
||||
* 2. Create Answer
|
||||
* 3. Set local description to Answer
|
||||
* 4. Send Answer to signaling server
|
||||
*/
|
||||
public async handleOffer(offer: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
this._logger.info('Handling offer from caller...');
|
||||
|
||||
// Step 1: Set remote description to offer
|
||||
await this._peerConnection.setRemoteDescription(offer);
|
||||
this._logger.info('Remote description set to offer');
|
||||
|
||||
// Step 2: Create Answer
|
||||
const answer = await this._peerConnection.createAnswer();
|
||||
this._logger.info('Answer created');
|
||||
|
||||
// Step 3: Set local description to Answer
|
||||
await this._peerConnection.setLocalDescription(answer);
|
||||
this._logger.info('Local description set to answer');
|
||||
|
||||
// Step 4: Send Answer to signaling server
|
||||
await this._signalingClient.sendAnswer(answer);
|
||||
this._logger.info('Answer sent to signaling server');
|
||||
} catch (error) {
|
||||
this._logger.error('Failed to handle offer', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
67
frontend-web-vanilla/src/call/Caller.ts
Normal file
67
frontend-web-vanilla/src/call/Caller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ISignalingClient } from '../interfaces/ISignalingClient';
|
||||
import type { IPeerConnectionOperations } from '../interfaces/IPeerConnectionOperations';
|
||||
import type { ILogger } from '@techniker-me/logger';
|
||||
import { LoggerFactory } from '@techniker-me/logger';
|
||||
|
||||
/**
|
||||
* Caller class - responsible for initiating WebRTC calls
|
||||
* Follows Single Responsibility Principle - only handles offer creation flow
|
||||
* Follows Dependency Inversion Principle - depends on abstractions (interfaces)
|
||||
*/
|
||||
export class Caller {
|
||||
private readonly _logger: ILogger = LoggerFactory.getLogger('Caller');
|
||||
private readonly _signalingClient: ISignalingClient;
|
||||
private readonly _peerConnection: IPeerConnectionOperations;
|
||||
|
||||
constructor(
|
||||
signalingClient: ISignalingClient,
|
||||
peerConnection: IPeerConnectionOperations
|
||||
) {
|
||||
this._signalingClient = signalingClient;
|
||||
this._peerConnection = peerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and send an offer following the sequence diagram:
|
||||
* 1. Create Offer
|
||||
* 2. Set local description to offer
|
||||
* 3. Send Offer to signaling server
|
||||
*/
|
||||
public async createAndSendOffer(): Promise<void> {
|
||||
try {
|
||||
this._logger.info('Creating offer...');
|
||||
|
||||
// Step 1: Create Offer
|
||||
const offer = await this._peerConnection.createOffer();
|
||||
|
||||
// Step 2: Set local description to offer
|
||||
await this._peerConnection.setLocalDescription(offer);
|
||||
this._logger.info('Local description set to offer');
|
||||
|
||||
// Step 3: Send Offer to signaling server
|
||||
await this._signalingClient.sendOffer(offer);
|
||||
this._logger.info('Offer sent to signaling server');
|
||||
} catch (error) {
|
||||
this._logger.error('Failed to create and send offer', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle receiving an answer from the callee
|
||||
* Sets the answer as remote description
|
||||
*/
|
||||
public async handleAnswer(answer: RTCSessionDescriptionInit): Promise<void> {
|
||||
try {
|
||||
this._logger.info('Handling answer from callee...');
|
||||
|
||||
// Set Answer as remote description
|
||||
await this._peerConnection.setRemoteDescription(answer);
|
||||
this._logger.info('Answer set as remote description');
|
||||
} catch (error) {
|
||||
this._logger.error('Failed to handle answer', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Interface for peer connection operations
|
||||
* Follows Interface Segregation Principle - focused interface for WebRTC operations
|
||||
*/
|
||||
export interface IPeerConnectionOperations {
|
||||
/**
|
||||
* Create a WebRTC offer
|
||||
*/
|
||||
createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionInit>;
|
||||
|
||||
/**
|
||||
* Create a WebRTC answer
|
||||
*/
|
||||
createAnswer(options?: RTCAnswerOptions): Promise<RTCSessionDescriptionInit>;
|
||||
|
||||
/**
|
||||
* Set the remote description
|
||||
*/
|
||||
setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void>;
|
||||
|
||||
/**
|
||||
* Set the local description
|
||||
*/
|
||||
setLocalDescription(description: RTCSessionDescriptionInit): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get the current signaling state
|
||||
*/
|
||||
getSignalingState(): RTCSignalingState;
|
||||
}
|
||||
|
||||
29
frontend-web-vanilla/src/interfaces/ISignalingClient.ts
Normal file
29
frontend-web-vanilla/src/interfaces/ISignalingClient.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IMessage } from '../messaging/IMessage';
|
||||
import { MessageKind } from '../messaging/MessageKind';
|
||||
|
||||
/**
|
||||
* Interface for signaling client operations
|
||||
* Follows Interface Segregation Principle - focused interface for signaling
|
||||
*/
|
||||
export interface ISignalingClient {
|
||||
/**
|
||||
* Register a callback for a specific message type
|
||||
*/
|
||||
on<T>(kind: MessageKind, callback: (message: IMessage<T>) => Promise<void>): void;
|
||||
|
||||
/**
|
||||
* Send an offer to the signaling server
|
||||
*/
|
||||
sendOffer(offer: RTCSessionDescriptionInit): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send an answer to the signaling server
|
||||
*/
|
||||
sendAnswer(answer: RTCSessionDescriptionInit): Promise<void>;
|
||||
|
||||
/**
|
||||
* Send an ICE candidate to the signaling server
|
||||
*/
|
||||
sendCandidate(candidate: RTCIceCandidateInit): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,73 +1,112 @@
|
||||
import PeerConnection from './PeerConnection';
|
||||
import User from './User';
|
||||
import SignalingServer from './SignalingServer';
|
||||
import { CallCoordinator } from './call/CallCoordinator';
|
||||
import { MessageKind } from './messaging/MessageKind';
|
||||
import type { IMessage } from './messaging/IMessage';
|
||||
import type { ISignalingClient } from './interfaces/ISignalingClient';
|
||||
|
||||
type Elements = {
|
||||
localVideo: HTMLVideoElement;
|
||||
remoteVideo: HTMLVideoElement;
|
||||
startLocalMediaButton: HTMLButtonElement;
|
||||
createAndSendOfferButton: HTMLButtonElement;
|
||||
createAndSendAnswerButton: HTMLButtonElement;
|
||||
sendOfferButton: HTMLButtonElement;
|
||||
sendAnswerButton: HTMLButtonElement;
|
||||
signalingStateValue: HTMLSpanElement;
|
||||
iceConnectionStateValue: HTMLSpanElement;
|
||||
iceGatheringStateValue: HTMLSpanElement;
|
||||
connectionStateValue: HTMLSpanElement;
|
||||
}
|
||||
|
||||
type ApplicationState = {
|
||||
localMediaStream: MediaStream | null;
|
||||
remoteMediaStream: MediaStream | null;
|
||||
peerConnection: RTCPeerConnection;
|
||||
offer: RTCSessionDescriptionInit | null;
|
||||
answer: RTCSessionDescriptionInit | null;
|
||||
};
|
||||
|
||||
|
||||
const elements: Elements = {
|
||||
localVideo: document.getElementById('local-video') as HTMLVideoElement,
|
||||
remoteVideo: document.getElementById('remote-video') as HTMLVideoElement,
|
||||
startLocalMediaButton: document.getElementById('start-local-media') as HTMLButtonElement,
|
||||
createAndSendOfferButton: document.getElementById('create-and-send-offer') as HTMLButtonElement,
|
||||
createAndSendAnswerButton: document.getElementById('create-and-send-answer') as HTMLButtonElement
|
||||
sendOfferButton: document.getElementById('send-offer') as HTMLButtonElement,
|
||||
sendAnswerButton: document.getElementById('send-answer') as HTMLButtonElement,
|
||||
signalingStateValue: document.getElementById('signaling-state-value') as HTMLSpanElement,
|
||||
iceConnectionStateValue: document.getElementById('ice-connection-state-value') as HTMLSpanElement,
|
||||
iceGatheringStateValue: document.getElementById('ice-gathering-state-value') as HTMLSpanElement,
|
||||
connectionStateValue: document.getElementById('connection-state-value') as HTMLSpanElement
|
||||
}
|
||||
|
||||
const state: ApplicationState = {
|
||||
localMediaStream: null,
|
||||
remoteMediaStream: null,
|
||||
peerConnection: new RTCPeerConnection(),
|
||||
offer: null,
|
||||
answer: null,
|
||||
|
||||
// Initialize WebSocket and signaling server
|
||||
const websocket = new WebSocket(`ws://${window.location.hostname}:3000/ws`);
|
||||
const signalingServer = new SignalingServer(websocket);
|
||||
|
||||
// Initialize peer connection configuration
|
||||
const peerConnectionConfiguration: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{
|
||||
urls: 'stun:stun.l.google.com:19302'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const actions = {
|
||||
getLocalMediaStream: (constraints = {audio: true, video: true}) => navigator.mediaDevices.getUserMedia(constraints),
|
||||
setMediaStream: (target: HTMLVideoElement, stream: MediaStream): void => {target.srcObject = stream},
|
||||
createOffer: (connection: RTCPeerConnection): Promise<RTCSessionDescriptionInit> => connection.createOffer(),
|
||||
createAnswer: (connection: RTCPeerConnection): Promise<RTCSessionDescriptionInit> => connection.createAnswer(),
|
||||
enableButton: (button: HTMLButtonElement): void => {button.disabled = false},
|
||||
disableButton: (button: HTMLButtonElement): void => {button.disabled = true},
|
||||
sendMessage: (message: string): void => {
|
||||
// TODO: Implement message sending
|
||||
},
|
||||
receiveMessage: (message: string): void => {
|
||||
// TODO: Implement message receiving
|
||||
}
|
||||
|
||||
};
|
||||
// Initialize peer connection and user
|
||||
const peerConnection = new PeerConnection(peerConnectionConfiguration);
|
||||
const user = new User(elements.localVideo, elements.remoteVideo, peerConnection, signalingServer as ISignalingClient);
|
||||
|
||||
elements.startLocalMediaButton.addEventListener('click', () => {
|
||||
actions.getLocalMediaStream()
|
||||
.then(mediaStream => actions.setMediaStream(elements.localVideo, mediaStream))
|
||||
.then(() => actions.enableButton(elements.createAndSendOfferButton))
|
||||
.then(() => actions.disableButton(elements.startLocalMediaButton))
|
||||
// Initialize call coordinator (handles caller/callee logic following SOLID principles)
|
||||
const callCoordinator = new CallCoordinator(signalingServer, peerConnection);
|
||||
|
||||
// Setup ICE candidate handling
|
||||
signalingServer.on<RTCIceCandidateInit>(MessageKind.Candidate, async (message: IMessage<RTCIceCandidateInit>) => {
|
||||
console.log('Candidate received', message);
|
||||
await peerConnection.addIceCandidate(message.payload);
|
||||
});
|
||||
|
||||
elements.createAndSendOfferButton.addEventListener('click', () => {
|
||||
actions.createOffer(state.peerConnection)
|
||||
.then(offer => state.offer = offer)
|
||||
.then(() => {
|
||||
if (!state.offer) {
|
||||
throw new Error('Offer not created');
|
||||
}
|
||||
|
||||
return state.peerConnection.setLocalDescription(state.offer)
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
})
|
||||
.then(() => actions.enableButton(elements.createAndSendAnswerButton))
|
||||
.then(() => actions.disableButton(elements.createAndSendOfferButton))
|
||||
// Setup UI state subscriptions
|
||||
peerConnection.signalingState.subscribe((state) => {
|
||||
elements.signalingStateValue.textContent = state;
|
||||
|
||||
// Enable send answer button when remote offer is received
|
||||
if (state === 'have-remote-offer' || state === 'have-remote-pranswer') {
|
||||
elements.sendAnswerButton.disabled = false;
|
||||
} else if (state === 'stable' || state === 'have-local-offer') {
|
||||
elements.sendAnswerButton.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
peerConnection.iceConnectionState.subscribe((state) => {
|
||||
elements.iceConnectionStateValue.textContent = state;
|
||||
});
|
||||
|
||||
peerConnection.iceGatheringState.subscribe((state) => {
|
||||
elements.iceGatheringStateValue.textContent = state;
|
||||
});
|
||||
|
||||
peerConnection.connectionState.subscribe((state) => {
|
||||
elements.connectionStateValue.textContent = state;
|
||||
});
|
||||
|
||||
// Setup button event handlers
|
||||
elements.startLocalMediaButton.addEventListener('click', async () => {
|
||||
await user.startLocalMedia();
|
||||
elements.startLocalMediaButton.disabled = true;
|
||||
elements.sendOfferButton.disabled = false;
|
||||
});
|
||||
|
||||
elements.sendOfferButton.addEventListener('click', async () => {
|
||||
try {
|
||||
await callCoordinator.initiateCall();
|
||||
elements.sendOfferButton.disabled = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initiate call', error);
|
||||
}
|
||||
});
|
||||
|
||||
elements.sendAnswerButton.addEventListener('click', async () => {
|
||||
try {
|
||||
// Start local media if not already started (for callee)
|
||||
if (!user.mediaStream.value) {
|
||||
await user.startLocalMedia();
|
||||
elements.startLocalMediaButton.disabled = true;
|
||||
}
|
||||
|
||||
await callCoordinator.createAndSendAnswer();
|
||||
elements.sendAnswerButton.disabled = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to send answer', error);
|
||||
}
|
||||
});
|
||||
24
frontend-web-vanilla/src/messaging/IMessage.ts
Normal file
24
frontend-web-vanilla/src/messaging/IMessage.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type {MessageType} from './MessageKind';
|
||||
|
||||
export interface IMessage<T> {
|
||||
type: MessageType;
|
||||
payload: T;
|
||||
}
|
||||
|
||||
|
||||
export type IOfferMessage = {
|
||||
type: 'offer';
|
||||
payload: RTCSessionDescriptionInit;
|
||||
}
|
||||
|
||||
export type IAnswerMessage = {
|
||||
type: 'answer';
|
||||
payload: RTCSessionDescriptionInit;
|
||||
}
|
||||
|
||||
export type ICandidateMessage = {
|
||||
type: 'candidate';
|
||||
payload: RTCIceCandidateInit;
|
||||
}
|
||||
|
||||
export type Message = IOfferMessage | IAnswerMessage | ICandidateMessage;
|
||||
32
frontend-web-vanilla/src/messaging/MessageKind.ts
Normal file
32
frontend-web-vanilla/src/messaging/MessageKind.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export enum MessageKind {
|
||||
Offer = 0,
|
||||
Answer = 1,
|
||||
Candidate = 2
|
||||
}
|
||||
|
||||
export type MessageKindType = 'offer' | 'answer' | 'candidate';
|
||||
export type MessageType = MessageKindType;
|
||||
|
||||
export class MessageKindMapping {
|
||||
public static convertMessageKindToMessageType(messageKind: MessageKind): MessageType {
|
||||
switch (messageKind) {
|
||||
case MessageKind.Offer:
|
||||
return 'offer';
|
||||
case MessageKind.Answer:
|
||||
return 'answer';
|
||||
case MessageKind.Candidate:
|
||||
return 'candidate';
|
||||
}
|
||||
}
|
||||
|
||||
public static convertMessageTypeToMessageKind(messageType: MessageType): MessageKind {
|
||||
switch (messageType) {
|
||||
case 'offer':
|
||||
return MessageKind.Offer;
|
||||
case 'answer':
|
||||
return MessageKind.Answer;
|
||||
case 'candidate':
|
||||
return MessageKind.Candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"erasableSyntaxOnly": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user