fix voice not hearing each other
This commit is contained in:
27
.vscode/settings.json
vendored
Normal file
27
.vscode/settings.json
vendored
Normal 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
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user