fix voice not hearing each other

This commit is contained in:
2026-03-03 01:05:55 +01:00
parent 47304254f3
commit 50e7a66812
4 changed files with 361 additions and 97 deletions

27
.vscode/settings.json vendored Normal file
View File

@@ -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
}

View File

@@ -201,26 +201,18 @@ 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) 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);
}
message.users.forEach((user: { oderId: string; displayName: string }) => {
if (user.oderId && !this.peerManager.activePeerConnections.has(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);
@@ -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();

View File

@@ -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<string, DisconnectedPeerEntry>();
private peerReconnectTimers = new Map<string, ReturnType<typeof setInterval>>();
/**
* 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<string, Promise<void>>();
readonly peerConnected$ = new Subject<string>();
readonly peerDisconnected$ = new Subject<string>();
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<void> {
return new Promise<void>((resolve) => {
this.enqueueNegotiation(remotePeerId, async () => {
await this.doCreateAndSendOffer(remotePeerId);
resolve();
});
});
}
private async doCreateAndSendOffer(remotePeerId: string): Promise<void> {
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>): 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<void> {
handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): void {
this.enqueueNegotiation(fromUserId, () => this.doHandleOffer(fromUserId, sdp));
}
private async doHandleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
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) {
const receiverKind = transceiver.receiver.track?.kind;
if (receiverKind === TRACK_KIND_AUDIO) {
if (!peerData.audioSender) {
peerData.audioSender = transceiver.sender;
} else if (transceiver.receiver.track?.kind === TRACK_KIND_VIDEO && !peerData.videoSender) {
}
// 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<void> {
handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): void {
this.enqueueNegotiation(fromUserId, () => this.doHandleAnswer(fromUserId, sdp));
}
private async doHandleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): Promise<void> {
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<void> {
handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): void {
this.enqueueNegotiation(fromUserId, () => this.doHandleIceCandidate(fromUserId, candidate));
}
private async doHandleIceCandidate(
fromUserId: string,
candidate: RTCIceCandidateInit,
): Promise<void> {
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<void> {
return new Promise<void>((resolve) => {
this.enqueueNegotiation(peerId, async () => {
await this.doRenegotiate(peerId);
resolve();
});
});
}
private async doRenegotiate(peerId: string): Promise<void> {
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);
}

View File

@@ -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';
@@ -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<void> {
@@ -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<void> {
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({
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined }
}));
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) {}
}
}
@@ -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`;
}