/** * WebRTCService - thin Angular service that composes specialised managers. * * Each concern lives in its own file under `./`: * • 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. */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion */ import { Injectable, inject, OnDestroy } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { ChatEvent } from '../../shared-kernel'; import type { SignalingMessage } from '../../shared-kernel'; import { TimeSyncService } from '../../core/services/time-sync.service'; import { DebuggingService } from '../../core/services/debugging'; import { ScreenShareSourcePickerService } from '../../domains/screen-share'; import { MediaManager } from './media/media.manager'; import { ScreenShareManager } from './media/screen-share.manager'; import { VoiceSessionController } from './media/voice-session-controller'; import type { PeerData, VoiceStateSnapshot } from './realtime.types'; import { LatencyProfile } from './realtime.constants'; import { ScreenShareStartOptions } from './screen-share.config'; import { WebRTCLogger } from './logging/webrtc-logger'; import { PeerConnectionManager } from './peer-connection-manager/peer-connection.manager'; import { PeerMediaFacade } from './streams/peer-media-facade'; import { RemoteScreenShareRequestController } from './streams/remote-screen-share-request-controller'; import { IncomingSignalingMessage, IncomingSignalingMessageHandler } from './signaling/signaling-message-handler'; import { ServerMembershipSignalingHandler } from './signaling/server-membership-signaling-handler'; import { ServerSignalingCoordinator } from './signaling/server-signaling-coordinator'; import { SignalingManager } from './signaling/signaling.manager'; import { SignalingTransportHandler } from './signaling/signaling-transport-handler'; import { WebRtcStateController } from './state/webrtc-state-controller'; @Injectable({ providedIn: 'root' }) export class WebRTCService implements OnDestroy { private readonly timeSync = inject(TimeSyncService); private readonly debugging = inject(DebuggingService); private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService); private readonly logger = new WebRTCLogger(() => this.debugging.enabled()); private readonly state = new WebRtcStateController(); readonly peerId = this.state.peerId; readonly isConnected = this.state.isConnected; readonly hasEverConnected = this.state.hasEverConnected; readonly isVoiceConnected = this.state.isVoiceConnected; readonly connectedPeers = this.state.connectedPeers; readonly isMuted = this.state.isMuted; readonly isDeafened = this.state.isDeafened; readonly isScreenSharing = this.state.isScreenSharing; readonly isNoiseReductionEnabled = this.state.isNoiseReductionEnabled; readonly screenStream = this.state.screenStream; readonly isScreenShareRemotePlaybackSuppressed = this.state.isScreenShareRemotePlaybackSuppressed; readonly forceDefaultRemotePlaybackOutput = this.state.forceDefaultRemotePlaybackOutput; readonly hasConnectionError = this.state.hasConnectionError; readonly connectionErrorMessage = this.state.connectionErrorMessage; readonly shouldShowConnectionError = this.state.shouldShowConnectionError; readonly peerLatencies = this.state.peerLatencies; private readonly signalingMessage$ = new Subject(); readonly onSignalingMessage = this.signalingMessage$.asObservable(); // Delegates to managers get onMessageReceived(): Observable { return this.peerMediaFacade.onMessageReceived; } get onPeerConnected(): Observable { return this.peerMediaFacade.onPeerConnected; } get onPeerDisconnected(): Observable { return this.peerMediaFacade.onPeerDisconnected; } get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { return this.peerMediaFacade.onRemoteStream; } get onVoiceConnected(): Observable { return this.peerMediaFacade.onVoiceConnected; } private readonly peerManager: PeerConnectionManager; private readonly mediaManager: MediaManager; private readonly screenShareManager: ScreenShareManager; private readonly peerMediaFacade: PeerMediaFacade; private readonly voiceSessionController: VoiceSessionController; private readonly signalingCoordinator: ServerSignalingCoordinator; private readonly signalingTransportHandler: SignalingTransportHandler; private readonly signalingMessageHandler: IncomingSignalingMessageHandler; private readonly serverMembershipSignalingHandler: ServerMembershipSignalingHandler; private readonly remoteScreenShareRequestController: RemoteScreenShareRequestController; constructor() { // Create managers with null callbacks first to break circular initialization this.peerManager = new PeerConnectionManager(this.logger, null!); this.mediaManager = new MediaManager(this.logger, null!); this.screenShareManager = new ScreenShareManager(this.logger, null!); this.peerMediaFacade = new PeerMediaFacade({ peerManager: this.peerManager, mediaManager: this.mediaManager, screenShareManager: this.screenShareManager }); this.voiceSessionController = new VoiceSessionController({ mediaManager: this.mediaManager, getIsScreenSharing: () => this.state.isScreenSharingActive(), setVoiceConnected: (connected) => this.state.setVoiceConnected(connected), setMuted: (muted) => this.state.setMuted(muted), setDeafened: (deafened) => this.state.setDeafened(deafened), setNoiseReductionEnabled: (enabled) => this.state.setNoiseReductionEnabled(enabled) }); this.signalingCoordinator = new ServerSignalingCoordinator({ createManager: (_signalUrl, getLastJoinedServer, getMemberServerIds) => new SignalingManager( this.logger, () => this.signalingTransportHandler.getIdentifyCredentials(), getLastJoinedServer, getMemberServerIds ), handleConnectionStatus: (_signalUrl, connected, errorMessage) => this.handleSignalingConnectionStatus(connected, errorMessage), handleHeartbeatTick: () => this.peerMediaFacade.broadcastCurrentStates(), handleMessage: (message, signalUrl) => this.handleSignalingMessage(message, signalUrl) }); this.signalingTransportHandler = new SignalingTransportHandler({ signalingCoordinator: this.signalingCoordinator, logger: this.logger, getLocalPeerId: () => this.state.getLocalPeerId() }); // Now wire up cross-references (all managers are instantiated) this.peerManager.setCallbacks({ sendRawMessage: (msg: Record) => this.signalingTransportHandler.sendRawMessage(msg), getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(), isSignalingConnected: (): boolean => this.state.isSignalingConnected(), getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(), getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(), getLocalPeerId: (): string => this.state.getLocalPeerId(), isScreenSharingActive: (): boolean => this.state.isScreenSharingActive() }); this.mediaManager.setCallbacks({ getActivePeers: (): Map => this.peerMediaFacade.getActivePeers(), renegotiate: (peerId: string): Promise => this.peerMediaFacade.renegotiate(peerId), broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event), getIdentifyOderId: (): string => this.signalingTransportHandler.getIdentifyOderId(), getIdentifyDisplayName: (): string => this.signalingTransportHandler.getIdentifyDisplayName() }); this.screenShareManager.setCallbacks({ getActivePeers: (): Map => this.peerMediaFacade.getActivePeers(), getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(), renegotiate: (peerId: string): Promise => this.peerMediaFacade.renegotiate(peerId), broadcastCurrentStates: (): void => this.peerMediaFacade.broadcastCurrentStates(), selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open( sources, options.includeSystemAudio ), updateLocalScreenShareState: (state): void => this.state.applyLocalScreenShareState(state) }); this.signalingMessageHandler = new IncomingSignalingMessageHandler({ getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId), peerManager: this.peerManager, setServerTime: (serverTime) => this.timeSync.setFromServerTime(serverTime), signalingCoordinator: this.signalingCoordinator, logger: this.logger }); this.serverMembershipSignalingHandler = new ServerMembershipSignalingHandler({ signalingCoordinator: this.signalingCoordinator, signalingTransport: this.signalingTransportHandler, logger: this.logger, getActiveServerId: () => this.state.currentServerId, isVoiceConnected: () => this.state.isVoiceConnectedActive(), runFullCleanup: () => this.fullCleanup() }); this.remoteScreenShareRequestController = new RemoteScreenShareRequestController({ getConnectedPeerIds: () => this.peerMediaFacade.getConnectedPeerIds(), sendToPeer: (peerId, event) => this.peerMediaFacade.sendToPeer(peerId, event), clearRemoteScreenShareStream: (peerId) => this.peerMediaFacade.clearRemoteScreenShareStream(peerId), requestScreenShareForPeer: (peerId) => this.peerMediaFacade.requestScreenShareForPeer(peerId), stopScreenShareForPeer: (peerId) => this.peerMediaFacade.stopScreenShareForPeer(peerId), clearScreenShareRequest: (peerId) => this.peerMediaFacade.clearScreenShareRequest(peerId) }); this.wireManagerEvents(); } private wireManagerEvents(): void { // Internal control-plane messages for on-demand screen-share delivery. this.peerManager.messageReceived$.subscribe((event) => this.remoteScreenShareRequestController.handlePeerControlMessage(event) ); // Peer manager → connected peers signal this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => this.state.setConnectedPeers(peers) ); // If we are already sharing when a new peer connection finishes, push the // current screen-share tracks to that peer and renegotiate. this.peerManager.peerConnected$.subscribe((peerId) => { if (this.peerMediaFacade.isScreenShareActive()) { this.peerMediaFacade.syncScreenShareToPeer(peerId); } this.remoteScreenShareRequestController.handlePeerConnected(peerId); }); this.peerManager.peerDisconnected$.subscribe((peerId) => { this.remoteScreenShareRequestController.handlePeerDisconnected(peerId); this.signalingCoordinator.deletePeerTracking(peerId); }); // Media manager → voice connected signal this.mediaManager.voiceConnected$.subscribe(() => { this.voiceSessionController.handleVoiceConnected(); }); // Peer manager → latency updates this.peerManager.peerLatencyChanged$.subscribe(() => this.state.syncPeerLatencies(this.peerManager.peerLatencies) ); } private handleSignalingConnectionStatus(connected: boolean, errorMessage?: string): void { this.state.updateSignalingConnectionStatus( this.signalingCoordinator.isAnySignalingConnected(), connected, errorMessage ); } private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.signalingMessage$.next(message); this.signalingMessageHandler.handleMessage(message, signalUrl); } // PUBLIC API - matches the old monolithic service's interface /** * Connect to a signaling server via WebSocket. * * @param serverUrl - The WebSocket URL of the signaling server. * @returns An observable that emits `true` once connected. */ connectToSignalingServer(serverUrl: string): Observable { return this.signalingTransportHandler.connectToSignalingServer(serverUrl); } /** Returns true when the signaling socket for a given URL is currently open. */ isSignalingConnectedTo(serverUrl: string): boolean { return this.signalingTransportHandler.isSignalingConnectedTo(serverUrl); } /** * Ensure the signaling WebSocket is connected, reconnecting if needed. * * @param timeoutMs - Maximum time (ms) to wait for the connection. * @returns `true` if connected within the timeout. */ async ensureSignalingConnected(timeoutMs?: number): Promise { return await this.signalingTransportHandler.ensureSignalingConnected(timeoutMs); } /** * Send a signaling-level message (with `from` and `timestamp` auto-populated). * * @param message - The signaling message payload (excluding `from` / `timestamp`). */ sendSignalingMessage(message: Omit): void { this.signalingTransportHandler.sendSignalingMessage(message); } /** * Send a raw JSON payload through the signaling WebSocket. * * @param message - Arbitrary JSON message. */ sendRawMessage(message: Record): void { this.signalingTransportHandler.sendRawMessage(message); } /** * Track the currently-active server ID (for server-scoped operations). * * @param serverId - The server to mark as active. */ setCurrentServer(serverId: string): void { this.state.setCurrentServer(serverId); } /** The server ID currently being viewed / active, or `null`. */ get currentServerId(): string | null { return this.state.currentServerId; } /** The last signaling URL used by the client, if any. */ getCurrentSignalingUrl(): string | null { return this.signalingTransportHandler.getCurrentSignalingUrl(this.state.currentServerId); } /** * Send an identify message to the signaling server. * * The credentials are cached so they can be replayed after a reconnect. * * @param oderId - The user's unique order/peer ID. * @param displayName - The user's display name. */ identify(oderId: string, displayName: string, signalUrl?: string): void { this.signalingTransportHandler.identify(oderId, displayName, signalUrl); } /** * Join a server (room) on the signaling server. * * @param roomId - The server / room ID to join. * @param userId - The local user ID. */ joinRoom(roomId: string, userId: string, signalUrl?: string): void { this.serverMembershipSignalingHandler.joinRoom(roomId, userId, signalUrl); } /** * Switch to a different server. If already a member, sends a view event; * otherwise joins the server. * * @param serverId - The target server ID. * @param userId - The local user ID. */ switchServer(serverId: string, userId: string, signalUrl?: string): void { this.serverMembershipSignalingHandler.switchServer(serverId, userId, signalUrl); } /** * Leave one or all servers. * * If `serverId` is provided, leaves only that server. * Otherwise leaves every joined server and performs a full cleanup. * * @param serverId - Optional server to leave; omit to leave all. */ leaveRoom(serverId?: string): void { this.serverMembershipSignalingHandler.leaveRoom(serverId); } /** * Check whether the local client has joined a given server. * * @param serverId - The server to check. */ hasJoinedServer(serverId: string): boolean { return this.signalingCoordinator.hasJoinedServer(serverId); } /** Returns a read-only set of all currently-joined server IDs. */ getJoinedServerIds(): ReadonlySet { return this.signalingCoordinator.getJoinedServerIds(); } /** * Broadcast a {@link ChatEvent} to every connected peer. * * @param event - The chat event to send. */ broadcastMessage(event: ChatEvent): void { this.peerMediaFacade.broadcastMessage(event); } /** * Send a {@link ChatEvent} to a specific peer. * * @param peerId - The target peer ID. * @param event - The chat event to send. */ sendToPeer(peerId: string, event: ChatEvent): void { this.peerMediaFacade.sendToPeer(peerId, event); } syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { this.remoteScreenShareRequestController.syncRemoteScreenShareRequests(peerIds, enabled); } /** * Send a {@link ChatEvent} to a peer with back-pressure awareness. * * @param peerId - The target peer ID. * @param event - The chat event to send. */ async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { return await this.peerMediaFacade.sendToPeerBuffered(peerId, event); } /** Returns an array of currently-connected peer IDs. */ getConnectedPeers(): string[] { return this.peerMediaFacade.getConnectedPeerIds(); } /** * Get the composite remote {@link MediaStream} for a connected peer. * * @param peerId - The remote peer whose stream to retrieve. * @returns The stream, or `null` if the peer has no active stream. */ getRemoteStream(peerId: string): MediaStream | null { return this.peerMediaFacade.getRemoteStream(peerId); } /** * Get the remote voice-only stream for a connected peer. * * @param peerId - The remote peer whose voice stream to retrieve. * @returns The stream, or `null` if the peer has no active voice audio. */ getRemoteVoiceStream(peerId: string): MediaStream | null { return this.peerMediaFacade.getRemoteVoiceStream(peerId); } /** * Get the remote screen-share stream for a connected peer. * * This contains the screen video track and any audio track that belongs to * the screen share itself, not the peer's normal voice-chat audio. * * @param peerId - The remote peer whose screen-share stream to retrieve. * @returns The stream, or `null` if the peer has no active screen share. */ getRemoteScreenShareStream(peerId: string): MediaStream | null { return this.peerMediaFacade.getRemoteScreenShareStream(peerId); } /** * Get the current local media stream (microphone audio). * * @returns The local {@link MediaStream}, or `null` if voice is not active. */ getLocalStream(): MediaStream | null { return this.peerMediaFacade.getLocalStream(); } /** * Get the raw local microphone stream before gain / RNNoise processing. * * @returns The raw microphone {@link MediaStream}, or `null` if voice is not active. */ getRawMicStream(): MediaStream | null { return this.peerMediaFacade.getRawMicStream(); } /** * Request microphone access and start sending audio to all peers. * * @returns The captured local {@link MediaStream}. */ async enableVoice(): Promise { return await this.voiceSessionController.enableVoice(); } /** Stop local voice capture and remove audio senders from peers. */ disableVoice(): void { this.voiceSessionController.disableVoice(); } /** * Inject an externally-obtained media stream as the local voice source. * * @param stream - The media stream to use. */ async setLocalStream(stream: MediaStream): Promise { await this.voiceSessionController.setLocalStream(stream); } /** * Toggle the local microphone mute state. * * @param muted - Explicit state; if omitted, the current state is toggled. */ toggleMute(muted?: boolean): void { this.voiceSessionController.toggleMute(muted); } /** * Toggle self-deafen (suppress incoming audio playback). * * @param deafened - Explicit state; if omitted, the current state is toggled. */ toggleDeafen(deafened?: boolean): void { this.voiceSessionController.toggleDeafen(deafened); } /** * Toggle RNNoise noise reduction on the local microphone. * * When enabled, the raw mic audio is routed through an AudioWorklet * that applies neural-network noise suppression before being sent * to peers. * * @param enabled - Explicit state; if omitted, the current state is toggled. */ async toggleNoiseReduction(enabled?: boolean): Promise { await this.voiceSessionController.toggleNoiseReduction(enabled); } /** * Set the output volume for remote audio playback. * * @param volume - Normalised volume (0-1). */ setOutputVolume(volume: number): void { this.voiceSessionController.setOutputVolume(volume); } /** * Set the input (microphone) volume. * * Adjusts a Web Audio GainNode on the local mic stream so the level * sent to peers changes in real time without renegotiation. * * @param volume - Normalised volume (0-1). */ setInputVolume(volume: number): void { this.voiceSessionController.setInputVolume(volume); } /** * Set the maximum audio bitrate for all peer connections. * * @param kbps - Target bitrate in kilobits per second. */ async setAudioBitrate(kbps: number): Promise { return await this.voiceSessionController.setAudioBitrate(kbps); } /** * Apply a predefined latency profile that maps to a specific bitrate. * * @param profile - One of `'low'`, `'balanced'`, or `'high'`. */ async setLatencyProfile(profile: LatencyProfile): Promise { return await this.voiceSessionController.setLatencyProfile(profile); } /** * Start broadcasting voice-presence heartbeats to all peers. * * Also marks the given server as the active voice server and closes * any peer connections that belong to other servers so that audio * is isolated to the correct voice channel. * * @param roomId - The voice channel room ID. * @param serverId - The voice channel server ID. */ startVoiceHeartbeat(roomId?: string, serverId?: string): void { this.voiceSessionController.startVoiceHeartbeat(roomId, serverId); } /** Stop the voice-presence heartbeat. */ stopVoiceHeartbeat(): void { this.voiceSessionController.stopVoiceHeartbeat(); } /** * Start sharing the screen (or a window) with all connected peers. * * @param options - Screen-share capture options. * @returns The screen-capture {@link MediaStream}. */ async startScreenShare(options: ScreenShareStartOptions): Promise { return await this.peerMediaFacade.startScreenShare(options); } /** Stop screen sharing and restore microphone audio on all peers. */ stopScreenShare(): void { this.peerMediaFacade.stopScreenShare(); } /** Disconnect from the signaling server and clean up all state. */ disconnect(): void { this.leaveRoom(); this.destroyAllSignalingManagers(); this.state.resetConnectionState(); } /** Alias for {@link disconnect}. */ disconnectAll(): void { this.disconnect(); } private fullCleanup(): void { this.signalingCoordinator.clearPeerTracking(); this.remoteScreenShareRequestController.clear(); this.peerMediaFacade.closeAllPeers(); this.state.clearPeerViewState(); this.voiceSessionController.resetVoiceSession(); this.peerMediaFacade.stopScreenShare(); this.state.clearScreenShareState(); } private destroyAllSignalingManagers(): void { this.signalingCoordinator.destroy(); } ngOnDestroy(): void { this.disconnect(); this.peerMediaFacade.destroy(); } }