465 lines
19 KiB
TypeScript
465 lines
19 KiB
TypeScript
/**
|
||
* WebRTCService — thin Angular service that composes specialised managers.
|
||
*
|
||
* Each concern lives in its own file under `./webrtc/`:
|
||
* • SignalingManager – WebSocket lifecycle & reconnection
|
||
* • PeerConnectionManager – RTCPeerConnection, offers/answers, ICE, data channels
|
||
* • MediaManager – mic voice, mute, deafen, bitrate
|
||
* • ScreenShareManager – screen capture & mixed audio
|
||
* • WebRTCLogger – debug / diagnostic logging
|
||
*
|
||
* This file wires them together and exposes a public API that is
|
||
* identical to the old monolithic service so consumers don't change.
|
||
*/
|
||
import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core';
|
||
import { Observable, Subject } from 'rxjs';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import { SignalingMessage, ChatEvent } from '../models';
|
||
import { TimeSyncService } from './time-sync.service';
|
||
|
||
import {
|
||
// Managers
|
||
SignalingManager,
|
||
PeerConnectionManager,
|
||
MediaManager,
|
||
ScreenShareManager,
|
||
WebRTCLogger,
|
||
// Types
|
||
IdentifyCredentials,
|
||
JoinedServerInfo,
|
||
VoiceStateSnapshot,
|
||
LatencyProfile,
|
||
// Constants
|
||
SIGNALING_TYPE_IDENTIFY,
|
||
SIGNALING_TYPE_JOIN_SERVER,
|
||
SIGNALING_TYPE_VIEW_SERVER,
|
||
SIGNALING_TYPE_LEAVE_SERVER,
|
||
SIGNALING_TYPE_OFFER,
|
||
SIGNALING_TYPE_ANSWER,
|
||
SIGNALING_TYPE_ICE_CANDIDATE,
|
||
SIGNALING_TYPE_CONNECTED,
|
||
SIGNALING_TYPE_SERVER_USERS,
|
||
SIGNALING_TYPE_USER_JOINED,
|
||
SIGNALING_TYPE_USER_LEFT,
|
||
DEFAULT_DISPLAY_NAME,
|
||
P2P_TYPE_VOICE_STATE,
|
||
P2P_TYPE_SCREEN_STATE,
|
||
} from './webrtc';
|
||
|
||
@Injectable({
|
||
providedIn: 'root',
|
||
})
|
||
export class WebRTCService implements OnDestroy {
|
||
private readonly timeSync = inject(TimeSyncService);
|
||
|
||
// ─── Logger ────────────────────────────────────────────────────────
|
||
private readonly logger = new WebRTCLogger(/* debugEnabled */ true);
|
||
|
||
// ─── Identity & server membership ──────────────────────────────────
|
||
private lastIdentifyCredentials: IdentifyCredentials | null = null;
|
||
private lastJoinedServer: JoinedServerInfo | null = null;
|
||
private readonly memberServerIds = new Set<string>();
|
||
private activeServerId: string | null = null;
|
||
private readonly serviceDestroyed$ = new Subject<void>();
|
||
|
||
// ─── Angular signals (reactive state) ──────────────────────────────
|
||
private readonly _localPeerId = signal<string>(uuidv4());
|
||
private readonly _isSignalingConnected = signal(false);
|
||
private readonly _isVoiceConnected = signal(false);
|
||
private readonly _connectedPeers = signal<string[]>([]);
|
||
private readonly _isMuted = signal(false);
|
||
private readonly _isDeafened = signal(false);
|
||
private readonly _isScreenSharing = signal(false);
|
||
private readonly _screenStreamSignal = signal<MediaStream | null>(null);
|
||
private readonly _hasConnectionError = signal(false);
|
||
private readonly _connectionErrorMessage = signal<string | null>(null);
|
||
|
||
// Public computed signals (unchanged external API)
|
||
readonly peerId = computed(() => this._localPeerId());
|
||
readonly isConnected = computed(() => this._isSignalingConnected());
|
||
readonly isVoiceConnected = computed(() => this._isVoiceConnected());
|
||
readonly connectedPeers = computed(() => this._connectedPeers());
|
||
readonly isMuted = computed(() => this._isMuted());
|
||
readonly isDeafened = computed(() => this._isDeafened());
|
||
readonly isScreenSharing = computed(() => this._isScreenSharing());
|
||
readonly screenStream = computed(() => this._screenStreamSignal());
|
||
readonly hasConnectionError = computed(() => this._hasConnectionError());
|
||
readonly connectionErrorMessage = computed(() => this._connectionErrorMessage());
|
||
readonly shouldShowConnectionError = computed(() => {
|
||
if (!this._hasConnectionError()) return false;
|
||
if (this._isVoiceConnected() && this._connectedPeers().length > 0) return false;
|
||
return true;
|
||
});
|
||
|
||
// ─── Public observables (unchanged external API) ───────────────────
|
||
private readonly signalingMessage$ = new Subject<SignalingMessage>();
|
||
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
||
|
||
// Delegates to managers
|
||
get onMessageReceived(): Observable<ChatEvent> { return this.peerManager.messageReceived$.asObservable(); }
|
||
get onPeerConnected(): Observable<string> { return this.peerManager.peerConnected$.asObservable(); }
|
||
get onPeerDisconnected(): Observable<string> { return this.peerManager.peerDisconnected$.asObservable(); }
|
||
get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerManager.remoteStream$.asObservable(); }
|
||
get onVoiceConnected(): Observable<void> { return this.mediaManager.voiceConnected$.asObservable(); }
|
||
|
||
// ─── Sub-managers ──────────────────────────────────────────────────
|
||
|
||
private readonly signalingManager: SignalingManager;
|
||
private readonly peerManager: PeerConnectionManager;
|
||
private readonly mediaManager: MediaManager;
|
||
private readonly screenShareManager: ScreenShareManager;
|
||
|
||
constructor() {
|
||
// Create managers with null callbacks first to break circular initialization
|
||
this.signalingManager = new SignalingManager(
|
||
this.logger,
|
||
() => this.lastIdentifyCredentials,
|
||
() => this.lastJoinedServer,
|
||
() => this.memberServerIds,
|
||
);
|
||
|
||
this.peerManager = new PeerConnectionManager(
|
||
this.logger,
|
||
null!,
|
||
);
|
||
|
||
this.mediaManager = new MediaManager(
|
||
this.logger,
|
||
null!,
|
||
);
|
||
|
||
this.screenShareManager = new ScreenShareManager(
|
||
this.logger,
|
||
null!,
|
||
);
|
||
|
||
// Now wire up cross-references (all managers are instantiated)
|
||
this.peerManager.setCallbacks({
|
||
sendRawMessage: (msg: Record<string, unknown>) => this.signalingManager.sendRawMessage(msg),
|
||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||
isSignalingConnected: (): boolean => this._isSignalingConnected(),
|
||
getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(),
|
||
getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials,
|
||
getLocalPeerId: (): string => this._localPeerId(),
|
||
isScreenSharingActive: (): boolean => this._isScreenSharing(),
|
||
});
|
||
|
||
this.mediaManager.setCallbacks({
|
||
getActivePeers: (): Map<string, import('./webrtc').PeerData> => this.peerManager.activePeerConnections,
|
||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||
broadcastMessage: (event: any): void => this.peerManager.broadcastMessage(event),
|
||
getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(),
|
||
getIdentifyDisplayName: (): string => this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME,
|
||
});
|
||
|
||
this.screenShareManager.setCallbacks({
|
||
getActivePeers: (): Map<string, import('./webrtc').PeerData> => this.peerManager.activePeerConnections,
|
||
getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(),
|
||
renegotiate: (peerId: string): Promise<void> => this.peerManager.renegotiate(peerId),
|
||
broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(),
|
||
});
|
||
|
||
this.wireManagerEvents();
|
||
}
|
||
|
||
// ─── Event wiring ──────────────────────────────────────────────────
|
||
|
||
private wireManagerEvents(): void {
|
||
// Signaling → connection status
|
||
this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => {
|
||
this._isSignalingConnected.set(connected);
|
||
this._hasConnectionError.set(!connected);
|
||
this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null));
|
||
});
|
||
|
||
// Signaling → message routing
|
||
this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg));
|
||
|
||
// Signaling → heartbeat → broadcast states
|
||
this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates());
|
||
|
||
// Peer manager → connected peers signal
|
||
this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => this._connectedPeers.set(peers));
|
||
|
||
// Media manager → voice connected signal
|
||
this.mediaManager.voiceConnected$.subscribe(() => {
|
||
this._isVoiceConnected.set(true);
|
||
});
|
||
}
|
||
|
||
// ─── Signaling message routing ─────────────────────────────────────
|
||
|
||
private handleSignalingMessage(message: any): void {
|
||
this.signalingMessage$.next(message);
|
||
this.logger.info('Signaling message', { type: message.type });
|
||
|
||
switch (message.type) {
|
||
case SIGNALING_TYPE_CONNECTED:
|
||
this.logger.info('Server connected', { oderId: message.oderId });
|
||
if (typeof message.serverTime === 'number') {
|
||
this.timeSync.setFromServerTime(message.serverTime);
|
||
}
|
||
break;
|
||
|
||
case SIGNALING_TYPE_SERVER_USERS:
|
||
this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0 });
|
||
if (message.users && Array.isArray(message.users)) {
|
||
message.users.forEach((user: { oderId: string; displayName: string }) => {
|
||
if (user.oderId && !this.peerManager.activePeerConnections.has(user.oderId)) {
|
||
this.logger.info('Create peer connection to existing user', { oderId: user.oderId });
|
||
this.peerManager.createPeerConnection(user.oderId, true);
|
||
this.peerManager.createAndSendOffer(user.oderId);
|
||
}
|
||
});
|
||
}
|
||
break;
|
||
|
||
case SIGNALING_TYPE_USER_JOINED:
|
||
this.logger.info('User joined', { displayName: message.displayName, oderId: message.oderId });
|
||
break;
|
||
|
||
case SIGNALING_TYPE_USER_LEFT:
|
||
this.logger.info('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId });
|
||
break;
|
||
|
||
case SIGNALING_TYPE_OFFER:
|
||
if (message.fromUserId && message.payload?.sdp) {
|
||
this.peerManager.handleOffer(message.fromUserId, message.payload.sdp);
|
||
}
|
||
break;
|
||
|
||
case SIGNALING_TYPE_ANSWER:
|
||
if (message.fromUserId && message.payload?.sdp) {
|
||
this.peerManager.handleAnswer(message.fromUserId, message.payload.sdp);
|
||
}
|
||
break;
|
||
|
||
case SIGNALING_TYPE_ICE_CANDIDATE:
|
||
if (message.fromUserId && message.payload?.candidate) {
|
||
this.peerManager.handleIceCandidate(message.fromUserId, message.payload.candidate);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ─── Voice state snapshot ──────────────────────────────────────────
|
||
|
||
private getCurrentVoiceState(): VoiceStateSnapshot {
|
||
return {
|
||
isConnected: this._isVoiceConnected(),
|
||
isMuted: this._isMuted(),
|
||
isDeafened: this._isDeafened(),
|
||
isScreenSharing: this._isScreenSharing(),
|
||
roomId: this.mediaManager.getCurrentVoiceRoomId(),
|
||
serverId: this.mediaManager.getCurrentVoiceServerId(),
|
||
};
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
// PUBLIC API – matches the old monolithic service's interface
|
||
// ═══════════════════════════════════════════════════════════════════
|
||
|
||
// ─── Signaling ─────────────────────────────────────────────────────
|
||
|
||
connectToSignalingServer(serverUrl: string): Observable<boolean> {
|
||
return this.signalingManager.connect(serverUrl);
|
||
}
|
||
|
||
async ensureSignalingConnected(timeoutMs?: number): Promise<boolean> {
|
||
return this.signalingManager.ensureConnected(timeoutMs);
|
||
}
|
||
|
||
sendSignalingMessage(message: Omit<SignalingMessage, 'from' | 'timestamp'>): void {
|
||
this.signalingManager.sendSignalingMessage(message, this._localPeerId());
|
||
}
|
||
|
||
sendRawMessage(message: Record<string, unknown>): void {
|
||
this.signalingManager.sendRawMessage(message);
|
||
}
|
||
|
||
// ─── Server membership ─────────────────────────────────────────────
|
||
|
||
setCurrentServer(serverId: string): void {
|
||
this.activeServerId = serverId;
|
||
}
|
||
|
||
identify(oderId: string, displayName: string): void {
|
||
this.lastIdentifyCredentials = { oderId, displayName };
|
||
this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, oderId, displayName });
|
||
}
|
||
|
||
joinRoom(roomId: string, userId: string): void {
|
||
this.lastJoinedServer = { serverId: roomId, userId };
|
||
this.memberServerIds.add(roomId);
|
||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId });
|
||
}
|
||
|
||
switchServer(serverId: string, userId: string): void {
|
||
this.lastJoinedServer = { serverId, userId };
|
||
|
||
if (this.memberServerIds.has(serverId)) {
|
||
this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, serverId });
|
||
this.logger.info('Viewed server (already joined)', { serverId, userId, voiceConnected: this._isVoiceConnected() });
|
||
} else {
|
||
this.memberServerIds.add(serverId);
|
||
this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, serverId });
|
||
this.logger.info('Joined new server via switch', { serverId, userId, voiceConnected: this._isVoiceConnected() });
|
||
}
|
||
}
|
||
|
||
leaveRoom(serverId?: string): void {
|
||
if (serverId) {
|
||
this.memberServerIds.delete(serverId);
|
||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId });
|
||
this.logger.info('Left server', { serverId });
|
||
if (this.memberServerIds.size === 0) { this.fullCleanup(); }
|
||
return;
|
||
}
|
||
|
||
this.memberServerIds.forEach((sid) => {
|
||
this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId: sid });
|
||
});
|
||
this.memberServerIds.clear();
|
||
this.fullCleanup();
|
||
}
|
||
|
||
hasJoinedServer(serverId: string): boolean {
|
||
return this.memberServerIds.has(serverId);
|
||
}
|
||
|
||
getJoinedServerIds(): ReadonlySet<string> {
|
||
return this.memberServerIds;
|
||
}
|
||
|
||
// ─── Peer messaging ────────────────────────────────────────────────
|
||
|
||
broadcastMessage(event: ChatEvent): void {
|
||
this.peerManager.broadcastMessage(event);
|
||
}
|
||
|
||
sendToPeer(peerId: string, event: ChatEvent): void {
|
||
this.peerManager.sendToPeer(peerId, event);
|
||
}
|
||
|
||
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||
return this.peerManager.sendToPeerBuffered(peerId, event);
|
||
}
|
||
|
||
getConnectedPeers(): string[] {
|
||
return this.peerManager.getConnectedPeerIds();
|
||
}
|
||
|
||
getRemoteStream(peerId: string): MediaStream | null {
|
||
return this.peerManager.remotePeerStreams.get(peerId) ?? null;
|
||
}
|
||
|
||
// ─── Voice / Media ─────────────────────────────────────────────────
|
||
|
||
async enableVoice(): Promise<MediaStream> {
|
||
const stream = await this.mediaManager.enableVoice();
|
||
this.syncMediaSignals();
|
||
return stream;
|
||
}
|
||
|
||
disableVoice(): void {
|
||
this.mediaManager.disableVoice();
|
||
this._isVoiceConnected.set(false);
|
||
}
|
||
|
||
setLocalStream(stream: MediaStream): void {
|
||
this.mediaManager.setLocalStream(stream);
|
||
this.syncMediaSignals();
|
||
}
|
||
|
||
toggleMute(muted?: boolean): void {
|
||
this.mediaManager.toggleMute(muted);
|
||
this._isMuted.set(this.mediaManager.getIsMicMuted());
|
||
}
|
||
|
||
toggleDeafen(deafened?: boolean): void {
|
||
this.mediaManager.toggleDeafen(deafened);
|
||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||
}
|
||
|
||
setOutputVolume(volume: number): void {
|
||
this.mediaManager.setOutputVolume(volume);
|
||
}
|
||
|
||
async setAudioBitrate(kbps: number): Promise<void> {
|
||
return this.mediaManager.setAudioBitrate(kbps);
|
||
}
|
||
|
||
async setLatencyProfile(profile: LatencyProfile): Promise<void> {
|
||
return this.mediaManager.setLatencyProfile(profile);
|
||
}
|
||
|
||
startVoiceHeartbeat(roomId?: string, serverId?: string): void {
|
||
this.mediaManager.startVoiceHeartbeat(roomId, serverId);
|
||
}
|
||
|
||
stopVoiceHeartbeat(): void {
|
||
this.mediaManager.stopVoiceHeartbeat();
|
||
}
|
||
|
||
// ─── Screen share ──────────────────────────────────────────────────
|
||
|
||
async startScreenShare(includeAudio: boolean = false): Promise<MediaStream> {
|
||
const stream = await this.screenShareManager.startScreenShare(includeAudio);
|
||
this._isScreenSharing.set(true);
|
||
this._screenStreamSignal.set(stream);
|
||
return stream;
|
||
}
|
||
|
||
stopScreenShare(): void {
|
||
this.screenShareManager.stopScreenShare();
|
||
this._isScreenSharing.set(false);
|
||
this._screenStreamSignal.set(null);
|
||
}
|
||
|
||
// ─── Disconnect / cleanup ─────────────────────────────────────────
|
||
|
||
disconnect(): void {
|
||
this.leaveRoom();
|
||
this.mediaManager.stopVoiceHeartbeat();
|
||
this.signalingManager.close();
|
||
this._isSignalingConnected.set(false);
|
||
this._hasConnectionError.set(false);
|
||
this._connectionErrorMessage.set(null);
|
||
this.serviceDestroyed$.next();
|
||
}
|
||
|
||
disconnectAll(): void {
|
||
this.disconnect();
|
||
}
|
||
|
||
private fullCleanup(): void {
|
||
this.peerManager.closeAllPeers();
|
||
this._connectedPeers.set([]);
|
||
this.mediaManager.disableVoice();
|
||
this._isVoiceConnected.set(false);
|
||
this.screenShareManager.stopScreenShare();
|
||
this._isScreenSharing.set(false);
|
||
this._screenStreamSignal.set(null);
|
||
}
|
||
|
||
// ─── Helpers ───────────────────────────────────────────────────────
|
||
|
||
/** Synchronise Angular signals from the MediaManager's internal state. */
|
||
private syncMediaSignals(): void {
|
||
this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive());
|
||
this._isMuted.set(this.mediaManager.getIsMicMuted());
|
||
this._isDeafened.set(this.mediaManager.getIsSelfDeafened());
|
||
}
|
||
|
||
// ─── Lifecycle ─────────────────────────────────────────────────────
|
||
|
||
ngOnDestroy(): void {
|
||
this.disconnect();
|
||
this.serviceDestroyed$.complete();
|
||
this.signalingManager.destroy();
|
||
this.peerManager.destroy();
|
||
this.mediaManager.destroy();
|
||
this.screenShareManager.destroy();
|
||
}
|
||
}
|