/** * 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. */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars */ import { Injectable, signal, computed, inject, OnDestroy } from '@angular/core'; import { Observable, of, Subject, Subscription } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { SignalingMessage, ChatEvent } from '../models/index'; import { TimeSyncService } from './time-sync.service'; import { DebuggingService } from './debugging.service'; import { ScreenShareSourcePickerService } from './screen-share-source-picker.service'; import { SignalingManager, PeerConnectionManager, MediaManager, ScreenShareManager, WebRTCLogger, IdentifyCredentials, JoinedServerInfo, VoiceStateSnapshot, LatencyProfile, ScreenShareStartOptions, 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_SCREEN_SHARE_REQUEST, P2P_TYPE_SCREEN_SHARE_STOP, P2P_TYPE_VOICE_STATE, P2P_TYPE_SCREEN_STATE } from './webrtc'; interface SignalingUserSummary { oderId: string; displayName: string; } interface IncomingSignalingPayload { sdp?: RTCSessionDescriptionInit; candidate?: RTCIceCandidateInit; } type IncomingSignalingMessage = Omit, 'type' | 'payload'> & { type: string; payload?: IncomingSignalingPayload; oderId?: string; serverTime?: number; serverId?: string; serverIds?: string[]; users?: SignalingUserSummary[]; displayName?: string; fromUserId?: string; }; @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 lastIdentifyCredentials: IdentifyCredentials | null = null; private readonly lastJoinedServerBySignalUrl = new Map(); private readonly memberServerIdsBySignalUrl = new Map>(); private readonly serverSignalingUrlMap = new Map(); private readonly peerSignalingUrlMap = new Map(); private readonly signalingManagers = new Map(); private readonly signalingSubscriptions = new Map(); private readonly signalingConnectionStates = new Map(); private activeServerId: string | null = null; /** The server ID where voice is currently active, or `null` when not in voice. */ private voiceServerId: string | null = null; /** Maps each remote peer ID to the shared servers they currently belong to. */ private readonly peerServerMap = new Map>(); private readonly serviceDestroyed$ = new Subject(); private remoteScreenShareRequestsEnabled = false; private readonly desiredRemoteScreenSharePeers = new Set(); private readonly activeRemoteScreenSharePeers = new Set(); 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 _isNoiseReductionEnabled = signal(false); private readonly _screenStreamSignal = signal(null); private readonly _isScreenShareRemotePlaybackSuppressed = signal(false); private readonly _forceDefaultRemotePlaybackOutput = signal(false); private readonly _hasConnectionError = signal(false); private readonly _connectionErrorMessage = signal(null); private readonly _hasEverConnected = signal(false); /** * Reactive snapshot of per-peer latencies (ms). * Updated whenever a ping/pong round-trip completes. * Keyed by remote peer (oderId). */ private readonly _peerLatencies = signal>(new Map()); // Public computed signals (unchanged external API) readonly peerId = computed(() => this._localPeerId()); readonly isConnected = computed(() => this._isSignalingConnected()); readonly hasEverConnected = computed(() => this._hasEverConnected()); 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 isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled()); readonly screenStream = computed(() => this._screenStreamSignal()); readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed()); readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput()); 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; }); /** Per-peer latency map (ms). Read via `peerLatencies()`. */ readonly peerLatencies = computed(() => this._peerLatencies()); 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(); } private readonly peerManager: PeerConnectionManager; private readonly mediaManager: MediaManager; private readonly screenShareManager: ScreenShareManager; 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!); // Now wire up cross-references (all managers are instantiated) this.peerManager.setCallbacks({ sendRawMessage: (msg: Record) => this.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: ChatEvent): 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(), selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open( sources, options.includeSystemAudio ), updateLocalScreenShareState: (state): void => { this._isScreenSharing.set(state.active); this._screenStreamSignal.set(state.stream); this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback); this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput); } }); this.wireManagerEvents(); } private wireManagerEvents(): void { // Internal control-plane messages for on-demand screen-share delivery. this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event)); // Peer manager → connected peers signal this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => this._connectedPeers.set(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.screenShareManager.getIsScreenActive()) { if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) { this.requestRemoteScreenShares([peerId]); } return; } this.screenShareManager.syncScreenShareToPeer(peerId); if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) { this.requestRemoteScreenShares([peerId]); } }); this.peerManager.peerDisconnected$.subscribe((peerId) => { this.activeRemoteScreenSharePeers.delete(peerId); this.peerServerMap.delete(peerId); this.peerSignalingUrlMap.delete(peerId); this.screenShareManager.clearScreenShareRequest(peerId); }); // Media manager → voice connected signal this.mediaManager.voiceConnected$.subscribe(() => { this._isVoiceConnected.set(true); }); // Peer manager → latency updates this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => { const next = new Map(this.peerManager.peerLatencies); this._peerLatencies.set(next); }); } private ensureSignalingManager(signalUrl: string): SignalingManager { const existingManager = this.signalingManagers.get(signalUrl); if (existingManager) { return existingManager; } const manager = new SignalingManager( this.logger, () => this.lastIdentifyCredentials, () => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null, () => this.getMemberServerIdsForSignalUrl(signalUrl) ); const subscriptions: Subscription[] = [ manager.connectionStatus$.subscribe(({ connected, errorMessage }) => this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage) ), manager.messageReceived$.subscribe((message) => this.handleSignalingMessage(message, signalUrl)), manager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates()) ]; this.signalingManagers.set(signalUrl, manager); this.signalingSubscriptions.set(signalUrl, subscriptions); return manager; } private handleSignalingConnectionStatus( signalUrl: string, connected: boolean, errorMessage?: string ): void { this.signalingConnectionStates.set(signalUrl, connected); if (connected) this._hasEverConnected.set(true); const anyConnected = this.isAnySignalingConnected(); this._isSignalingConnected.set(anyConnected); this._hasConnectionError.set(!anyConnected); this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server')); } private isAnySignalingConnected(): boolean { for (const manager of this.signalingManagers.values()) { if (manager.isSocketOpen()) { return true; } } return false; } private getConnectedSignalingManagers(): { signalUrl: string; manager: SignalingManager }[] { const connectedManagers: { signalUrl: string; manager: SignalingManager }[] = []; for (const [signalUrl, manager] of this.signalingManagers.entries()) { if (!manager.isSocketOpen()) { continue; } connectedManagers.push({ signalUrl, manager }); } return connectedManagers; } private getOrCreateMemberServerSet(signalUrl: string): Set { const existingSet = this.memberServerIdsBySignalUrl.get(signalUrl); if (existingSet) { return existingSet; } const createdSet = new Set(); this.memberServerIdsBySignalUrl.set(signalUrl, createdSet); return createdSet; } private getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet { return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set(); } private isJoinedServer(serverId: string): boolean { for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { if (memberServerIds.has(serverId)) { return true; } } return false; } private getJoinedServerCount(): number { let joinedServerCount = 0; for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { joinedServerCount += memberServerIds.size; } return joinedServerCount; } private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.signalingMessage$.next(message); this.logger.info('Signaling message', { signalUrl, type: message.type }); switch (message.type) { case SIGNALING_TYPE_CONNECTED: this.handleConnectedSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_SERVER_USERS: this.handleServerUsersSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_USER_JOINED: this.handleUserJoinedSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_USER_LEFT: this.handleUserLeftSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_OFFER: this.handleOfferSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_ANSWER: this.handleAnswerSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_ICE_CANDIDATE: this.handleIceCandidateSignalingMessage(message, signalUrl); return; default: return; } } private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.logger.info('Server connected', { oderId: message.oderId, signalUrl }); if (message.serverId) { this.serverSignalingUrlMap.set(message.serverId, signalUrl); } if (typeof message.serverTime === 'number') { this.timeSync.setFromServerTime(message.serverTime); } } private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const users = Array.isArray(message.users) ? message.users : []; this.logger.info('Server users', { count: users.length, signalUrl, serverId: message.serverId }); if (message.serverId) { this.serverSignalingUrlMap.set(message.serverId, signalUrl); } for (const user of users) { if (!user.oderId) continue; this.peerSignalingUrlMap.set(user.oderId, signalUrl); if (message.serverId) { this.trackPeerInServer(user.oderId, message.serverId); } const existing = this.peerManager.activePeerConnections.get(user.oderId); if (this.canReusePeerConnection(existing)) { this.logger.info('Reusing active peer connection', { connectionState: existing?.connection.connectionState ?? 'unknown', dataChannelState: existing?.dataChannel?.readyState ?? 'missing', oderId: user.oderId, serverId: message.serverId, signalUrl }); continue; } if (existing) { this.logger.info('Removing failed peer before recreate', { connectionState: existing.connection.connectionState, dataChannelState: existing.dataChannel?.readyState ?? 'missing', oderId: user.oderId, serverId: message.serverId, signalUrl }); this.peerManager.removePeer(user.oderId); } this.logger.info('Create peer connection to existing user', { oderId: user.oderId, serverId: message.serverId, signalUrl }); this.peerManager.createPeerConnection(user.oderId, true); void this.peerManager.createAndSendOffer(user.oderId); } } private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.logger.info('User joined', { displayName: message.displayName, oderId: message.oderId, signalUrl }); if (message.serverId) { this.serverSignalingUrlMap.set(message.serverId, signalUrl); } if (message.oderId) { this.peerSignalingUrlMap.set(message.oderId, signalUrl); } if (message.oderId && message.serverId) { this.trackPeerInServer(message.oderId, message.serverId); } } private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.logger.info('User left', { displayName: message.displayName, oderId: message.oderId, signalUrl, serverId: message.serverId }); if (message.oderId) { const hasRemainingSharedServers = Array.isArray(message.serverIds) ? this.replacePeerSharedServers(message.oderId, message.serverIds) : (message.serverId ? this.untrackPeerFromServer(message.oderId, message.serverId) : false); if (!hasRemainingSharedServers) { this.peerManager.removePeer(message.oderId); this.peerServerMap.delete(message.oderId); this.peerSignalingUrlMap.delete(message.oderId); } } } private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const fromUserId = message.fromUserId; const sdp = message.payload?.sdp; if (!fromUserId || !sdp) return; this.peerSignalingUrlMap.set(fromUserId, signalUrl); const offerEffectiveServer = this.voiceServerId || this.activeServerId; if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) { this.trackPeerInServer(fromUserId, offerEffectiveServer); } this.peerManager.handleOffer(fromUserId, sdp); } private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const fromUserId = message.fromUserId; const sdp = message.payload?.sdp; if (!fromUserId || !sdp) return; this.peerSignalingUrlMap.set(fromUserId, signalUrl); this.peerManager.handleAnswer(fromUserId, sdp); } private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const fromUserId = message.fromUserId; const candidate = message.payload?.candidate; if (!fromUserId || !candidate) return; this.peerSignalingUrlMap.set(fromUserId, signalUrl); this.peerManager.handleIceCandidate(fromUserId, candidate); } /** * Close all peer connections that were discovered from a server * other than `serverId`. Also removes their entries from * {@link peerServerMap} so the bookkeeping stays clean. * * This ensures audio (and data channels) are scoped to only * the voice-active (or currently viewed) server. */ private closePeersNotInServer(serverId: string): void { const peersToClose: string[] = []; this.peerServerMap.forEach((peerServerIds, peerId) => { if (!peerServerIds.has(serverId)) { peersToClose.push(peerId); } }); for (const peerId of peersToClose) { this.logger.info('Closing peer from different server', { peerId, currentServer: serverId }); this.peerManager.removePeer(peerId); this.peerServerMap.delete(peerId); this.peerSignalingUrlMap.delete(peerId); } } 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 /** * 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 { const manager = this.ensureSignalingManager(serverUrl); if (manager.isSocketOpen()) { return of(true); } return manager.connect(serverUrl); } /** Returns true when the signaling socket for a given URL is currently open. */ isSignalingConnectedTo(serverUrl: string): boolean { return this.signalingManagers.get(serverUrl)?.isSocketOpen() ?? false; } private trackPeerInServer(peerId: string, serverId: string): void { if (!peerId || !serverId) return; const trackedServers = this.peerServerMap.get(peerId) ?? new Set(); trackedServers.add(serverId); this.peerServerMap.set(peerId, trackedServers); } private replacePeerSharedServers(peerId: string, serverIds: string[]): boolean { const sharedServerIds = serverIds.filter((serverId) => this.isJoinedServer(serverId)); if (sharedServerIds.length === 0) { this.peerServerMap.delete(peerId); return false; } this.peerServerMap.set(peerId, new Set(sharedServerIds)); return true; } private untrackPeerFromServer(peerId: string, serverId: string): boolean { const trackedServers = this.peerServerMap.get(peerId); if (!trackedServers) return false; trackedServers.delete(serverId); if (trackedServers.size === 0) { this.peerServerMap.delete(peerId); return false; } this.peerServerMap.set(peerId, trackedServers); return true; } /** * 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 { if (this.isAnySignalingConnected()) { return true; } for (const manager of this.signalingManagers.values()) { if (await manager.ensureConnected(timeoutMs)) { return true; } } return false; } /** * Send a signaling-level message (with `from` and `timestamp` auto-populated). * * @param message - The signaling message payload (excluding `from` / `timestamp`). */ sendSignalingMessage(message: Omit): void { const targetPeerId = message.to; if (targetPeerId) { const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId); if (targetSignalUrl) { const targetManager = this.ensureSignalingManager(targetSignalUrl); targetManager.sendSignalingMessage(message, this._localPeerId()); return; } } const connectedManagers = this.getConnectedSignalingManagers(); if (connectedManagers.length === 0) { this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), { type: message.type }); return; } for (const { manager } of connectedManagers) { manager.sendSignalingMessage(message, this._localPeerId()); } } /** * Send a raw JSON payload through the signaling WebSocket. * * @param message - Arbitrary JSON message. */ sendRawMessage(message: Record): void { const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null; if (targetPeerId) { const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId); if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) { return; } } const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null; if (serverId) { const serverSignalUrl = this.serverSignalingUrlMap.get(serverId); if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) { return; } } const connectedManagers = this.getConnectedSignalingManagers(); if (connectedManagers.length === 0) { this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), { type: typeof message['type'] === 'string' ? message['type'] : 'unknown' }); return; } for (const { manager } of connectedManagers) { manager.sendRawMessage(message); } } private sendRawMessageToSignalUrl(signalUrl: string, message: Record): boolean { const manager = this.signalingManagers.get(signalUrl); if (!manager) { return false; } manager.sendRawMessage(message); return true; } /** * Track the currently-active server ID (for server-scoped operations). * * @param serverId - The server to mark as active. */ setCurrentServer(serverId: string): void { this.activeServerId = serverId; } /** The server ID currently being viewed / active, or `null`. */ get currentServerId(): string | null { return this.activeServerId; } /** The last signaling URL used by the client, if any. */ getCurrentSignalingUrl(): string | null { if (this.activeServerId) { const activeServerSignalUrl = this.serverSignalingUrlMap.get(this.activeServerId); if (activeServerSignalUrl) { return activeServerSignalUrl; } } return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null; } /** * 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.lastIdentifyCredentials = { oderId, displayName }; const identifyMessage = { type: SIGNALING_TYPE_IDENTIFY, oderId, displayName }; if (signalUrl) { this.sendRawMessageToSignalUrl(signalUrl, identifyMessage); return; } this.sendRawMessage(identifyMessage); } /** * 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 { const resolvedSignalUrl = signalUrl ?? this.serverSignalingUrlMap.get(roomId) ?? this.getCurrentSignalingUrl(); if (!resolvedSignalUrl) { this.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId }); return; } this.serverSignalingUrlMap.set(roomId, resolvedSignalUrl); this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, { serverId: roomId, userId }); this.getOrCreateMemberServerSet(resolvedSignalUrl).add(roomId); this.sendRawMessageToSignalUrl(resolvedSignalUrl, { type: SIGNALING_TYPE_JOIN_SERVER, serverId: roomId }); } /** * 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 { const resolvedSignalUrl = signalUrl ?? this.serverSignalingUrlMap.get(serverId) ?? this.getCurrentSignalingUrl(); if (!resolvedSignalUrl) { this.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId }); return; } this.serverSignalingUrlMap.set(serverId, resolvedSignalUrl); this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, { serverId, userId }); const memberServerIds = this.getOrCreateMemberServerSet(resolvedSignalUrl); if (memberServerIds.has(serverId)) { this.sendRawMessageToSignalUrl(resolvedSignalUrl, { type: SIGNALING_TYPE_VIEW_SERVER, serverId }); this.logger.info('Viewed server (already joined)', { serverId, signalUrl: resolvedSignalUrl, userId, voiceConnected: this._isVoiceConnected() }); } else { memberServerIds.add(serverId); this.sendRawMessageToSignalUrl(resolvedSignalUrl, { type: SIGNALING_TYPE_JOIN_SERVER, serverId }); this.logger.info('Joined new server via switch', { serverId, signalUrl: resolvedSignalUrl, userId, voiceConnected: this._isVoiceConnected() }); } } /** * 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 { if (serverId) { const resolvedSignalUrl = this.serverSignalingUrlMap.get(serverId); if (resolvedSignalUrl) { this.getOrCreateMemberServerSet(resolvedSignalUrl).delete(serverId); this.sendRawMessageToSignalUrl(resolvedSignalUrl, { type: SIGNALING_TYPE_LEAVE_SERVER, serverId }); } else { this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, serverId }); for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { memberServerIds.delete(serverId); } } this.serverSignalingUrlMap.delete(serverId); this.logger.info('Left server', { serverId }); if (this.getJoinedServerCount() === 0) { this.fullCleanup(); } return; } for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) { for (const sid of memberServerIds) { this.sendRawMessageToSignalUrl(signalUrl, { type: SIGNALING_TYPE_LEAVE_SERVER, serverId: sid }); } } this.memberServerIdsBySignalUrl.clear(); this.serverSignalingUrlMap.clear(); this.fullCleanup(); } /** * Check whether the local client has joined a given server. * * @param serverId - The server to check. */ hasJoinedServer(serverId: string): boolean { return this.isJoinedServer(serverId); } /** Returns a read-only set of all currently-joined server IDs. */ getJoinedServerIds(): ReadonlySet { const joinedServerIds = new Set(); for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { memberServerIds.forEach((serverId) => joinedServerIds.add(serverId)); } return joinedServerIds; } /** * Broadcast a {@link ChatEvent} to every connected peer. * * @param event - The chat event to send. */ broadcastMessage(event: ChatEvent): void { this.peerManager.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.peerManager.sendToPeer(peerId, event); } syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { const nextDesiredPeers = new Set( peerIds.filter((peerId): peerId is string => !!peerId) ); if (!enabled) { this.remoteScreenShareRequestsEnabled = false; this.desiredRemoteScreenSharePeers.clear(); this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]); return; } this.remoteScreenShareRequestsEnabled = true; for (const activePeerId of [...this.activeRemoteScreenSharePeers]) { if (!nextDesiredPeers.has(activePeerId)) { this.stopRemoteScreenShares([activePeerId]); } } this.desiredRemoteScreenSharePeers.clear(); nextDesiredPeers.forEach((peerId) => this.desiredRemoteScreenSharePeers.add(peerId)); this.requestRemoteScreenShares([...nextDesiredPeers]); } /** * 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 this.peerManager.sendToPeerBuffered(peerId, event); } /** Returns an array of currently-connected peer IDs. */ getConnectedPeers(): string[] { return this.peerManager.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.peerManager.remotePeerStreams.get(peerId) ?? null; } /** * 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.peerManager.remotePeerVoiceStreams.get(peerId) ?? null; } /** * 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.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null; } /** * 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.mediaManager.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.mediaManager.getRawMicStream(); } /** * Request microphone access and start sending audio to all peers. * * @returns The captured local {@link MediaStream}. */ async enableVoice(): Promise { const stream = await this.mediaManager.enableVoice(); this.syncMediaSignals(); return stream; } /** Stop local voice capture and remove audio senders from peers. */ disableVoice(): void { this.voiceServerId = null; this.mediaManager.disableVoice(); this._isVoiceConnected.set(false); } /** * 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.mediaManager.setLocalStream(stream); this.syncMediaSignals(); } /** * Toggle the local microphone mute state. * * @param muted - Explicit state; if omitted, the current state is toggled. */ toggleMute(muted?: boolean): void { this.mediaManager.toggleMute(muted); this._isMuted.set(this.mediaManager.getIsMicMuted()); } /** * Toggle self-deafen (suppress incoming audio playback). * * @param deafened - Explicit state; if omitted, the current state is toggled. */ toggleDeafen(deafened?: boolean): void { this.mediaManager.toggleDeafen(deafened); this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); } /** * 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.mediaManager.toggleNoiseReduction(enabled); this._isNoiseReductionEnabled.set(this.mediaManager.getIsNoiseReductionEnabled()); } /** * Set the output volume for remote audio playback. * * @param volume - Normalised volume (0-1). */ setOutputVolume(volume: number): void { this.mediaManager.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.mediaManager.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 this.mediaManager.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 this.mediaManager.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 { if (serverId) { this.voiceServerId = serverId; } this.mediaManager.startVoiceHeartbeat(roomId, serverId); } /** Stop the voice-presence heartbeat. */ stopVoiceHeartbeat(): void { this.mediaManager.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.screenShareManager.startScreenShare(options); } /** Stop screen sharing and restore microphone audio on all peers. */ stopScreenShare(): void { this.screenShareManager.stopScreenShare(); } /** Disconnect from the signaling server and clean up all state. */ disconnect(): void { this.leaveRoom(); this.voiceServerId = null; this.peerServerMap.clear(); this.peerSignalingUrlMap.clear(); this.lastJoinedServerBySignalUrl.clear(); this.memberServerIdsBySignalUrl.clear(); this.serverSignalingUrlMap.clear(); this.mediaManager.stopVoiceHeartbeat(); this.destroyAllSignalingManagers(); this._isSignalingConnected.set(false); this._hasEverConnected.set(false); this._hasConnectionError.set(false); this._connectionErrorMessage.set(null); this.serviceDestroyed$.next(); } /** Alias for {@link disconnect}. */ disconnectAll(): void { this.disconnect(); } private fullCleanup(): void { this.voiceServerId = null; this.peerServerMap.clear(); this.peerSignalingUrlMap.clear(); this.remoteScreenShareRequestsEnabled = false; this.desiredRemoteScreenSharePeers.clear(); this.activeRemoteScreenSharePeers.clear(); this.peerManager.closeAllPeers(); this._connectedPeers.set([]); this.mediaManager.disableVoice(); this._isVoiceConnected.set(false); this.screenShareManager.stopScreenShare(); this._isScreenSharing.set(false); this._screenStreamSignal.set(null); this._isScreenShareRemotePlaybackSuppressed.set(false); this._forceDefaultRemotePlaybackOutput.set(false); } /** 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()); } /** Returns true if a peer connection is still alive enough to finish negotiating. */ private canReusePeerConnection(peer: import('./webrtc').PeerData | undefined): boolean { if (!peer) return false; const connState = peer.connection?.connectionState; return connState !== 'closed' && connState !== 'failed'; } private handlePeerControlMessage(event: ChatEvent): void { if (!event.fromPeerId) { return; } if (event.type === P2P_TYPE_SCREEN_STATE && event.isScreenSharing === false) { this.peerManager.clearRemoteScreenShareStream(event.fromPeerId); return; } if (event.type === P2P_TYPE_SCREEN_SHARE_REQUEST) { this.screenShareManager.requestScreenShareForPeer(event.fromPeerId); return; } if (event.type === P2P_TYPE_SCREEN_SHARE_STOP) { this.screenShareManager.stopScreenShareForPeer(event.fromPeerId); } } private requestRemoteScreenShares(peerIds: string[]): void { const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds()); for (const peerId of peerIds) { if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) { continue; } this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST }); this.activeRemoteScreenSharePeers.add(peerId); } } private stopRemoteScreenShares(peerIds: string[]): void { const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds()); for (const peerId of peerIds) { if (this.activeRemoteScreenSharePeers.has(peerId) && connectedPeerIds.has(peerId)) { this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_STOP }); } this.activeRemoteScreenSharePeers.delete(peerId); this.peerManager.clearRemoteScreenShareStream(peerId); } } private destroyAllSignalingManagers(): void { for (const subscriptions of this.signalingSubscriptions.values()) { for (const subscription of subscriptions) { subscription.unsubscribe(); } } for (const manager of this.signalingManagers.values()) { manager.destroy(); } this.signalingSubscriptions.clear(); this.signalingManagers.clear(); this.signalingConnectionStates.clear(); } ngOnDestroy(): void { this.disconnect(); this.serviceDestroyed$.complete(); this.peerManager.destroy(); this.mediaManager.destroy(); this.screenShareManager.destroy(); } }