/** * 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(); private activeServerId: string | null = null; private readonly serviceDestroyed$ = new Subject(); // ─── Angular signals (reactive state) ────────────────────────────── private readonly _localPeerId = signal(uuidv4()); private readonly _isSignalingConnected = signal(false); private readonly _isVoiceConnected = signal(false); private readonly _connectedPeers = signal([]); private readonly _isMuted = signal(false); private readonly _isDeafened = signal(false); private readonly _isScreenSharing = signal(false); private readonly _screenStreamSignal = signal(null); private readonly _hasConnectionError = signal(false); private readonly _connectionErrorMessage = signal(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(); readonly onSignalingMessage = this.signalingMessage$.asObservable(); // Delegates to managers get onMessageReceived(): Observable { return this.peerManager.messageReceived$.asObservable(); } get onPeerConnected(): Observable { return this.peerManager.peerConnected$.asObservable(); } get onPeerDisconnected(): Observable { return this.peerManager.peerDisconnected$.asObservable(); } get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerManager.remoteStream$.asObservable(); } get onVoiceConnected(): Observable { 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) => 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 => this.peerManager.activePeerConnections, renegotiate: (peerId: string): Promise => 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 => this.peerManager.activePeerConnections, getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), renegotiate: (peerId: string): Promise => 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 { return this.signalingManager.connect(serverUrl); } async ensureSignalingConnected(timeoutMs?: number): Promise { return this.signalingManager.ensureConnected(timeoutMs); } sendSignalingMessage(message: Omit): void { this.signalingManager.sendSignalingMessage(message, this._localPeerId()); } sendRawMessage(message: Record): 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 { 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 { 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 { 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 { return this.mediaManager.setAudioBitrate(kbps); } async setLatencyProfile(profile: LatencyProfile): Promise { 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 { 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(); } }