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,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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user