From 50e7a66812ab0d12b7095ac27fefb9f26b85fae5 Mon Sep 17 00:00:00 2001 From: Myx Date: Tue, 3 Mar 2026 01:05:55 +0100 Subject: [PATCH] fix voice not hearing each other --- .vscode/settings.json | 27 ++ src/app/core/services/webrtc.service.ts | 55 ++-- .../webrtc/peer-connection.manager.ts | 298 +++++++++++++++--- .../voice-controls.component.ts | 78 +++-- 4 files changed, 361 insertions(+), 97 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c0f004d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + } + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "html" + ], + "prettier.printWidth": 150, + "prettier.singleAttributePerLine": true, + "prettier.htmlWhitespaceSensitivity": "css", + "prettier.tabWidth": 4 +} diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index 4fc6105..60ca1c4 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -201,33 +201,25 @@ export class WebRTCService implements OnDestroy { case SIGNALING_TYPE_SERVER_USERS: { this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0, serverId: message.serverId }); - // Only create peer connections for the voice server (if in voice) - // or the currently active/viewed server (if not in voice). - const effectiveServerId = this.voiceServerId || this.activeServerId; - if (message.serverId && effectiveServerId && message.serverId !== effectiveServerId) { - this.logger.info('Skipping peer connections for non-target server', { - messageServerId: message.serverId, - effectiveServerId, - voiceActive: !!this.voiceServerId, - }); - break; - } - if (message.users && Array.isArray(message.users)) { - // Close stale peer connections from other servers - if (message.serverId) { - this.closePeersNotInServer(message.serverId); - } - 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, serverId: message.serverId }); - this.peerManager.createPeerConnection(user.oderId, true); - this.peerManager.createAndSendOffer(user.oderId); - if (message.serverId) { - this.peerServerMap.set(user.oderId, message.serverId); + if (!user.oderId) return; + + const existing = this.peerManager.activePeerConnections.get(user.oderId); + const healthy = this.isPeerHealthy(existing); + if (existing && !healthy) { + this.logger.info('Removing stale peer before recreate', { oderId: user.oderId }); + this.peerManager.removePeer(user.oderId); + } + + if (!healthy) { + this.logger.info('Create peer connection to existing user', { oderId: user.oderId, serverId: message.serverId }); + this.peerManager.createPeerConnection(user.oderId, true); + this.peerManager.createAndSendOffer(user.oderId); + if (message.serverId) { + this.peerServerMap.set(user.oderId, message.serverId); + } } - } }); } break; @@ -239,6 +231,10 @@ export class WebRTCService implements OnDestroy { case SIGNALING_TYPE_USER_LEFT: this.logger.info('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId }); + if (message.oderId) { + this.peerManager.removePeer(message.oderId); + this.peerServerMap.delete(message.oderId); + } break; case SIGNALING_TYPE_OFFER: @@ -574,9 +570,6 @@ export class WebRTCService implements OnDestroy { startVoiceHeartbeat(roomId?: string, serverId?: string): void { if (serverId) { this.voiceServerId = serverId; - // Remove peer connections that belong to a different server - // so audio does not leak across voice channels. - this.closePeersNotInServer(serverId); } this.mediaManager.startVoiceHeartbeat(roomId, serverId); } @@ -644,6 +637,14 @@ export class WebRTCService implements OnDestroy { this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); } + /** Returns true if a peer connection exists and its data channel is open. */ + private isPeerHealthy(peer: import('./webrtc').PeerData | undefined): boolean { + if (!peer) return false; + const connState = peer.connection?.connectionState; + const dcState = peer.dataChannel?.readyState; + return connState === 'connected' && dcState === 'open'; + } + ngOnDestroy(): void { this.disconnect(); this.serviceDestroyed$.complete(); diff --git a/src/app/core/services/webrtc/peer-connection.manager.ts b/src/app/core/services/webrtc/peer-connection.manager.ts index 3305431..c041ffc 100644 --- a/src/app/core/services/webrtc/peer-connection.manager.ts +++ b/src/app/core/services/webrtc/peer-connection.manager.ts @@ -5,7 +5,12 @@ import { Subject } from 'rxjs'; import { ChatEvent } from '../../models'; import { WebRTCLogger } from './webrtc-logger'; -import { PeerData, DisconnectedPeerEntry, VoiceStateSnapshot, IdentifyCredentials } from './webrtc.types'; +import { + PeerData, + DisconnectedPeerEntry, + VoiceStateSnapshot, + IdentifyCredentials, +} from './webrtc.types'; import { ICE_SERVERS, DATA_CHANNEL_LABEL, @@ -64,6 +69,13 @@ export class PeerConnectionManager { private disconnectedPeerTracker = new Map(); private peerReconnectTimers = new Map>(); + /** + * Per-peer promise chain that serialises all SDP operations + * (handleOffer, handleAnswer, renegotiate) so they never run + * concurrently on the same RTCPeerConnection. + */ + private readonly peerNegotiationQueue = new Map>(); + readonly peerConnected$ = new Subject(); readonly peerDisconnected$ = new Subject(); readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>(); @@ -106,7 +118,10 @@ export class PeerConnectionManager { // ICE candidates → signaling connection.onicecandidate = (event) => { if (event.candidate) { - this.logger.info('ICE candidate gathered', { remotePeerId, candidateType: (event.candidate as any)?.type }); + this.logger.info('ICE candidate gathered', { + remotePeerId, + candidateType: (event.candidate as any)?.type, + }); this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_ICE_CANDIDATE, targetUserId: remotePeerId, @@ -117,7 +132,10 @@ export class PeerConnectionManager { // Connection state connection.onconnectionstatechange = () => { - this.logger.info('connectionstatechange', { remotePeerId, state: connection.connectionState }); + this.logger.info('connectionstatechange', { + remotePeerId, + state: connection.connectionState, + }); switch (connection.connectionState) { case CONNECTION_STATE_CONNECTED: @@ -143,7 +161,10 @@ export class PeerConnectionManager { // Additional state logs connection.oniceconnectionstatechange = () => { - this.logger.info('iceconnectionstatechange', { remotePeerId, state: connection.iceConnectionState }); + this.logger.info('iceconnectionstatechange', { + remotePeerId, + state: connection.iceConnectionState, + }); }; connection.onsignalingstatechange = () => { this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState }); @@ -166,7 +187,9 @@ export class PeerConnectionManager { this.logger.info('Received data channel', { remotePeerId }); dataChannel = event.channel; const existing = this.activePeerConnections.get(remotePeerId); - if (existing) { existing.dataChannel = dataChannel; } + if (existing) { + existing.dataChannel = dataChannel; + } this.setupDataChannel(dataChannel, remotePeerId); }; } @@ -182,8 +205,12 @@ export class PeerConnectionManager { // Pre-create transceivers only for the initiator (offerer). if (isInitiator) { - const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); - const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_RECV_ONLY }); + const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, { + direction: TRANSCEIVER_SEND_RECV, + }); + const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, { + direction: TRANSCEIVER_RECV_ONLY, + }); peerData.audioSender = audioTransceiver.sender; peerData.videoSender = videoTransceiver.sender; } @@ -196,13 +223,19 @@ export class PeerConnectionManager { this.logger.logStream(`localStream->${remotePeerId}`, localStream); localStream.getTracks().forEach((track) => { if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) { - peerData.audioSender.replaceTrack(track) + peerData.audioSender + .replaceTrack(track) .then(() => this.logger.info('audio replaceTrack (init) ok', { remotePeerId })) - .catch((e) => this.logger.error('audio replaceTrack failed at createPeerConnection', e)); + .catch((e) => + this.logger.error('audio replaceTrack failed at createPeerConnection', e), + ); } else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) { - peerData.videoSender.replaceTrack(track) + peerData.videoSender + .replaceTrack(track) .then(() => this.logger.info('video replaceTrack (init) ok', { remotePeerId })) - .catch((e) => this.logger.error('video replaceTrack failed at createPeerConnection', e)); + .catch((e) => + this.logger.error('video replaceTrack failed at createPeerConnection', e), + ); } else { const sender = connection.addTrack(track, localStream); if (track.kind === TRACK_KIND_AUDIO) peerData.audioSender = sender; @@ -217,16 +250,31 @@ export class PeerConnectionManager { /** * Create an SDP offer and send it to the remote peer via the signaling server. * + * Serialised per-peer via the negotiation queue. + * * @param remotePeerId - The peer to send the offer to. */ async createAndSendOffer(remotePeerId: string): Promise { + return new Promise((resolve) => { + this.enqueueNegotiation(remotePeerId, async () => { + await this.doCreateAndSendOffer(remotePeerId); + resolve(); + }); + }); + } + + private async doCreateAndSendOffer(remotePeerId: string): Promise { const peerData = this.activePeerConnections.get(remotePeerId); if (!peerData) return; try { const offer = await peerData.connection.createOffer(); await peerData.connection.setLocalDescription(offer); - this.logger.info('Sending offer', { remotePeerId, type: offer.type, sdpLength: offer.sdp?.length }); + this.logger.info('Sending offer', { + remotePeerId, + type: offer.type, + sdpLength: offer.sdp?.length, + }); this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_OFFER, targetUserId: remotePeerId, @@ -237,17 +285,42 @@ export class PeerConnectionManager { } } + // ═══════════════════════════════════════════════════════════════════ + // Per-peer negotiation serialisation helpers + // ═══════════════════════════════════════════════════════════════════ + + /** + * Enqueue an async SDP operation for a given peer. + * + * All operations on the same peer run strictly in order, preventing + * concurrent `setLocalDescription` / `setRemoteDescription` calls + * that corrupt the RTCPeerConnection signalling state. + */ + private enqueueNegotiation(peerId: string, task: () => Promise): void { + const prev = this.peerNegotiationQueue.get(peerId) ?? Promise.resolve(); + const next = prev.then(task, task); // always chain, even after rejection + this.peerNegotiationQueue.set(peerId, next); + } + /** * Handle an incoming SDP offer from a remote peer. * - * Creates the peer connection if it doesn't exist, sets the remote - * description, discovers browser-created transceivers, attaches local - * tracks, flushes queued ICE candidates, and sends back an answer. + * Implements the "perfect negotiation" pattern to resolve offer + * collisions (glare). When both sides send offers simultaneously + * the **polite** peer (higher ID) rolls back its own offer and + * accepts the remote one; the **impolite** peer (lower ID) ignores + * the incoming offer and waits for an answer to its own. + * + * The actual SDP work is serialised per-peer to prevent races. * * @param fromUserId - The peer ID that sent the offer. * @param sdp - The remote session description. */ - async handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { + handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): void { + this.enqueueNegotiation(fromUserId, () => this.doHandleOffer(fromUserId, sdp)); + } + + private async doHandleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { this.logger.info('Handling offer', { fromUserId }); let peerData = this.activePeerConnections.get(fromUserId); @@ -256,14 +329,46 @@ export class PeerConnectionManager { } try { + // ── Offer-collision (glare) detection ────────────────────────── + const signalingState = peerData.connection.signalingState; + const collision = + signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer'; + + if (collision) { + const localId = + this.callbacks.getIdentifyCredentials()?.oderId || this.callbacks.getLocalPeerId(); + // The "polite" peer (lexicographically greater ID) yields its own offer. + const isPolite = localId > fromUserId; + + if (!isPolite) { + this.logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId }); + return; // Our offer takes priority – remote will answer it. + } + + this.logger.info('Rolling back local offer (polite side)', { fromUserId, localId }); + await peerData.connection.setLocalDescription({ + type: 'rollback', + } as RTCSessionDescriptionInit); + } + // ────────────────────────────────────────────────────────────── + await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp)); // Discover transceivers the browser created on the answerer side + // and ensure audio transceivers are set to sendrecv for bidirectional voice. + // Without this, the answerer's SDP answer defaults to recvonly for audio, + // making the connection one-way (only the offerer's audio is heard). const transceivers = peerData.connection.getTransceivers(); for (const transceiver of transceivers) { - if (transceiver.receiver.track?.kind === TRACK_KIND_AUDIO && !peerData.audioSender) { - peerData.audioSender = transceiver.sender; - } else if (transceiver.receiver.track?.kind === TRACK_KIND_VIDEO && !peerData.videoSender) { + const receiverKind = transceiver.receiver.track?.kind; + if (receiverKind === TRACK_KIND_AUDIO) { + if (!peerData.audioSender) { + peerData.audioSender = transceiver.sender; + } + // Promote to sendrecv so the SDP answer includes a send direction, + // enabling bidirectional audio regardless of who initiated the connection. + transceiver.direction = TRANSCEIVER_SEND_RECV; + } else if (receiverKind === TRACK_KIND_VIDEO && !peerData.videoSender) { peerData.videoSender = transceiver.sender; } } @@ -292,7 +397,11 @@ export class PeerConnectionManager { const answer = await peerData.connection.createAnswer(); await peerData.connection.setLocalDescription(answer); - this.logger.info('Sending answer', { to: fromUserId, type: answer.type, sdpLength: answer.sdp?.length }); + this.logger.info('Sending answer', { + to: fromUserId, + type: answer.type, + sdpLength: answer.sdp?.length, + }); this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_ANSWER, targetUserId: fromUserId, @@ -309,10 +418,16 @@ export class PeerConnectionManager { * Sets the remote description and flushes any queued ICE candidates. * Ignored if the connection is not in the `have-local-offer` state. * + * Serialised per-peer via the negotiation queue. + * * @param fromUserId - The peer ID that sent the answer. * @param sdp - The remote session description. */ - async handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { + handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): void { + this.enqueueNegotiation(fromUserId, () => this.doHandleAnswer(fromUserId, sdp)); + } + + private async doHandleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise { this.logger.info('Handling answer', { fromUserId }); const peerData = this.activePeerConnections.get(fromUserId); if (!peerData) { @@ -328,7 +443,9 @@ export class PeerConnectionManager { } peerData.pendingIceCandidates = []; } else { - this.logger.warn('Ignoring answer – wrong signaling state', { state: peerData.connection.signalingState }); + this.logger.warn('Ignoring answer – wrong signaling state', { + state: peerData.connection.signalingState, + }); } } catch (error) { this.logger.error('Failed to handle answer', error); @@ -341,10 +458,19 @@ export class PeerConnectionManager { * If the remote description has already been set the candidate is added * immediately; otherwise it is queued until the description arrives. * + * Serialised per-peer via the negotiation queue. + * * @param fromUserId - The peer ID that sent the candidate. * @param candidate - The ICE candidate to add. */ - async handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): Promise { + handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): void { + this.enqueueNegotiation(fromUserId, () => this.doHandleIceCandidate(fromUserId, candidate)); + } + + private async doHandleIceCandidate( + fromUserId: string, + candidate: RTCIceCandidateInit, + ): Promise { let peerData = this.activePeerConnections.get(fromUserId); if (!peerData) { this.logger.info('Creating peer for early ICE', { fromUserId }); @@ -363,15 +489,33 @@ export class PeerConnectionManager { } } - /** Re-negotiate (create offer) to push track changes to remote. */ + /** + * Re-negotiate (create offer) to push track changes to remote. + * + * Serialised per-peer via the negotiation queue so it never races + * against an incoming offer or a previous renegotiate. + */ async renegotiate(peerId: string): Promise { + return new Promise((resolve) => { + this.enqueueNegotiation(peerId, async () => { + await this.doRenegotiate(peerId); + resolve(); + }); + }); + } + + private async doRenegotiate(peerId: string): Promise { const peerData = this.activePeerConnections.get(peerId); if (!peerData) return; try { const offer = await peerData.connection.createOffer(); await peerData.connection.setLocalDescription(offer); - this.logger.info('Renegotiate offer', { peerId, type: offer.type, sdpLength: offer.sdp?.length }); + this.logger.info('Renegotiate offer', { + peerId, + type: offer.type, + sdpLength: offer.sdp?.length, + }); this.callbacks.sendRawMessage({ type: SIGNALING_TYPE_OFFER, targetUserId: peerId, @@ -395,7 +539,11 @@ export class PeerConnectionManager { channel.onopen = () => { this.logger.info('Data channel open', { remotePeerId }); this.sendCurrentStatesToChannel(channel, remotePeerId); - try { channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); } catch { /* ignore */ } + try { + channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST })); + } catch { + /* ignore */ + } }; channel.onclose = () => { @@ -509,7 +657,11 @@ export class PeerConnectionManager { }); } - try { channel.send(data); } catch (error) { this.logger.error('Failed to send buffered message', error, { peerId }); } + try { + channel.send(data); + } catch (error) { + this.logger.error('Failed to send buffered message', error, { peerId }); + } } /** @@ -524,12 +676,20 @@ export class PeerConnectionManager { const voiceState = this.callbacks.getVoiceStateSnapshot(); this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); - this.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any); + this.sendToPeer(peerId, { + type: P2P_TYPE_SCREEN_STATE, + oderId, + displayName, + isScreenSharing: this.callbacks.isScreenSharingActive(), + } as any); } private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void { if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) { - this.logger.warn('Cannot send states – channel not open', { remotePeerId, state: channel.readyState }); + this.logger.warn('Cannot send states – channel not open', { + remotePeerId, + state: channel.readyState, + }); return; } const credentials = this.callbacks.getIdentifyCredentials(); @@ -539,7 +699,14 @@ export class PeerConnectionManager { try { channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState })); - channel.send(JSON.stringify({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() })); + channel.send( + JSON.stringify({ + type: P2P_TYPE_SCREEN_STATE, + oderId, + displayName, + isScreenSharing: this.callbacks.isScreenSharingActive(), + }), + ); this.logger.info('Sent initial states to channel', { remotePeerId, voiceState }); } catch (e) { this.logger.error('Failed to send initial states to channel', e); @@ -554,26 +721,49 @@ export class PeerConnectionManager { const voiceState = this.callbacks.getVoiceStateSnapshot(); this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); - this.broadcastMessage({ type: P2P_TYPE_SCREEN_STATE, oderId, displayName, isScreenSharing: this.callbacks.isScreenSharingActive() } as any); + this.broadcastMessage({ + type: P2P_TYPE_SCREEN_STATE, + oderId, + displayName, + isScreenSharing: this.callbacks.isScreenSharingActive(), + } as any); } private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void { const track = event.track; - const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings; - this.logger.info('Remote track', { remotePeerId, kind: track.kind, id: track.id, enabled: track.enabled, readyState: track.readyState, settings }); + const settings = + typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings); + this.logger.info('Remote track', { + remotePeerId, + kind: track.kind, + id: track.id, + enabled: track.enabled, + readyState: track.readyState, + settings, + }); this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`); // Skip inactive video placeholder tracks if (track.kind === TRACK_KIND_VIDEO && (!track.enabled || track.readyState !== 'live')) { - this.logger.info('Skipping inactive video track', { remotePeerId, enabled: track.enabled, readyState: track.readyState }); + this.logger.info('Skipping inactive video track', { + remotePeerId, + enabled: track.enabled, + readyState: track.readyState, + }); return; } // Merge into composite stream per peer let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream(); - const trackAlreadyAdded = compositeStream.getTracks().some(existingTrack => existingTrack.id === track.id); + const trackAlreadyAdded = compositeStream + .getTracks() + .some((existingTrack) => existingTrack.id === track.id); if (!trackAlreadyAdded) { - try { compositeStream.addTrack(track); } catch (e) { this.logger.warn('Failed to add track to composite stream', e as any); } + try { + compositeStream.addTrack(track); + } catch (e) { + this.logger.warn('Failed to add track to composite stream', e as any); + } } this.remotePeerStreams.set(remotePeerId, compositeStream); this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream }); @@ -590,6 +780,7 @@ export class PeerConnectionManager { if (peerData.dataChannel) peerData.dataChannel.close(); peerData.connection.close(); this.activePeerConnections.delete(peerId); + this.peerNegotiationQueue.delete(peerId); this.removeFromConnectedPeers(peerId); this.peerDisconnected$.next(peerId); } @@ -603,16 +794,23 @@ export class PeerConnectionManager { peerData.connection.close(); }); this.activePeerConnections.clear(); + this.peerNegotiationQueue.clear(); this.connectedPeersChanged$.next([]); } private trackDisconnectedPeer(peerId: string): void { - this.disconnectedPeerTracker.set(peerId, { lastSeenTimestamp: Date.now(), reconnectAttempts: 0 }); + this.disconnectedPeerTracker.set(peerId, { + lastSeenTimestamp: Date.now(), + reconnectAttempts: 0, + }); } private clearPeerReconnectTimer(peerId: string): void { const timer = this.peerReconnectTimers.get(peerId); - if (timer) { clearInterval(timer); this.peerReconnectTimers.delete(peerId); } + if (timer) { + clearInterval(timer); + this.peerReconnectTimers.delete(peerId); + } } /** Cancel all pending peer reconnect timers and clear the tracker. */ @@ -628,7 +826,10 @@ export class PeerConnectionManager { const timer = setInterval(() => { const info = this.disconnectedPeerTracker.get(peerId); - if (!info) { this.clearPeerReconnectTimer(peerId); return; } + if (!info) { + this.clearPeerReconnectTimer(peerId); + return; + } info.reconnectAttempts++; this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts }); @@ -653,7 +854,14 @@ export class PeerConnectionManager { private attemptPeerReconnect(peerId: string): void { const existing = this.activePeerConnections.get(peerId); - if (existing) { try { existing.connection.close(); } catch { /* ignore */ } this.activePeerConnections.delete(peerId); } + if (existing) { + try { + existing.connection.close(); + } catch { + /* ignore */ + } + this.activePeerConnections.delete(peerId); + } this.createPeerConnection(peerId, true); this.createAndSendOffer(peerId); } @@ -661,7 +869,11 @@ export class PeerConnectionManager { private requestVoiceStateFromPeer(peerId: string): void { const peerData = this.activePeerConnections.get(peerId); if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { - try { peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST })); } catch (e) { this.logger.warn('Failed to request voice state', e as any); } + try { + peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST })); + } catch (e) { + this.logger.warn('Failed to request voice state', e as any); + } } } @@ -685,7 +897,9 @@ export class PeerConnectionManager { * @param peerId - The peer to remove. */ private removeFromConnectedPeers(peerId: string): void { - this.connectedPeersList = this.connectedPeersList.filter(connectedId => connectedId !== peerId); + this.connectedPeersList = this.connectedPeersList.filter( + (connectedId) => connectedId !== peerId, + ); this.connectedPeersChanged$.next(this.connectedPeersList); } diff --git a/src/app/features/voice/voice-controls/voice-controls.component.ts b/src/app/features/voice/voice-controls/voice-controls.component.ts index 48bb78b..add6d4d 100644 --- a/src/app/features/voice/voice-controls/voice-controls.component.ts +++ b/src/app/features/voice/voice-controls/voice-controls.component.ts @@ -1,4 +1,13 @@ -import { Component, inject, signal, OnInit, OnDestroy, ElementRef, ViewChild, computed } from '@angular/core'; +import { + Component, + inject, + signal, + OnInit, + OnDestroy, + ElementRef, + ViewChild, + computed, +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; @@ -75,7 +84,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { inputVolume = signal(100); outputVolume = signal(100); audioBitrate = signal(96); - latencyProfile = signal<'low'|'balanced'|'high'>('balanced'); + latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced'); includeSystemAudio = signal(false); private voiceConnectedSubscription: Subscription | null = null; @@ -91,7 +100,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe( ({ peerId, stream }) => { this.playRemoteAudio(peerId, stream); - } + }, ); // Subscribe to voice connected event to play pending streams and ensure all remote audio is set up @@ -208,9 +217,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { } // Play the audio - audio.play().then(() => { - }).catch((error) => { - }); + audio + .play() + .then(() => {}) + .catch((error) => {}); this.remoteAudioElements.set(peerId, audio); } @@ -224,15 +234,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { this.inputDevices.set( devices .filter((device) => device.kind === 'audioinput') - .map((device) => ({ deviceId: device.deviceId, label: device.label })) + .map((device) => ({ deviceId: device.deviceId, label: device.label })), ); this.outputDevices.set( devices .filter((device) => device.kind === 'audiooutput') - .map((device) => ({ deviceId: device.deviceId, label: device.label })) + .map((device) => ({ deviceId: device.deviceId, label: device.label })), ); - } catch (error) { - } + } catch (error) {} } async connect(): Promise { @@ -264,8 +273,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { } // Start voice heartbeat to broadcast presence every 5 seconds - const roomId = this.currentUser()?.voiceState?.roomId; - this.webrtcService.startVoiceHeartbeat(roomId); + const room = this.currentRoom(); + const roomId = this.currentUser()?.voiceState?.roomId || room?.id; + const serverId = room?.id; + this.webrtcService.startVoiceHeartbeat(roomId, serverId); // Broadcast voice state to other users this.webrtcService.broadcastMessage({ @@ -277,6 +288,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { isMuted: this.isMuted(), isDeafened: this.isDeafened(), roomId, + serverId, }, }); @@ -288,16 +300,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { // Persist settings after successful connection this.saveSettings(); - } catch (error) { - } + } catch (error) {} } // Retry connection when there's a connection error async retryConnection(): Promise { try { await this.webrtcService.ensureSignalingConnected(10000); - } catch (_error) { - } + } catch (_error) {} } disconnect(): void { @@ -313,6 +323,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { isConnected: false, isMuted: false, isDeafened: false, + serverId: this.currentRoom()?.id, }, }); @@ -340,10 +351,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { const user = this.currentUser(); if (user?.id) { - this.store.dispatch(UsersActions.updateVoiceState({ - userId: user.id, - voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } - })); + this.store.dispatch( + UsersActions.updateVoiceState({ + userId: user.id, + voiceState: { + isConnected: false, + isMuted: false, + isDeafened: false, + roomId: undefined, + serverId: undefined, + }, + }), + ); } // End voice session for floating controls @@ -407,8 +426,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { try { await this.webrtcService.startScreenShare(this.includeSystemAudio()); this.isScreenSharing.set(true); - } catch (error) { - } + } catch (error) {} } } @@ -458,7 +476,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { onLatencyProfileChange(event: Event): void { const select = event.target as HTMLSelectElement; - const profile = select.value as 'low'|'balanced'|'high'; + const profile = select.value as 'low' | 'balanced' | 'high'; this.latencyProfile.set(profile); this.webrtcService.setLatencyProfile(profile); this.saveSettings(); @@ -488,7 +506,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { inputVolume?: number; outputVolume?: number; audioBitrate?: number; - latencyProfile?: 'low'|'balanced'|'high'; + latencyProfile?: 'low' | 'balanced' | 'high'; includeSystemAudio?: boolean; }; if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice); @@ -497,7 +515,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { if (typeof settings.outputVolume === 'number') this.outputVolume.set(settings.outputVolume); if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate); if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile); - if (typeof settings.includeSystemAudio === 'boolean') this.includeSystemAudio.set(settings.includeSystemAudio); + if (typeof settings.includeSystemAudio === 'boolean') + this.includeSystemAudio.set(settings.includeSystemAudio); } catch {} } @@ -537,7 +556,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { } getMuteButtonClass(): string { - const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; + const base = + 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; if (this.isMuted()) { return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; } @@ -545,7 +565,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { } getDeafenButtonClass(): string { - const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; + const base = + 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; if (this.isDeafened()) { return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; } @@ -553,7 +574,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { } getScreenShareButtonClass(): string { - const base = 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; + const base = + 'w-10 h-10 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed'; if (this.isScreenSharing()) { return `${base} bg-primary/20 text-primary hover:bg-primary/30`; }