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,33 +201,25 @@ export class WebRTCService implements OnDestroy {
case SIGNALING_TYPE_SERVER_USERS: { case SIGNALING_TYPE_SERVER_USERS: {
this.logger.info('Server users', { count: Array.isArray(message.users) ? message.users.length : 0, serverId: message.serverId }); 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)) { 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 }) => { message.users.forEach((user: { oderId: string; displayName: string }) => {
if (user.oderId && !this.peerManager.activePeerConnections.has(user.oderId)) { if (!user.oderId) return;
this.logger.info('Create peer connection to existing user', { oderId: user.oderId, serverId: message.serverId });
this.peerManager.createPeerConnection(user.oderId, true); const existing = this.peerManager.activePeerConnections.get(user.oderId);
this.peerManager.createAndSendOffer(user.oderId); const healthy = this.isPeerHealthy(existing);
if (message.serverId) { if (existing && !healthy) {
this.peerServerMap.set(user.oderId, message.serverId); 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; break;
@@ -239,6 +231,10 @@ export class WebRTCService implements OnDestroy {
case SIGNALING_TYPE_USER_LEFT: case SIGNALING_TYPE_USER_LEFT:
this.logger.info('User left', { displayName: message.displayName, oderId: message.oderId, serverId: message.serverId }); 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; break;
case SIGNALING_TYPE_OFFER: case SIGNALING_TYPE_OFFER:
@@ -574,9 +570,6 @@ export class WebRTCService implements OnDestroy {
startVoiceHeartbeat(roomId?: string, serverId?: string): void { startVoiceHeartbeat(roomId?: string, serverId?: string): void {
if (serverId) { if (serverId) {
this.voiceServerId = 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); this.mediaManager.startVoiceHeartbeat(roomId, serverId);
} }
@@ -644,6 +637,14 @@ export class WebRTCService implements OnDestroy {
this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); 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 { ngOnDestroy(): void {
this.disconnect(); this.disconnect();
this.serviceDestroyed$.complete(); this.serviceDestroyed$.complete();

View File

@@ -5,7 +5,12 @@
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ChatEvent } from '../../models'; import { ChatEvent } from '../../models';
import { WebRTCLogger } from './webrtc-logger'; import { WebRTCLogger } from './webrtc-logger';
import { PeerData, DisconnectedPeerEntry, VoiceStateSnapshot, IdentifyCredentials } from './webrtc.types'; import {
PeerData,
DisconnectedPeerEntry,
VoiceStateSnapshot,
IdentifyCredentials,
} from './webrtc.types';
import { import {
ICE_SERVERS, ICE_SERVERS,
DATA_CHANNEL_LABEL, DATA_CHANNEL_LABEL,
@@ -64,6 +69,13 @@ export class PeerConnectionManager {
private disconnectedPeerTracker = new Map<string, DisconnectedPeerEntry>(); private disconnectedPeerTracker = new Map<string, DisconnectedPeerEntry>();
private peerReconnectTimers = new Map<string, ReturnType<typeof setInterval>>(); 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 peerConnected$ = new Subject<string>();
readonly peerDisconnected$ = new Subject<string>(); readonly peerDisconnected$ = new Subject<string>();
readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>(); readonly remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
@@ -106,7 +118,10 @@ export class PeerConnectionManager {
// ICE candidates → signaling // ICE candidates → signaling
connection.onicecandidate = (event) => { connection.onicecandidate = (event) => {
if (event.candidate) { 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({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ICE_CANDIDATE, type: SIGNALING_TYPE_ICE_CANDIDATE,
targetUserId: remotePeerId, targetUserId: remotePeerId,
@@ -117,7 +132,10 @@ export class PeerConnectionManager {
// Connection state // Connection state
connection.onconnectionstatechange = () => { connection.onconnectionstatechange = () => {
this.logger.info('connectionstatechange', { remotePeerId, state: connection.connectionState }); this.logger.info('connectionstatechange', {
remotePeerId,
state: connection.connectionState,
});
switch (connection.connectionState) { switch (connection.connectionState) {
case CONNECTION_STATE_CONNECTED: case CONNECTION_STATE_CONNECTED:
@@ -143,7 +161,10 @@ export class PeerConnectionManager {
// Additional state logs // Additional state logs
connection.oniceconnectionstatechange = () => { connection.oniceconnectionstatechange = () => {
this.logger.info('iceconnectionstatechange', { remotePeerId, state: connection.iceConnectionState }); this.logger.info('iceconnectionstatechange', {
remotePeerId,
state: connection.iceConnectionState,
});
}; };
connection.onsignalingstatechange = () => { connection.onsignalingstatechange = () => {
this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState }); this.logger.info('signalingstatechange', { remotePeerId, state: connection.signalingState });
@@ -166,7 +187,9 @@ export class PeerConnectionManager {
this.logger.info('Received data channel', { remotePeerId }); this.logger.info('Received data channel', { remotePeerId });
dataChannel = event.channel; dataChannel = event.channel;
const existing = this.activePeerConnections.get(remotePeerId); const existing = this.activePeerConnections.get(remotePeerId);
if (existing) { existing.dataChannel = dataChannel; } if (existing) {
existing.dataChannel = dataChannel;
}
this.setupDataChannel(dataChannel, remotePeerId); this.setupDataChannel(dataChannel, remotePeerId);
}; };
} }
@@ -182,8 +205,12 @@ export class PeerConnectionManager {
// Pre-create transceivers only for the initiator (offerer). // Pre-create transceivers only for the initiator (offerer).
if (isInitiator) { if (isInitiator) {
const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, { direction: TRANSCEIVER_SEND_RECV }); const audioTransceiver = connection.addTransceiver(TRACK_KIND_AUDIO, {
const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, { direction: TRANSCEIVER_RECV_ONLY }); direction: TRANSCEIVER_SEND_RECV,
});
const videoTransceiver = connection.addTransceiver(TRACK_KIND_VIDEO, {
direction: TRANSCEIVER_RECV_ONLY,
});
peerData.audioSender = audioTransceiver.sender; peerData.audioSender = audioTransceiver.sender;
peerData.videoSender = videoTransceiver.sender; peerData.videoSender = videoTransceiver.sender;
} }
@@ -196,13 +223,19 @@ export class PeerConnectionManager {
this.logger.logStream(`localStream->${remotePeerId}`, localStream); this.logger.logStream(`localStream->${remotePeerId}`, localStream);
localStream.getTracks().forEach((track) => { localStream.getTracks().forEach((track) => {
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) { 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 })) .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) { } 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 })) .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 { } else {
const sender = connection.addTrack(track, localStream); const sender = connection.addTrack(track, localStream);
if (track.kind === TRACK_KIND_AUDIO) peerData.audioSender = sender; 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. * 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. * @param remotePeerId - The peer to send the offer to.
*/ */
async createAndSendOffer(remotePeerId: string): Promise<void> { 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); const peerData = this.activePeerConnections.get(remotePeerId);
if (!peerData) return; if (!peerData) return;
try { try {
const offer = await peerData.connection.createOffer(); const offer = await peerData.connection.createOffer();
await peerData.connection.setLocalDescription(offer); 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({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER, type: SIGNALING_TYPE_OFFER,
targetUserId: remotePeerId, 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. * Handle an incoming SDP offer from a remote peer.
* *
* Creates the peer connection if it doesn't exist, sets the remote * Implements the "perfect negotiation" pattern to resolve offer
* description, discovers browser-created transceivers, attaches local * collisions (glare). When both sides send offers simultaneously
* tracks, flushes queued ICE candidates, and sends back an answer. * 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 fromUserId - The peer ID that sent the offer.
* @param sdp - The remote session description. * @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 }); this.logger.info('Handling offer', { fromUserId });
let peerData = this.activePeerConnections.get(fromUserId); let peerData = this.activePeerConnections.get(fromUserId);
@@ -256,14 +329,46 @@ export class PeerConnectionManager {
} }
try { 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)); await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
// Discover transceivers the browser created on the answerer side // 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(); const transceivers = peerData.connection.getTransceivers();
for (const transceiver of transceivers) { for (const transceiver of transceivers) {
if (transceiver.receiver.track?.kind === TRACK_KIND_AUDIO && !peerData.audioSender) { const receiverKind = transceiver.receiver.track?.kind;
peerData.audioSender = transceiver.sender; if (receiverKind === TRACK_KIND_AUDIO) {
} else if (transceiver.receiver.track?.kind === TRACK_KIND_VIDEO && !peerData.videoSender) { 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; peerData.videoSender = transceiver.sender;
} }
} }
@@ -292,7 +397,11 @@ export class PeerConnectionManager {
const answer = await peerData.connection.createAnswer(); const answer = await peerData.connection.createAnswer();
await peerData.connection.setLocalDescription(answer); 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({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_ANSWER, type: SIGNALING_TYPE_ANSWER,
targetUserId: fromUserId, targetUserId: fromUserId,
@@ -309,10 +418,16 @@ export class PeerConnectionManager {
* Sets the remote description and flushes any queued ICE candidates. * Sets the remote description and flushes any queued ICE candidates.
* Ignored if the connection is not in the `have-local-offer` state. * 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 fromUserId - The peer ID that sent the answer.
* @param sdp - The remote session description. * @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 }); this.logger.info('Handling answer', { fromUserId });
const peerData = this.activePeerConnections.get(fromUserId); const peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) { if (!peerData) {
@@ -328,7 +443,9 @@ export class PeerConnectionManager {
} }
peerData.pendingIceCandidates = []; peerData.pendingIceCandidates = [];
} else { } 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) { } catch (error) {
this.logger.error('Failed to handle answer', 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 * If the remote description has already been set the candidate is added
* immediately; otherwise it is queued until the description arrives. * 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 fromUserId - The peer ID that sent the candidate.
* @param candidate - The ICE candidate to add. * @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); let peerData = this.activePeerConnections.get(fromUserId);
if (!peerData) { if (!peerData) {
this.logger.info('Creating peer for early ICE', { fromUserId }); 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> { 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); const peerData = this.activePeerConnections.get(peerId);
if (!peerData) return; if (!peerData) return;
try { try {
const offer = await peerData.connection.createOffer(); const offer = await peerData.connection.createOffer();
await peerData.connection.setLocalDescription(offer); 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({ this.callbacks.sendRawMessage({
type: SIGNALING_TYPE_OFFER, type: SIGNALING_TYPE_OFFER,
targetUserId: peerId, targetUserId: peerId,
@@ -395,7 +539,11 @@ export class PeerConnectionManager {
channel.onopen = () => { channel.onopen = () => {
this.logger.info('Data channel open', { remotePeerId }); this.logger.info('Data channel open', { remotePeerId });
this.sendCurrentStatesToChannel(channel, 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 = () => { 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(); const voiceState = this.callbacks.getVoiceStateSnapshot();
this.sendToPeer(peerId, { type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); 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 { private sendCurrentStatesToChannel(channel: RTCDataChannel, remotePeerId: string): void {
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) { 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; return;
} }
const credentials = this.callbacks.getIdentifyCredentials(); const credentials = this.callbacks.getIdentifyCredentials();
@@ -539,7 +699,14 @@ export class PeerConnectionManager {
try { try {
channel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState })); 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 }); this.logger.info('Sent initial states to channel', { remotePeerId, voiceState });
} catch (e) { } catch (e) {
this.logger.error('Failed to send initial states to channel', e); this.logger.error('Failed to send initial states to channel', e);
@@ -554,26 +721,49 @@ export class PeerConnectionManager {
const voiceState = this.callbacks.getVoiceStateSnapshot(); const voiceState = this.callbacks.getVoiceStateSnapshot();
this.broadcastMessage({ type: P2P_TYPE_VOICE_STATE, oderId, displayName, voiceState } as any); 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 { private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
const track = event.track; const track = event.track;
const settings = typeof track.getSettings === 'function' ? track.getSettings() : {} as MediaTrackSettings; const settings =
this.logger.info('Remote track', { remotePeerId, kind: track.kind, id: track.id, enabled: track.enabled, readyState: track.readyState, 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}`); this.logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
// Skip inactive video placeholder tracks // Skip inactive video placeholder tracks
if (track.kind === TRACK_KIND_VIDEO && (!track.enabled || track.readyState !== 'live')) { 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; return;
} }
// Merge into composite stream per peer // Merge into composite stream per peer
let compositeStream = this.remotePeerStreams.get(remotePeerId) || new MediaStream(); 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) { 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.remotePeerStreams.set(remotePeerId, compositeStream);
this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream }); this.remoteStream$.next({ peerId: remotePeerId, stream: compositeStream });
@@ -590,6 +780,7 @@ export class PeerConnectionManager {
if (peerData.dataChannel) peerData.dataChannel.close(); if (peerData.dataChannel) peerData.dataChannel.close();
peerData.connection.close(); peerData.connection.close();
this.activePeerConnections.delete(peerId); this.activePeerConnections.delete(peerId);
this.peerNegotiationQueue.delete(peerId);
this.removeFromConnectedPeers(peerId); this.removeFromConnectedPeers(peerId);
this.peerDisconnected$.next(peerId); this.peerDisconnected$.next(peerId);
} }
@@ -603,16 +794,23 @@ export class PeerConnectionManager {
peerData.connection.close(); peerData.connection.close();
}); });
this.activePeerConnections.clear(); this.activePeerConnections.clear();
this.peerNegotiationQueue.clear();
this.connectedPeersChanged$.next([]); this.connectedPeersChanged$.next([]);
} }
private trackDisconnectedPeer(peerId: string): void { 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 { private clearPeerReconnectTimer(peerId: string): void {
const timer = this.peerReconnectTimers.get(peerId); 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. */ /** Cancel all pending peer reconnect timers and clear the tracker. */
@@ -628,7 +826,10 @@ export class PeerConnectionManager {
const timer = setInterval(() => { const timer = setInterval(() => {
const info = this.disconnectedPeerTracker.get(peerId); const info = this.disconnectedPeerTracker.get(peerId);
if (!info) { this.clearPeerReconnectTimer(peerId); return; } if (!info) {
this.clearPeerReconnectTimer(peerId);
return;
}
info.reconnectAttempts++; info.reconnectAttempts++;
this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts }); this.logger.info('P2P reconnect attempt', { peerId, attempt: info.reconnectAttempts });
@@ -653,7 +854,14 @@ export class PeerConnectionManager {
private attemptPeerReconnect(peerId: string): void { private attemptPeerReconnect(peerId: string): void {
const existing = this.activePeerConnections.get(peerId); 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.createPeerConnection(peerId, true);
this.createAndSendOffer(peerId); this.createAndSendOffer(peerId);
} }
@@ -661,7 +869,11 @@ export class PeerConnectionManager {
private requestVoiceStateFromPeer(peerId: string): void { private requestVoiceStateFromPeer(peerId: string): void {
const peerData = this.activePeerConnections.get(peerId); const peerData = this.activePeerConnections.get(peerId);
if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) { 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. * @param peerId - The peer to remove.
*/ */
private removeFromConnectedPeers(peerId: string): void { 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); 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 { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -75,7 +84,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
inputVolume = signal(100); inputVolume = signal(100);
outputVolume = signal(100); outputVolume = signal(100);
audioBitrate = signal(96); audioBitrate = signal(96);
latencyProfile = signal<'low'|'balanced'|'high'>('balanced'); latencyProfile = signal<'low' | 'balanced' | 'high'>('balanced');
includeSystemAudio = signal(false); includeSystemAudio = signal(false);
private voiceConnectedSubscription: Subscription | null = null; private voiceConnectedSubscription: Subscription | null = null;
@@ -91,7 +100,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe( this.remoteStreamSubscription = this.webrtcService.onRemoteStream.subscribe(
({ peerId, stream }) => { ({ peerId, stream }) => {
this.playRemoteAudio(peerId, stream); this.playRemoteAudio(peerId, stream);
} },
); );
// Subscribe to voice connected event to play pending streams and ensure all remote audio is set up // 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 // Play the audio
audio.play().then(() => { audio
}).catch((error) => { .play()
}); .then(() => {})
.catch((error) => {});
this.remoteAudioElements.set(peerId, audio); this.remoteAudioElements.set(peerId, audio);
} }
@@ -224,15 +234,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
this.inputDevices.set( this.inputDevices.set(
devices devices
.filter((device) => device.kind === 'audioinput') .filter((device) => device.kind === 'audioinput')
.map((device) => ({ deviceId: device.deviceId, label: device.label })) .map((device) => ({ deviceId: device.deviceId, label: device.label })),
); );
this.outputDevices.set( this.outputDevices.set(
devices devices
.filter((device) => device.kind === 'audiooutput') .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> { async connect(): Promise<void> {
@@ -264,8 +273,10 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
} }
// Start voice heartbeat to broadcast presence every 5 seconds // Start voice heartbeat to broadcast presence every 5 seconds
const roomId = this.currentUser()?.voiceState?.roomId; const room = this.currentRoom();
this.webrtcService.startVoiceHeartbeat(roomId); const roomId = this.currentUser()?.voiceState?.roomId || room?.id;
const serverId = room?.id;
this.webrtcService.startVoiceHeartbeat(roomId, serverId);
// Broadcast voice state to other users // Broadcast voice state to other users
this.webrtcService.broadcastMessage({ this.webrtcService.broadcastMessage({
@@ -277,6 +288,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isMuted: this.isMuted(), isMuted: this.isMuted(),
isDeafened: this.isDeafened(), isDeafened: this.isDeafened(),
roomId, roomId,
serverId,
}, },
}); });
@@ -288,16 +300,14 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
// Persist settings after successful connection // Persist settings after successful connection
this.saveSettings(); this.saveSettings();
} catch (error) { } catch (error) {}
}
} }
// Retry connection when there's a connection error // Retry connection when there's a connection error
async retryConnection(): Promise<void> { async retryConnection(): Promise<void> {
try { try {
await this.webrtcService.ensureSignalingConnected(10000); await this.webrtcService.ensureSignalingConnected(10000);
} catch (_error) { } catch (_error) {}
}
} }
disconnect(): void { disconnect(): void {
@@ -313,6 +323,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
isConnected: false, isConnected: false,
isMuted: false, isMuted: false,
isDeafened: false, isDeafened: false,
serverId: this.currentRoom()?.id,
}, },
}); });
@@ -340,10 +351,18 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
const user = this.currentUser(); const user = this.currentUser();
if (user?.id) { if (user?.id) {
this.store.dispatch(UsersActions.updateVoiceState({ this.store.dispatch(
userId: user.id, UsersActions.updateVoiceState({
voiceState: { isConnected: false, isMuted: false, isDeafened: false, roomId: undefined, serverId: undefined } userId: user.id,
})); voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined,
},
}),
);
} }
// End voice session for floating controls // End voice session for floating controls
@@ -407,8 +426,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
try { try {
await this.webrtcService.startScreenShare(this.includeSystemAudio()); await this.webrtcService.startScreenShare(this.includeSystemAudio());
this.isScreenSharing.set(true); this.isScreenSharing.set(true);
} catch (error) { } catch (error) {}
}
} }
} }
@@ -458,7 +476,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
onLatencyProfileChange(event: Event): void { onLatencyProfileChange(event: Event): void {
const select = event.target as HTMLSelectElement; 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.latencyProfile.set(profile);
this.webrtcService.setLatencyProfile(profile); this.webrtcService.setLatencyProfile(profile);
this.saveSettings(); this.saveSettings();
@@ -488,7 +506,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
inputVolume?: number; inputVolume?: number;
outputVolume?: number; outputVolume?: number;
audioBitrate?: number; audioBitrate?: number;
latencyProfile?: 'low'|'balanced'|'high'; latencyProfile?: 'low' | 'balanced' | 'high';
includeSystemAudio?: boolean; includeSystemAudio?: boolean;
}; };
if (settings.inputDevice) this.selectedInputDevice.set(settings.inputDevice); 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.outputVolume === 'number') this.outputVolume.set(settings.outputVolume);
if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate); if (typeof settings.audioBitrate === 'number') this.audioBitrate.set(settings.audioBitrate);
if (settings.latencyProfile) this.latencyProfile.set(settings.latencyProfile); 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 {} } catch {}
} }
@@ -537,7 +556,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
} }
getMuteButtonClass(): string { 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()) { if (this.isMuted()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
} }
@@ -545,7 +565,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
} }
getDeafenButtonClass(): string { 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()) { if (this.isDeafened()) {
return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`; return `${base} bg-destructive/20 text-destructive hover:bg-destructive/30`;
} }
@@ -553,7 +574,8 @@ export class VoiceControlsComponent implements OnInit, OnDestroy {
} }
getScreenShareButtonClass(): string { 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()) { if (this.isScreenSharing()) {
return `${base} bg-primary/20 text-primary hover:bg-primary/30`; return `${base} bg-primary/20 text-primary hover:bg-primary/30`;
} }