import { Injectable, signal, computed, inject } 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'; // ICE server configuration for NAT traversal const ICE_SERVERS: RTCIceServer[] = [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' }, { urls: 'stun:stun2.l.google.com:19302' }, { urls: 'stun:stun3.l.google.com:19302' }, { urls: 'stun:stun4.l.google.com:19302' }, ]; interface PeerData { connection: RTCPeerConnection; dataChannel: RTCDataChannel | null; isInitiator: boolean; pendingCandidates: RTCIceCandidateInit[]; audioSender?: RTCRtpSender; videoSender?: RTCRtpSender; } @Injectable({ providedIn: 'root', }) export class WebRTCService { private timeSync = inject(TimeSyncService); private peers = new Map(); private localStream: MediaStream | null = null; private _screenStream: MediaStream | null = null; private remoteStreams = new Map(); private signalingSocket: WebSocket | null = null; private lastWsUrl: string | null = null; private reconnectAttempts = 0; private reconnectTimer: any = null; private destroy$ = new Subject(); private outputVolume = 1; private currentServerId: string | null = null; private lastIdentify: { oderId: string; displayName: string } | null = null; private lastJoin: { serverId: string; userId: string } | null = null; // Signals for reactive state private readonly _peerId = signal(uuidv4()); private readonly _isConnected = 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); // Public computed signals readonly peerId = computed(() => this._peerId()); readonly isConnected = computed(() => this._isConnected()); 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()); // Subjects for events private readonly messageReceived$ = new Subject(); private readonly peerConnected$ = new Subject(); private readonly peerDisconnected$ = new Subject(); private readonly signalingMessage$ = new Subject(); private readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>(); // Public observables readonly onMessageReceived = this.messageReceived$.asObservable(); readonly onPeerConnected = this.peerConnected$.asObservable(); readonly onPeerDisconnected = this.peerDisconnected$.asObservable(); readonly onSignalingMessage = this.signalingMessage$.asObservable(); readonly onRemoteStream = this.remoteStream$.asObservable(); // Accessor for remote screen/media streams by peer ID getRemoteStream(peerId: string): MediaStream | null { return this.remoteStreams.get(peerId) ?? null; } // Connect to signaling server connectToSignalingServer(serverUrl: string): Observable { return new Observable((observer) => { try { // Close existing connection if any if (this.signalingSocket) { this.signalingSocket.close(); } this.lastWsUrl = serverUrl; this.signalingSocket = new WebSocket(serverUrl); this.signalingSocket.onopen = () => { console.log('Connected to signaling server'); this._isConnected.set(true); this.clearReconnect(); // Re-identify and rejoin if we have prior context if (this.lastIdentify) { this.sendRawMessage({ type: 'identify', oderId: this.lastIdentify.oderId, displayName: this.lastIdentify.displayName, }); } if (this.lastJoin) { this.sendRawMessage({ type: 'join_server', serverId: this.lastJoin.serverId, }); } observer.next(true); }; this.signalingSocket.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handleSignalingMessage(message); } catch (error) { console.error('Failed to parse signaling message:', error); } }; this.signalingSocket.onerror = (error) => { console.error('Signaling socket error:', error); observer.error(error); }; this.signalingSocket.onclose = () => { console.log('Disconnected from signaling server'); this._isConnected.set(false); this.scheduleReconnect(); }; } catch (error) { observer.error(error); } }); } // Send signaling message sendSignalingMessage(message: Omit): void { if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) { console.error('Signaling socket not connected'); return; } const fullMessage: SignalingMessage = { ...message, from: this._peerId(), timestamp: Date.now(), }; this.signalingSocket.send(JSON.stringify(fullMessage)); } // Send raw message to server (for identify, join_server, etc.) sendRawMessage(message: Record): void { if (!this.signalingSocket || this.signalingSocket.readyState !== WebSocket.OPEN) { console.error('Signaling socket not connected'); return; } this.signalingSocket.send(JSON.stringify(message)); } // Create peer connection using native WebRTC private createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData { console.log(`Creating peer connection to ${remotePeerId}, initiator: ${isInitiator}`); const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS }); let dataChannel: RTCDataChannel | null = null; // Handle ICE candidates connection.onicecandidate = (event) => { if (event.candidate) { console.log('Sending ICE candidate to:', remotePeerId); this.sendRawMessage({ type: 'ice_candidate', targetUserId: remotePeerId, payload: { candidate: event.candidate }, }); } }; // Handle connection state changes connection.onconnectionstatechange = () => { console.log(`Connection state with ${remotePeerId}:`, connection.connectionState); if (connection.connectionState === 'connected') { this._connectedPeers.update((peers) => peers.includes(remotePeerId) ? peers : [...peers, remotePeerId] ); this.peerConnected$.next(remotePeerId); } else if (connection.connectionState === 'disconnected' || connection.connectionState === 'failed' || connection.connectionState === 'closed') { this.removePeer(remotePeerId); } }; // Handle incoming tracks (audio/video) connection.ontrack = (event) => { console.log(`Received track from ${remotePeerId}:`, event.track.kind); if (event.streams[0]) { this.remoteStreams.set(remotePeerId, event.streams[0]); this.remoteStream$.next({ peerId: remotePeerId, stream: event.streams[0] }); } }; // If initiator, create data channel if (isInitiator) { dataChannel = connection.createDataChannel('chat', { ordered: true }); this.setupDataChannel(dataChannel, remotePeerId); } else { // If not initiator, wait for data channel from remote peer connection.ondatachannel = (event) => { console.log('Received data channel from:', remotePeerId); dataChannel = event.channel; this.setupDataChannel(dataChannel, remotePeerId); // Update the peer data with the new channel const existing = this.peers.get(remotePeerId); if (existing) { existing.dataChannel = dataChannel; } }; } // Create and register peer data before adding local tracks const peerData: PeerData = { connection, dataChannel, isInitiator, pendingCandidates: [] }; this.peers.set(remotePeerId, peerData); // Add local stream if available if (this.localStream) { this.localStream.getTracks().forEach((track) => { const sender = connection.addTrack(track, this.localStream!); if (track.kind === 'audio') peerData.audioSender = sender; if (track.kind === 'video') peerData.videoSender = sender; }); } return peerData; } // Setup data channel event handlers private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void { channel.onopen = () => { console.log(`Data channel open with ${remotePeerId}`); }; channel.onclose = () => { console.log(`Data channel closed with ${remotePeerId}`); }; channel.onerror = (error) => { console.error(`Data channel error with ${remotePeerId}:`, error); }; channel.onmessage = (event) => { try { const message = JSON.parse(event.data); this.handlePeerMessage(remotePeerId, message); } catch (error) { console.error('Failed to parse peer message:', error); } }; } // Create and send offer private async createOffer(remotePeerId: string): Promise { const peerData = this.peers.get(remotePeerId); if (!peerData) return; try { const offer = await peerData.connection.createOffer(); await peerData.connection.setLocalDescription(offer); console.log('Sending offer to:', remotePeerId); this.sendRawMessage({ type: 'offer', targetUserId: remotePeerId, payload: { sdp: offer }, }); } catch (error) { console.error('Failed to create offer:', error); } } // Handle incoming offer private async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { console.log('Handling offer from:', fromUserId); let peerData = this.peers.get(fromUserId); if (!peerData) { peerData = this.createPeerConnection(fromUserId, false); } try { await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); // Process any pending ICE candidates for (const candidate of peerData.pendingCandidates) { await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); } peerData.pendingCandidates = []; const answer = await peerData.connection.createAnswer(); await peerData.connection.setLocalDescription(answer); console.log('Sending answer to:', fromUserId); this.sendRawMessage({ type: 'answer', targetUserId: fromUserId, payload: { sdp: answer }, }); } catch (error) { console.error('Failed to handle offer:', error); } } // Handle incoming answer private async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { console.log('Handling answer from:', fromUserId); const peerData = this.peers.get(fromUserId); if (!peerData) { console.error('No peer connection for answer from:', fromUserId); return; } try { // Only set remote description if we're in the right state if (peerData.connection.signalingState === 'have-local-offer') { await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); // Process any pending ICE candidates for (const candidate of peerData.pendingCandidates) { await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); } peerData.pendingCandidates = []; } else { console.warn('Ignoring answer - wrong signaling state:', peerData.connection.signalingState); } } catch (error) { console.error('Failed to handle answer:', error); } } // Handle incoming ICE candidate private async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise { let peerData = this.peers.get(fromUserId); if (!peerData) { // Create peer connection if it doesn't exist yet (candidate arrived before offer) console.log('Creating peer connection for early ICE candidate from:', fromUserId); peerData = this.createPeerConnection(fromUserId, false); } try { if (peerData.connection.remoteDescription) { await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate)); } else { // Queue the candidate for later console.log('Queuing ICE candidate from:', fromUserId); peerData.pendingCandidates.push(candidate); } } catch (error) { console.error('Failed to add ICE candidate:', error); } } // Handle incoming signaling messages private handleSignalingMessage(message: any): void { this.signalingMessage$.next(message); console.log('Received signaling message:', message.type, message); switch (message.type) { case 'connected': console.log('Server connection acknowledged, oderId:', message.oderId); if (typeof message.serverTime === 'number') { this.timeSync.setFromServerTime(message.serverTime); } break; case 'server_users': console.log('Users in server:', message.users); if (message.users && Array.isArray(message.users)) { message.users.forEach((user: { oderId: string; displayName: string }) => { if (user.oderId && !this.peers.has(user.oderId)) { console.log('Creating peer connection to existing user:', user.oderId); this.createPeerConnection(user.oderId, true); // Create and send offer this.createOffer(user.oderId); } }); } break; case 'user_joined': console.log('User joined:', message.displayName, message.oderId); // Don't create connection here - the new user will initiate to us break; case 'user_left': console.log('User left:', message.displayName, message.oderId); this.removePeer(message.oderId); break; case 'offer': if (message.fromUserId && message.payload?.sdp) { this.handleOffer(message.fromUserId, message.payload.sdp); } break; case 'answer': if (message.fromUserId && message.payload?.sdp) { this.handleAnswer(message.fromUserId, message.payload.sdp); } break; case 'ice_candidate': if (message.fromUserId && message.payload?.candidate) { this.handleIceCandidate(message.fromUserId, message.payload.candidate); } break; } } // Set current server ID for message routing setCurrentServer(serverId: string): void { this.currentServerId = serverId; } // Get a snapshot of currently connected peer IDs getConnectedPeers(): string[] { return this._connectedPeers(); } // Identify and remember credentials identify(oderId: string, displayName: string): void { this.lastIdentify = { oderId, displayName }; this.sendRawMessage({ type: 'identify', oderId, displayName }); } // Handle messages from peers private handlePeerMessage(peerId: string, message: any): void { console.log('Received P2P message from', peerId, ':', message); const enriched = { ...message, fromPeerId: peerId }; this.messageReceived$.next(enriched); } // Send message to all connected peers via P2P only broadcastMessage(event: ChatEvent): void { const data = JSON.stringify(event); this.peers.forEach((peerData, peerId) => { try { if (peerData.dataChannel && peerData.dataChannel.readyState === 'open') { peerData.dataChannel.send(data); console.log('Sent message via P2P to:', peerId); } } catch (error) { console.error(`Failed to send to peer ${peerId}:`, error); } }); } // Send message to specific peer sendToPeer(peerId: string, event: ChatEvent): void { const peerData = this.peers.get(peerId); if (!peerData?.dataChannel || peerData.dataChannel.readyState !== 'open') { console.error(`Peer ${peerId} not connected`); return; } try { const data = JSON.stringify(event); peerData.dataChannel.send(data); } catch (error) { console.error(`Failed to send to peer ${peerId}:`, error); } } // Remove peer connection private removePeer(peerId: string): void { const peerData = this.peers.get(peerId); if (peerData) { if (peerData.dataChannel) { peerData.dataChannel.close(); } peerData.connection.close(); this.peers.delete(peerId); this._connectedPeers.update((peers) => peers.filter((p) => p !== peerId)); this.peerDisconnected$.next(peerId); } } // Voice chat - get user media async enableVoice(): Promise { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, video: false, }); this.localStream = stream; // Add stream to all existing peers and renegotiate this.peers.forEach((peerData, peerId) => { if (this.localStream) { this.localStream.getTracks().forEach((track) => { peerData.connection.addTrack(track, this.localStream!); }); // Renegotiate to send the new tracks (both sides need to renegotiate) this.renegotiate(peerId); } }); this._isVoiceConnected.set(true); return this.localStream; } catch (error) { console.error('Failed to get user media:', error); throw error; } } // Disable voice (stop and remove audio tracks) disableVoice(): void { if (this.localStream) { this.localStream.getTracks().forEach((track) => { track.stop(); }); this.localStream = null; } // Remove audio senders from peer connections but keep connections open this.peers.forEach((peerData) => { const senders = peerData.connection.getSenders(); senders.forEach(sender => { if (sender.track?.kind === 'audio') { peerData.connection.removeTrack(sender); } }); }); // Update voice connection state this._isVoiceConnected.set(false); } // Screen sharing async startScreenShare(): Promise { try { // Check if Electron API is available for desktop capturer if (typeof window !== 'undefined' && (window as any).electronAPI?.getSources) { const sources = await (window as any).electronAPI.getSources(); const screenSource = sources.find((s: any) => s.name === 'Entire Screen') || sources[0]; this._screenStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { mandatory: { chromeMediaSource: 'desktop', chromeMediaSourceId: screenSource.id, }, } as any, }); } else { // Fallback to standard getDisplayMedia (no system audio to preserve mic) this._screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 }, }, audio: false, }); } // Add/replace screen video track to all peers and renegotiate this.peers.forEach((peerData, peerId) => { if (this._screenStream) { const videoTrack = this._screenStream.getVideoTracks()[0]; if (!videoTrack) return; const sender = peerData.connection.getSenders().find(s => s.track?.kind === 'video'); if (sender) { sender.replaceTrack(videoTrack).catch((e) => console.error('replaceTrack failed:', e)); } else { peerData.connection.addTrack(videoTrack, this._screenStream!); } // Renegotiate to ensure remote receives video this.renegotiate(peerId); } }); this._isScreenSharing.set(true); this._screenStreamSignal.set(this._screenStream); // Handle when user stops sharing via browser UI this._screenStream.getVideoTracks()[0].onended = () => { this.stopScreenShare(); }; return this._screenStream; } catch (error) { console.error('Failed to start screen share:', error); throw error; } } // Stop screen sharing stopScreenShare(): void { if (this._screenStream) { this._screenStream.getTracks().forEach((track) => { track.stop(); }); this._screenStream = null; this._screenStreamSignal.set(null); this._isScreenSharing.set(false); } // Remove sent video tracks from peers and renegotiate back to audio-only this.peers.forEach((peerData, peerId) => { const senders = peerData.connection.getSenders(); senders.forEach(sender => { if (sender.track?.kind === 'video') { peerData.connection.removeTrack(sender); } }); this.renegotiate(peerId); }); } // Join a room joinRoom(roomId: string, userId: string): void { this.lastJoin = { serverId: roomId, userId }; this.sendRawMessage({ type: 'join_server', serverId: roomId, }); } private scheduleReconnect(): void { if (this.reconnectTimer || !this.lastWsUrl) return; const delay = Math.min(30000, 1000 * Math.pow(2, this.reconnectAttempts)); // 1s,2s,4s.. up to 30s this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null; this.reconnectAttempts++; console.log('Attempting to reconnect to signaling...'); this.connectToSignalingServer(this.lastWsUrl!).subscribe({ next: () => { this.reconnectAttempts = 0; }, error: () => { // schedule next attempt this.scheduleReconnect(); }, }); }, delay); } private clearReconnect(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.reconnectAttempts = 0; } // Leave room leaveRoom(): void { this.sendRawMessage({ type: 'leave_server', }); // Close all peer connections this.peers.forEach((peerData, peerId) => { if (peerData.dataChannel) { peerData.dataChannel.close(); } peerData.connection.close(); }); this.peers.clear(); this._connectedPeers.set([]); // Stop all media this.disableVoice(); this.stopScreenShare(); } // Disconnect from signaling server disconnect(): void { this.leaveRoom(); if (this.signalingSocket) { this.signalingSocket.close(); this.signalingSocket = null; } this._isConnected.set(false); this.destroy$.next(); } // Alias for disconnect - used by components disconnectAll(): void { this.disconnect(); } // Set local media stream from external source setLocalStream(stream: MediaStream): void { this.localStream = stream; // Add stream to all existing peers and renegotiate this.peers.forEach((peerData, peerId) => { if (this.localStream) { // Remove existing audio tracks first const senders = peerData.connection.getSenders(); senders.forEach(sender => { if (sender.track?.kind === 'audio') { peerData.connection.removeTrack(sender); } }); // Add new tracks this.localStream.getTracks().forEach((track) => { const sender = peerData.connection.addTrack(track, this.localStream!); if (track.kind === 'audio') peerData.audioSender = sender; if (track.kind === 'video') peerData.videoSender = sender; }); // Renegotiate to send the new tracks (both sides need to renegotiate) this.renegotiate(peerId); } }); this._isVoiceConnected.set(true); } // Renegotiate connection (for adding/removing tracks) private async renegotiate(peerId: string): Promise { const peerData = this.peers.get(peerId); if (!peerData) return; try { const offer = await peerData.connection.createOffer(); await peerData.connection.setLocalDescription(offer); console.log('Sending renegotiation offer to:', peerId); this.sendRawMessage({ type: 'offer', targetUserId: peerId, payload: { sdp: offer }, }); } catch (error) { console.error('Failed to renegotiate:', error); } } // Toggle mute with explicit state toggleMute(muted?: boolean): void { if (this.localStream) { const audioTracks = this.localStream.getAudioTracks(); const newMutedState = muted !== undefined ? muted : !this._isMuted(); audioTracks.forEach((track) => { track.enabled = !newMutedState; }); this._isMuted.set(newMutedState); } } // Toggle deafen state toggleDeafen(deafened?: boolean): void { const newDeafenedState = deafened !== undefined ? deafened : !this._isDeafened(); this._isDeafened.set(newDeafenedState); } // Set output volume for remote streams setOutputVolume(volume: number): void { this.outputVolume = Math.max(0, Math.min(1, volume)); } // Latency/bitrate controls for audio async setAudioBitrate(kbps: number): Promise { const bps = Math.max(16000, Math.min(256000, Math.floor(kbps * 1000))); this.peers.forEach(async (peerData) => { const sender = peerData.audioSender || peerData.connection.getSenders().find(s => s.track?.kind === 'audio'); if (!sender) return; const params = sender.getParameters(); params.encodings = params.encodings || [{}]; params.encodings[0].maxBitrate = bps; try { await sender.setParameters(params); console.log('Applied audio bitrate:', bps); } catch (e) { console.warn('Failed to set audio bitrate', e); } }); } async setLatencyProfile(profile: 'low' | 'balanced' | 'high'): Promise { const map = { low: 64000, balanced: 96000, high: 128000 } as const; await this.setAudioBitrate(map[profile]); } // Cleanup ngOnDestroy(): void { this.disconnect(); this.destroy$.complete(); } }