Split connection manager into multiple files
This commit is contained in:
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
CONNECTION_STATE_CLOSED,
|
||||||
|
CONNECTION_STATE_CONNECTED,
|
||||||
|
CONNECTION_STATE_DISCONNECTED,
|
||||||
|
CONNECTION_STATE_FAILED,
|
||||||
|
DATA_CHANNEL_LABEL,
|
||||||
|
ICE_SERVERS,
|
||||||
|
SIGNALING_TYPE_ICE_CANDIDATE,
|
||||||
|
TRACK_KIND_AUDIO,
|
||||||
|
TRACK_KIND_VIDEO,
|
||||||
|
TRANSCEIVER_RECV_ONLY,
|
||||||
|
TRANSCEIVER_SEND_RECV
|
||||||
|
} from '../../webrtc.constants';
|
||||||
|
import { PeerData } from '../../webrtc.types';
|
||||||
|
import { ConnectionLifecycleHandlers, PeerConnectionManagerContext } from '../shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and configure a new RTCPeerConnection for a remote peer.
|
||||||
|
*/
|
||||||
|
export function createPeerConnection(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
remotePeerId: string,
|
||||||
|
isInitiator: boolean,
|
||||||
|
handlers: ConnectionLifecycleHandlers
|
||||||
|
): PeerData {
|
||||||
|
const { callbacks, logger, state } = context;
|
||||||
|
|
||||||
|
logger.info('Creating peer connection', { remotePeerId, isInitiator });
|
||||||
|
|
||||||
|
const connection = new RTCPeerConnection({ iceServers: ICE_SERVERS });
|
||||||
|
|
||||||
|
let dataChannel: RTCDataChannel | null = null;
|
||||||
|
|
||||||
|
connection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
logger.info('ICE candidate gathered', {
|
||||||
|
remotePeerId,
|
||||||
|
candidateType: (event.candidate as RTCIceCandidate & { type?: string }).type
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks.sendRawMessage({
|
||||||
|
type: SIGNALING_TYPE_ICE_CANDIDATE,
|
||||||
|
targetUserId: remotePeerId,
|
||||||
|
payload: { candidate: event.candidate }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onconnectionstatechange = () => {
|
||||||
|
logger.info('connectionstatechange', {
|
||||||
|
remotePeerId,
|
||||||
|
state: connection.connectionState
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (connection.connectionState) {
|
||||||
|
case CONNECTION_STATE_CONNECTED:
|
||||||
|
handlers.clearPeerDisconnectGraceTimer(remotePeerId);
|
||||||
|
handlers.addToConnectedPeers(remotePeerId);
|
||||||
|
state.peerConnected$.next(remotePeerId);
|
||||||
|
handlers.clearPeerReconnectTimer(remotePeerId);
|
||||||
|
state.disconnectedPeerTracker.delete(remotePeerId);
|
||||||
|
handlers.requestVoiceStateFromPeer(remotePeerId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CONNECTION_STATE_DISCONNECTED:
|
||||||
|
handlers.schedulePeerDisconnectRecovery(remotePeerId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CONNECTION_STATE_FAILED:
|
||||||
|
handlers.trackDisconnectedPeer(remotePeerId);
|
||||||
|
handlers.removePeer(remotePeerId, { preserveReconnectState: true });
|
||||||
|
handlers.schedulePeerReconnect(remotePeerId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CONNECTION_STATE_CLOSED:
|
||||||
|
handlers.removePeer(remotePeerId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.oniceconnectionstatechange = () => {
|
||||||
|
logger.info('iceconnectionstatechange', {
|
||||||
|
remotePeerId,
|
||||||
|
state: connection.iceConnectionState
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onsignalingstatechange = () => {
|
||||||
|
logger.info('signalingstatechange', {
|
||||||
|
remotePeerId,
|
||||||
|
state: connection.signalingState
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onnegotiationneeded = () => {
|
||||||
|
logger.info('negotiationneeded', { remotePeerId });
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.ontrack = (event) => {
|
||||||
|
handlers.handleRemoteTrack(event, remotePeerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInitiator) {
|
||||||
|
dataChannel = connection.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true });
|
||||||
|
handlers.setupDataChannel(dataChannel, remotePeerId);
|
||||||
|
} else {
|
||||||
|
connection.ondatachannel = (event) => {
|
||||||
|
logger.info('Received data channel', { remotePeerId });
|
||||||
|
dataChannel = event.channel;
|
||||||
|
|
||||||
|
const existing = state.activePeerConnections.get(remotePeerId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.dataChannel = dataChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.setupDataChannel(dataChannel, remotePeerId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerData: PeerData = {
|
||||||
|
connection,
|
||||||
|
dataChannel,
|
||||||
|
isInitiator,
|
||||||
|
pendingIceCandidates: [],
|
||||||
|
audioSender: undefined,
|
||||||
|
videoSender: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isInitiator) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.activePeerConnections.set(remotePeerId, peerData);
|
||||||
|
|
||||||
|
const localStream = callbacks.getLocalMediaStream();
|
||||||
|
|
||||||
|
if (localStream && isInitiator) {
|
||||||
|
logger.logStream(`localStream->${remotePeerId}`, localStream);
|
||||||
|
|
||||||
|
localStream.getTracks().forEach((track) => {
|
||||||
|
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||||
|
peerData.audioSender
|
||||||
|
.replaceTrack(track)
|
||||||
|
.then(() => logger.info('audio replaceTrack (init) ok', { remotePeerId }))
|
||||||
|
.catch((error) =>
|
||||||
|
logger.error('audio replaceTrack failed at createPeerConnection', error)
|
||||||
|
);
|
||||||
|
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||||
|
peerData.videoSender
|
||||||
|
.replaceTrack(track)
|
||||||
|
.then(() => logger.info('video replaceTrack (init) ok', { remotePeerId }))
|
||||||
|
.catch((error) =>
|
||||||
|
logger.error('video replaceTrack failed at createPeerConnection', error)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const sender = connection.addTrack(track, localStream);
|
||||||
|
|
||||||
|
if (track.kind === TRACK_KIND_AUDIO)
|
||||||
|
peerData.audioSender = sender;
|
||||||
|
|
||||||
|
if (track.kind === TRACK_KIND_VIDEO)
|
||||||
|
peerData.videoSender = sender;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return peerData;
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/* eslint-disable complexity */
|
||||||
|
import {
|
||||||
|
SIGNALING_TYPE_ANSWER,
|
||||||
|
SIGNALING_TYPE_OFFER,
|
||||||
|
TRACK_KIND_AUDIO,
|
||||||
|
TRACK_KIND_VIDEO,
|
||||||
|
TRANSCEIVER_SEND_RECV
|
||||||
|
} from '../../webrtc.constants';
|
||||||
|
import {
|
||||||
|
NegotiationHandlers,
|
||||||
|
PeerConnectionManagerContext,
|
||||||
|
PeerConnectionManagerState
|
||||||
|
} from '../shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a negotiation task so SDP operations for a single peer never overlap.
|
||||||
|
*/
|
||||||
|
export function enqueueNegotiation(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
peerId: string,
|
||||||
|
task: () => Promise<void>
|
||||||
|
): void {
|
||||||
|
const previousTask = state.peerNegotiationQueue.get(peerId) ?? Promise.resolve();
|
||||||
|
const nextTask = previousTask.then(task, task);
|
||||||
|
|
||||||
|
state.peerNegotiationQueue.set(peerId, nextTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doCreateAndSendOffer(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
remotePeerId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { callbacks, logger, state } = context;
|
||||||
|
const peerData = state.activePeerConnections.get(remotePeerId);
|
||||||
|
|
||||||
|
if (!peerData)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offer = await peerData.connection.createOffer();
|
||||||
|
|
||||||
|
await peerData.connection.setLocalDescription(offer);
|
||||||
|
logger.info('Sending offer', {
|
||||||
|
remotePeerId,
|
||||||
|
type: offer.type,
|
||||||
|
sdpLength: offer.sdp?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks.sendRawMessage({
|
||||||
|
type: SIGNALING_TYPE_OFFER,
|
||||||
|
targetUserId: remotePeerId,
|
||||||
|
payload: { sdp: offer }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to create offer', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doHandleOffer(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
fromUserId: string,
|
||||||
|
sdp: RTCSessionDescriptionInit,
|
||||||
|
handlers: NegotiationHandlers
|
||||||
|
): Promise<void> {
|
||||||
|
const { callbacks, logger, state } = context;
|
||||||
|
|
||||||
|
logger.info('Handling offer', { fromUserId });
|
||||||
|
|
||||||
|
let peerData = state.activePeerConnections.get(fromUserId);
|
||||||
|
|
||||||
|
if (!peerData) {
|
||||||
|
peerData = handlers.createPeerConnection(fromUserId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signalingState = peerData.connection.signalingState;
|
||||||
|
const hasCollision =
|
||||||
|
signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer';
|
||||||
|
|
||||||
|
if (hasCollision) {
|
||||||
|
const localId =
|
||||||
|
callbacks.getIdentifyCredentials()?.oderId || callbacks.getLocalPeerId();
|
||||||
|
const isPolite = localId > fromUserId;
|
||||||
|
|
||||||
|
if (!isPolite) {
|
||||||
|
logger.info('Ignoring colliding offer (impolite side)', { fromUserId, localId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
const transceivers = peerData.connection.getTransceivers();
|
||||||
|
|
||||||
|
for (const transceiver of transceivers) {
|
||||||
|
const receiverKind = transceiver.receiver.track?.kind;
|
||||||
|
|
||||||
|
if (receiverKind === TRACK_KIND_AUDIO) {
|
||||||
|
if (!peerData.audioSender) {
|
||||||
|
peerData.audioSender = transceiver.sender;
|
||||||
|
}
|
||||||
|
|
||||||
|
transceiver.direction = TRANSCEIVER_SEND_RECV;
|
||||||
|
} else if (receiverKind === TRACK_KIND_VIDEO && !peerData.videoSender) {
|
||||||
|
peerData.videoSender = transceiver.sender;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const localStream = callbacks.getLocalMediaStream();
|
||||||
|
|
||||||
|
if (localStream) {
|
||||||
|
logger.logStream(`localStream->${fromUserId} (answerer)`, localStream);
|
||||||
|
|
||||||
|
for (const track of localStream.getTracks()) {
|
||||||
|
if (track.kind === TRACK_KIND_AUDIO && peerData.audioSender) {
|
||||||
|
await peerData.audioSender.replaceTrack(track);
|
||||||
|
logger.info('audio replaceTrack (answerer) ok', { fromUserId });
|
||||||
|
} else if (track.kind === TRACK_KIND_VIDEO && peerData.videoSender) {
|
||||||
|
await peerData.videoSender.replaceTrack(track);
|
||||||
|
logger.info('video replaceTrack (answerer) ok', { fromUserId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of peerData.pendingIceCandidates) {
|
||||||
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
peerData.pendingIceCandidates = [];
|
||||||
|
|
||||||
|
const answer = await peerData.connection.createAnswer();
|
||||||
|
|
||||||
|
await peerData.connection.setLocalDescription(answer);
|
||||||
|
|
||||||
|
logger.info('Sending answer', {
|
||||||
|
to: fromUserId,
|
||||||
|
type: answer.type,
|
||||||
|
sdpLength: answer.sdp?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks.sendRawMessage({
|
||||||
|
type: SIGNALING_TYPE_ANSWER,
|
||||||
|
targetUserId: fromUserId,
|
||||||
|
payload: { sdp: answer }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to handle offer', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doHandleAnswer(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
fromUserId: string,
|
||||||
|
sdp: RTCSessionDescriptionInit
|
||||||
|
): Promise<void> {
|
||||||
|
const { logger, state } = context;
|
||||||
|
|
||||||
|
logger.info('Handling answer', { fromUserId });
|
||||||
|
|
||||||
|
const peerData = state.activePeerConnections.get(fromUserId);
|
||||||
|
|
||||||
|
if (!peerData) {
|
||||||
|
logger.error('No peer for answer', new Error('Missing peer'), { fromUserId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (peerData.connection.signalingState === 'have-local-offer') {
|
||||||
|
await peerData.connection.setRemoteDescription(new RTCSessionDescription(sdp));
|
||||||
|
|
||||||
|
for (const candidate of peerData.pendingIceCandidates) {
|
||||||
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
}
|
||||||
|
|
||||||
|
peerData.pendingIceCandidates = [];
|
||||||
|
} else {
|
||||||
|
logger.warn('Ignoring answer - wrong signaling state', {
|
||||||
|
state: peerData.connection.signalingState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to handle answer', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doHandleIceCandidate(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
fromUserId: string,
|
||||||
|
candidate: RTCIceCandidateInit,
|
||||||
|
handlers: NegotiationHandlers
|
||||||
|
): Promise<void> {
|
||||||
|
const { logger, state } = context;
|
||||||
|
|
||||||
|
let peerData = state.activePeerConnections.get(fromUserId);
|
||||||
|
|
||||||
|
if (!peerData) {
|
||||||
|
logger.info('Creating peer for early ICE', { fromUserId });
|
||||||
|
peerData = handlers.createPeerConnection(fromUserId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (peerData.connection.remoteDescription) {
|
||||||
|
await peerData.connection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||||
|
} else {
|
||||||
|
logger.info('Queuing ICE candidate', { fromUserId });
|
||||||
|
peerData.pendingIceCandidates.push(candidate);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to add ICE candidate', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doRenegotiate(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { callbacks, logger, state } = context;
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const offer = await peerData.connection.createOffer();
|
||||||
|
|
||||||
|
await peerData.connection.setLocalDescription(offer);
|
||||||
|
logger.info('Renegotiate offer', {
|
||||||
|
peerId,
|
||||||
|
type: offer.type,
|
||||||
|
sdpLength: offer.sdp?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
callbacks.sendRawMessage({
|
||||||
|
type: SIGNALING_TYPE_OFFER,
|
||||||
|
targetUserId: peerId,
|
||||||
|
payload: { sdp: offer }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to renegotiate', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './peer-connection.manager';
|
||||||
|
export * from './shared';
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { ChatEvent } from '../../../../models';
|
||||||
|
import {
|
||||||
|
DATA_CHANNEL_HIGH_WATER_BYTES,
|
||||||
|
DATA_CHANNEL_LOW_WATER_BYTES,
|
||||||
|
DATA_CHANNEL_STATE_OPEN,
|
||||||
|
DEFAULT_DISPLAY_NAME,
|
||||||
|
P2P_TYPE_PING,
|
||||||
|
P2P_TYPE_PONG,
|
||||||
|
P2P_TYPE_SCREEN_STATE,
|
||||||
|
P2P_TYPE_STATE_REQUEST,
|
||||||
|
P2P_TYPE_VOICE_STATE,
|
||||||
|
P2P_TYPE_VOICE_STATE_REQUEST
|
||||||
|
} from '../../webrtc.constants';
|
||||||
|
import { PeerConnectionManagerContext } from '../shared';
|
||||||
|
import { startPingInterval } from './ping';
|
||||||
|
|
||||||
|
type PeerMessage = Record<string, unknown> & {
|
||||||
|
type?: string;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire open/close/error/message handlers onto a data channel.
|
||||||
|
*/
|
||||||
|
export function setupDataChannel(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
channel: RTCDataChannel,
|
||||||
|
remotePeerId: string
|
||||||
|
): void {
|
||||||
|
const { logger } = context;
|
||||||
|
|
||||||
|
channel.onopen = () => {
|
||||||
|
logger.info('Data channel open', { remotePeerId });
|
||||||
|
sendCurrentStatesToChannel(context, channel, remotePeerId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
channel.send(JSON.stringify({ type: P2P_TYPE_STATE_REQUEST }));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
startPingInterval(context.state, remotePeerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onclose = () => {
|
||||||
|
logger.info('Data channel closed', { remotePeerId });
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onerror = (error) => {
|
||||||
|
logger.error('Data channel error', error, { remotePeerId });
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data) as PeerMessage;
|
||||||
|
|
||||||
|
handlePeerMessage(context, remotePeerId, message);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to parse peer message', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route an incoming peer-to-peer message.
|
||||||
|
*/
|
||||||
|
export function handlePeerMessage(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string,
|
||||||
|
message: PeerMessage
|
||||||
|
): void {
|
||||||
|
const { logger, state } = context;
|
||||||
|
|
||||||
|
logger.info('Received P2P message', {
|
||||||
|
peerId,
|
||||||
|
type: message.type
|
||||||
|
});
|
||||||
|
|
||||||
|
if (message.type === P2P_TYPE_STATE_REQUEST || message.type === P2P_TYPE_VOICE_STATE_REQUEST) {
|
||||||
|
sendCurrentStatesToPeer(context, peerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === P2P_TYPE_PING) {
|
||||||
|
sendToPeer(context, peerId, {
|
||||||
|
type: P2P_TYPE_PONG,
|
||||||
|
ts: message.ts
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === P2P_TYPE_PONG) {
|
||||||
|
const sentAt = state.pendingPings.get(peerId);
|
||||||
|
|
||||||
|
if (sentAt && typeof message.ts === 'number' && message.ts === sentAt) {
|
||||||
|
const latencyMs = Math.round(performance.now() - sentAt);
|
||||||
|
|
||||||
|
state.peerLatencies.set(peerId, latencyMs);
|
||||||
|
state.peerLatencyChanged$.next({ peerId, latencyMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pendingPings.delete(peerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrichedMessage = {
|
||||||
|
...message,
|
||||||
|
fromPeerId: peerId
|
||||||
|
} as ChatEvent;
|
||||||
|
|
||||||
|
state.messageReceived$.next(enrichedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast a ChatEvent to every peer with an open data channel. */
|
||||||
|
export function broadcastMessage(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
event: object
|
||||||
|
): void {
|
||||||
|
const { logger, state } = context;
|
||||||
|
const data = JSON.stringify(event);
|
||||||
|
|
||||||
|
state.activePeerConnections.forEach((peerData, peerId) => {
|
||||||
|
try {
|
||||||
|
if (peerData.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||||
|
peerData.dataChannel.send(data);
|
||||||
|
logger.info('Sent message via P2P', { peerId });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send to peer', error, { peerId });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a ChatEvent to a specific peer's data channel.
|
||||||
|
*/
|
||||||
|
export function sendToPeer(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string,
|
||||||
|
event: object
|
||||||
|
): void {
|
||||||
|
const { logger, state } = context;
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
|
logger.warn('Peer not connected - cannot send', { peerId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
peerData.dataChannel.send(JSON.stringify(event));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send to peer', error, { peerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a ChatEvent with back-pressure awareness.
|
||||||
|
*/
|
||||||
|
export async function sendToPeerBuffered(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string,
|
||||||
|
event: object
|
||||||
|
): Promise<void> {
|
||||||
|
const { logger, state } = context;
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
|
logger.warn('Peer not connected - cannot send buffered', { peerId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = peerData.dataChannel;
|
||||||
|
const data = JSON.stringify(event);
|
||||||
|
|
||||||
|
if (typeof channel.bufferedAmountLowThreshold === 'number') {
|
||||||
|
channel.bufferedAmountLowThreshold = DATA_CHANNEL_LOW_WATER_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (channel.bufferedAmount > DATA_CHANNEL_HIGH_WATER_BYTES) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const handleBufferedAmountLow = () => {
|
||||||
|
if (channel.bufferedAmount <= DATA_CHANNEL_LOW_WATER_BYTES) {
|
||||||
|
channel.removeEventListener('bufferedamountlow', handleBufferedAmountLow);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channel.addEventListener('bufferedamountlow', handleBufferedAmountLow, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
channel.send(data);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send buffered message', error, { peerId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the current voice and screen-share states to a single peer.
|
||||||
|
*/
|
||||||
|
export function sendCurrentStatesToPeer(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string
|
||||||
|
): void {
|
||||||
|
const { callbacks } = context;
|
||||||
|
const credentials = callbacks.getIdentifyCredentials();
|
||||||
|
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
|
||||||
|
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||||
|
const voiceState = callbacks.getVoiceStateSnapshot();
|
||||||
|
|
||||||
|
sendToPeer(context, peerId, {
|
||||||
|
type: P2P_TYPE_VOICE_STATE,
|
||||||
|
oderId,
|
||||||
|
displayName,
|
||||||
|
voiceState
|
||||||
|
});
|
||||||
|
|
||||||
|
sendToPeer(context, peerId, {
|
||||||
|
type: P2P_TYPE_SCREEN_STATE,
|
||||||
|
oderId,
|
||||||
|
displayName,
|
||||||
|
isScreenSharing: callbacks.isScreenSharingActive()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendCurrentStatesToChannel(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
channel: RTCDataChannel,
|
||||||
|
remotePeerId: string
|
||||||
|
): void {
|
||||||
|
const { callbacks, logger } = context;
|
||||||
|
|
||||||
|
if (channel.readyState !== DATA_CHANNEL_STATE_OPEN) {
|
||||||
|
logger.warn('Cannot send states - channel not open', {
|
||||||
|
remotePeerId,
|
||||||
|
state: channel.readyState
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = callbacks.getIdentifyCredentials();
|
||||||
|
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
|
||||||
|
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||||
|
const voiceState = callbacks.getVoiceStateSnapshot();
|
||||||
|
|
||||||
|
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: callbacks.isScreenSharingActive()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Sent initial states to channel', { remotePeerId, voiceState });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to send initial states to channel', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast the current voice and screen-share states to all connected peers. */
|
||||||
|
export function broadcastCurrentStates(context: PeerConnectionManagerContext): void {
|
||||||
|
const { callbacks } = context;
|
||||||
|
const credentials = callbacks.getIdentifyCredentials();
|
||||||
|
const oderId = credentials?.oderId || callbacks.getLocalPeerId();
|
||||||
|
const displayName = credentials?.displayName || DEFAULT_DISPLAY_NAME;
|
||||||
|
const voiceState = callbacks.getVoiceStateSnapshot();
|
||||||
|
|
||||||
|
broadcastMessage(context, {
|
||||||
|
type: P2P_TYPE_VOICE_STATE,
|
||||||
|
oderId,
|
||||||
|
displayName,
|
||||||
|
voiceState
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastMessage(context, {
|
||||||
|
type: P2P_TYPE_SCREEN_STATE,
|
||||||
|
oderId,
|
||||||
|
displayName,
|
||||||
|
isScreenSharing: callbacks.isScreenSharingActive()
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
DATA_CHANNEL_STATE_OPEN,
|
||||||
|
P2P_TYPE_PING,
|
||||||
|
PEER_PING_INTERVAL_MS
|
||||||
|
} from '../../webrtc.constants';
|
||||||
|
import { PeerConnectionManagerState } from '../shared';
|
||||||
|
|
||||||
|
/** Start periodic pings to a peer to measure round-trip latency. */
|
||||||
|
export function startPingInterval(state: PeerConnectionManagerState, peerId: string): void {
|
||||||
|
stopPingInterval(state, peerId);
|
||||||
|
sendPing(state, peerId);
|
||||||
|
|
||||||
|
const timer = setInterval(() => sendPing(state, peerId), PEER_PING_INTERVAL_MS);
|
||||||
|
|
||||||
|
state.peerPingTimers.set(peerId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the periodic ping for a specific peer. */
|
||||||
|
export function stopPingInterval(state: PeerConnectionManagerState, peerId: string): void {
|
||||||
|
const timer = state.peerPingTimers.get(peerId);
|
||||||
|
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
state.peerPingTimers.delete(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel all active ping timers. */
|
||||||
|
export function clearAllPingTimers(state: PeerConnectionManagerState): void {
|
||||||
|
state.peerPingTimers.forEach((timer) => clearInterval(timer));
|
||||||
|
state.peerPingTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a single ping to a peer. */
|
||||||
|
export function sendPing(state: PeerConnectionManagerState, peerId: string): void {
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData?.dataChannel || peerData.dataChannel.readyState !== DATA_CHANNEL_STATE_OPEN)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const timestamp = performance.now();
|
||||||
|
|
||||||
|
state.pendingPings.set(peerId, timestamp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
peerData.dataChannel.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: P2P_TYPE_PING,
|
||||||
|
ts: timestamp
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
|
import { ChatEvent } from '../../../models';
|
||||||
|
import { WebRTCLogger } from '../webrtc-logger';
|
||||||
|
import { PeerData } from '../webrtc.types';
|
||||||
|
import { createPeerConnection as createManagedPeerConnection } from './connection/create-peer-connection';
|
||||||
|
import {
|
||||||
|
doCreateAndSendOffer,
|
||||||
|
doHandleAnswer,
|
||||||
|
doHandleIceCandidate,
|
||||||
|
doHandleOffer,
|
||||||
|
doRenegotiate,
|
||||||
|
enqueueNegotiation
|
||||||
|
} from './connection/negotiation';
|
||||||
|
import {
|
||||||
|
broadcastCurrentStates,
|
||||||
|
broadcastMessage,
|
||||||
|
sendCurrentStatesToPeer,
|
||||||
|
sendToPeer,
|
||||||
|
sendToPeerBuffered,
|
||||||
|
setupDataChannel
|
||||||
|
} from './messaging/data-channel';
|
||||||
|
import {
|
||||||
|
addToConnectedPeers,
|
||||||
|
clearAllPeerReconnectTimers,
|
||||||
|
clearPeerDisconnectGraceTimer,
|
||||||
|
clearPeerReconnectTimer,
|
||||||
|
closeAllPeers as closeManagedPeers,
|
||||||
|
getConnectedPeerIds,
|
||||||
|
removePeer as removeManagedPeer,
|
||||||
|
requestVoiceStateFromPeer,
|
||||||
|
resetConnectedPeers,
|
||||||
|
schedulePeerDisconnectRecovery,
|
||||||
|
schedulePeerReconnect,
|
||||||
|
trackDisconnectedPeer
|
||||||
|
} from './recovery/peer-recovery';
|
||||||
|
import { handleRemoteTrack } from './streams/remote-streams';
|
||||||
|
import {
|
||||||
|
ConnectionLifecycleHandlers,
|
||||||
|
createPeerConnectionManagerState,
|
||||||
|
NegotiationHandlers,
|
||||||
|
PeerConnectionCallbacks,
|
||||||
|
PeerConnectionManagerContext,
|
||||||
|
RecoveryHandlers,
|
||||||
|
RemovePeerOptions
|
||||||
|
} from './shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and manages RTCPeerConnections, data channels,
|
||||||
|
* offer/answer negotiation, ICE candidates, and P2P reconnection.
|
||||||
|
*/
|
||||||
|
export class PeerConnectionManager {
|
||||||
|
private readonly state = createPeerConnectionManagerState();
|
||||||
|
|
||||||
|
/** Active peer connections keyed by remote peer ID. */
|
||||||
|
readonly activePeerConnections = this.state.activePeerConnections;
|
||||||
|
|
||||||
|
/** Remote composite streams keyed by remote peer ID. */
|
||||||
|
readonly remotePeerStreams = this.state.remotePeerStreams;
|
||||||
|
|
||||||
|
/** Last measured latency (ms) per peer. */
|
||||||
|
readonly peerLatencies = this.state.peerLatencies;
|
||||||
|
|
||||||
|
/** Emitted whenever a peer latency value changes. */
|
||||||
|
readonly peerLatencyChanged$ = this.state.peerLatencyChanged$;
|
||||||
|
|
||||||
|
readonly peerConnected$ = this.state.peerConnected$;
|
||||||
|
readonly peerDisconnected$ = this.state.peerDisconnected$;
|
||||||
|
readonly remoteStream$ = this.state.remoteStream$;
|
||||||
|
readonly messageReceived$ = this.state.messageReceived$;
|
||||||
|
|
||||||
|
/** Emitted whenever the connected peer list changes. */
|
||||||
|
readonly connectedPeersChanged$ = this.state.connectedPeersChanged$;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly logger: WebRTCLogger,
|
||||||
|
private callbacks: PeerConnectionCallbacks
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the callback set at runtime.
|
||||||
|
* Needed because of circular initialisation between managers.
|
||||||
|
*/
|
||||||
|
setCallbacks(callbacks: PeerConnectionCallbacks): void {
|
||||||
|
this.callbacks = callbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new RTCPeerConnection to a remote peer.
|
||||||
|
*/
|
||||||
|
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData {
|
||||||
|
return createManagedPeerConnection(
|
||||||
|
this.context,
|
||||||
|
remotePeerId,
|
||||||
|
isInitiator,
|
||||||
|
this.connectionHandlers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an SDP offer and send it to the remote peer via the signaling server.
|
||||||
|
*/
|
||||||
|
async createAndSendOffer(remotePeerId: string): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
enqueueNegotiation(this.state, remotePeerId, async () => {
|
||||||
|
await doCreateAndSendOffer(this.context, remotePeerId);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming SDP offer from a remote peer.
|
||||||
|
*/
|
||||||
|
handleOffer(fromUserId: string, sdp: RTCSessionDescriptionInit): void {
|
||||||
|
enqueueNegotiation(this.state, fromUserId, () =>
|
||||||
|
doHandleOffer(this.context, fromUserId, sdp, this.negotiationHandlers)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming SDP answer from a remote peer.
|
||||||
|
*/
|
||||||
|
handleAnswer(fromUserId: string, sdp: RTCSessionDescriptionInit): void {
|
||||||
|
enqueueNegotiation(this.state, fromUserId, () =>
|
||||||
|
doHandleAnswer(this.context, fromUserId, sdp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming ICE candidate from a remote peer.
|
||||||
|
*/
|
||||||
|
handleIceCandidate(fromUserId: string, candidate: RTCIceCandidateInit): void {
|
||||||
|
enqueueNegotiation(this.state, fromUserId, () =>
|
||||||
|
doHandleIceCandidate(this.context, fromUserId, candidate, this.negotiationHandlers)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-negotiate (create offer) to push track changes to remote.
|
||||||
|
*/
|
||||||
|
async renegotiate(peerId: string): Promise<void> {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
enqueueNegotiation(this.state, peerId, async () => {
|
||||||
|
await doRenegotiate(this.context, peerId);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast a ChatEvent to every peer with an open data channel. */
|
||||||
|
broadcastMessage(event: ChatEvent): void {
|
||||||
|
broadcastMessage(this.context, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a ChatEvent to a specific peer's data channel.
|
||||||
|
*/
|
||||||
|
sendToPeer(peerId: string, event: ChatEvent): void {
|
||||||
|
sendToPeer(this.context, peerId, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a ChatEvent with back-pressure awareness.
|
||||||
|
*/
|
||||||
|
async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise<void> {
|
||||||
|
await sendToPeerBuffered(this.context, peerId, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the current voice and screen-share states to a single peer.
|
||||||
|
*/
|
||||||
|
sendCurrentStatesToPeer(peerId: string): void {
|
||||||
|
sendCurrentStatesToPeer(this.context, peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast the current voice and screen-share states to all connected peers. */
|
||||||
|
broadcastCurrentStates(): void {
|
||||||
|
broadcastCurrentStates(this.context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close and remove a peer connection, data channel, and emit a disconnect event.
|
||||||
|
*/
|
||||||
|
removePeer(peerId: string, options?: RemovePeerOptions): void {
|
||||||
|
removeManagedPeer(this.context, peerId, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close every active peer connection and clear internal state. */
|
||||||
|
closeAllPeers(): void {
|
||||||
|
closeManagedPeers(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
||||||
|
clearAllPeerReconnectTimers(): void {
|
||||||
|
clearAllPeerReconnectTimers(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a snapshot copy of the currently-connected peer IDs. */
|
||||||
|
getConnectedPeerIds(): string[] {
|
||||||
|
return getConnectedPeerIds(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the connected peers list to empty and notify subscribers. */
|
||||||
|
resetConnectedPeers(): void {
|
||||||
|
resetConnectedPeers(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clean up all resources. */
|
||||||
|
destroy(): void {
|
||||||
|
this.closeAllPeers();
|
||||||
|
this.peerConnected$.complete();
|
||||||
|
this.peerDisconnected$.complete();
|
||||||
|
this.remoteStream$.complete();
|
||||||
|
this.messageReceived$.complete();
|
||||||
|
this.connectedPeersChanged$.complete();
|
||||||
|
this.peerLatencyChanged$.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private get context(): PeerConnectionManagerContext {
|
||||||
|
return {
|
||||||
|
logger: this.logger,
|
||||||
|
callbacks: this.callbacks,
|
||||||
|
state: this.state
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private get connectionHandlers(): ConnectionLifecycleHandlers {
|
||||||
|
return {
|
||||||
|
clearPeerDisconnectGraceTimer: (peerId: string) => this.clearPeerDisconnectGraceTimer(peerId),
|
||||||
|
addToConnectedPeers: (peerId: string) => this.addToConnectedPeers(peerId),
|
||||||
|
clearPeerReconnectTimer: (peerId: string) => this.clearPeerReconnectTimer(peerId),
|
||||||
|
requestVoiceStateFromPeer: (peerId: string) => this.requestVoiceStateFromPeer(peerId),
|
||||||
|
schedulePeerDisconnectRecovery: (peerId: string) =>
|
||||||
|
this.schedulePeerDisconnectRecovery(peerId),
|
||||||
|
trackDisconnectedPeer: (peerId: string) => this.trackDisconnectedPeer(peerId),
|
||||||
|
removePeer: (peerId: string, options?: RemovePeerOptions) => this.removePeer(peerId, options),
|
||||||
|
schedulePeerReconnect: (peerId: string) => this.schedulePeerReconnect(peerId),
|
||||||
|
handleRemoteTrack: (event: RTCTrackEvent, peerId: string) =>
|
||||||
|
this.handleRemoteTrack(event, peerId),
|
||||||
|
setupDataChannel: (channel: RTCDataChannel, peerId: string) =>
|
||||||
|
this.setupDataChannel(channel, peerId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private get negotiationHandlers(): NegotiationHandlers {
|
||||||
|
return {
|
||||||
|
createPeerConnection: (remotePeerId: string, isInitiator: boolean) =>
|
||||||
|
this.createPeerConnection(remotePeerId, isInitiator)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private get recoveryHandlers(): RecoveryHandlers {
|
||||||
|
return {
|
||||||
|
removePeer: (peerId: string, options?: RemovePeerOptions) => this.removePeer(peerId, options),
|
||||||
|
createPeerConnection: (peerId: string, isInitiator: boolean) =>
|
||||||
|
this.createPeerConnection(peerId, isInitiator),
|
||||||
|
createAndSendOffer: (peerId: string) => this.createAndSendOffer(peerId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void {
|
||||||
|
setupDataChannel(this.context, channel, remotePeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void {
|
||||||
|
handleRemoteTrack(this.context, event, remotePeerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackDisconnectedPeer(peerId: string): void {
|
||||||
|
trackDisconnectedPeer(this.state, peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPeerReconnectTimer(peerId: string): void {
|
||||||
|
clearPeerReconnectTimer(this.state, peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearPeerDisconnectGraceTimer(peerId: string): void {
|
||||||
|
clearPeerDisconnectGraceTimer(this.state, peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedulePeerDisconnectRecovery(peerId: string): void {
|
||||||
|
schedulePeerDisconnectRecovery(this.context, peerId, this.recoveryHandlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private schedulePeerReconnect(peerId: string): void {
|
||||||
|
schedulePeerReconnect(this.context, peerId, this.recoveryHandlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestVoiceStateFromPeer(peerId: string): void {
|
||||||
|
requestVoiceStateFromPeer(this.state, this.logger, peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addToConnectedPeers(peerId: string): void {
|
||||||
|
addToConnectedPeers(this.state, peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
CONNECTION_STATE_CONNECTED,
|
||||||
|
DATA_CHANNEL_STATE_OPEN,
|
||||||
|
P2P_TYPE_VOICE_STATE_REQUEST,
|
||||||
|
PEER_DISCONNECT_GRACE_MS,
|
||||||
|
PEER_RECONNECT_INTERVAL_MS,
|
||||||
|
PEER_RECONNECT_MAX_ATTEMPTS
|
||||||
|
} from '../../webrtc.constants';
|
||||||
|
import {
|
||||||
|
PeerConnectionManagerContext,
|
||||||
|
PeerConnectionManagerState,
|
||||||
|
RecoveryHandlers,
|
||||||
|
RemovePeerOptions
|
||||||
|
} from '../shared';
|
||||||
|
import { clearAllPingTimers, stopPingInterval } from '../messaging/ping';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close and remove a peer connection, data channel, and emit a disconnect event.
|
||||||
|
*/
|
||||||
|
export function removePeer(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string,
|
||||||
|
options?: RemovePeerOptions
|
||||||
|
): void {
|
||||||
|
const { state } = context;
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
const preserveReconnectState = options?.preserveReconnectState === true;
|
||||||
|
|
||||||
|
clearPeerDisconnectGraceTimer(state, peerId);
|
||||||
|
|
||||||
|
if (!preserveReconnectState) {
|
||||||
|
clearPeerReconnectTimer(state, peerId);
|
||||||
|
state.disconnectedPeerTracker.delete(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.remotePeerStreams.delete(peerId);
|
||||||
|
|
||||||
|
if (peerData) {
|
||||||
|
if (peerData.dataChannel)
|
||||||
|
peerData.dataChannel.close();
|
||||||
|
|
||||||
|
peerData.connection.close();
|
||||||
|
state.activePeerConnections.delete(peerId);
|
||||||
|
state.peerNegotiationQueue.delete(peerId);
|
||||||
|
removeFromConnectedPeers(state, peerId);
|
||||||
|
stopPingInterval(state, peerId);
|
||||||
|
state.peerLatencies.delete(peerId);
|
||||||
|
state.pendingPings.delete(peerId);
|
||||||
|
state.peerDisconnected$.next(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close every active peer connection and clear internal state. */
|
||||||
|
export function closeAllPeers(state: PeerConnectionManagerState): void {
|
||||||
|
clearAllPeerReconnectTimers(state);
|
||||||
|
clearAllPeerDisconnectGraceTimers(state);
|
||||||
|
clearAllPingTimers(state);
|
||||||
|
|
||||||
|
state.activePeerConnections.forEach((peerData) => {
|
||||||
|
if (peerData.dataChannel)
|
||||||
|
peerData.dataChannel.close();
|
||||||
|
|
||||||
|
peerData.connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
state.activePeerConnections.clear();
|
||||||
|
state.remotePeerStreams.clear();
|
||||||
|
state.peerNegotiationQueue.clear();
|
||||||
|
state.peerLatencies.clear();
|
||||||
|
state.pendingPings.clear();
|
||||||
|
state.connectedPeersChanged$.next([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackDisconnectedPeer(state: PeerConnectionManagerState, peerId: string): void {
|
||||||
|
state.disconnectedPeerTracker.set(peerId, {
|
||||||
|
lastSeenTimestamp: Date.now(),
|
||||||
|
reconnectAttempts: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPeerReconnectTimer(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
peerId: string
|
||||||
|
): void {
|
||||||
|
const timer = state.peerReconnectTimers.get(peerId);
|
||||||
|
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
state.peerReconnectTimers.delete(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPeerDisconnectGraceTimer(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
peerId: string
|
||||||
|
): void {
|
||||||
|
const timer = state.peerDisconnectGraceTimers.get(peerId);
|
||||||
|
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
state.peerDisconnectGraceTimers.delete(peerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel all pending peer reconnect timers and clear the tracker. */
|
||||||
|
export function clearAllPeerReconnectTimers(state: PeerConnectionManagerState): void {
|
||||||
|
state.peerReconnectTimers.forEach((timer) => clearInterval(timer));
|
||||||
|
state.peerReconnectTimers.clear();
|
||||||
|
state.disconnectedPeerTracker.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllPeerDisconnectGraceTimers(state: PeerConnectionManagerState): void {
|
||||||
|
state.peerDisconnectGraceTimers.forEach((timer) => clearTimeout(timer));
|
||||||
|
state.peerDisconnectGraceTimers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schedulePeerDisconnectRecovery(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string,
|
||||||
|
handlers: RecoveryHandlers
|
||||||
|
): void {
|
||||||
|
const { logger, state } = context;
|
||||||
|
|
||||||
|
if (state.peerDisconnectGraceTimers.has(peerId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.warn('Peer temporarily disconnected; waiting before reconnect', { peerId });
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
state.peerDisconnectGraceTimers.delete(peerId);
|
||||||
|
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (!peerData)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const connectionState = peerData.connection.connectionState;
|
||||||
|
|
||||||
|
if (connectionState === CONNECTION_STATE_CONNECTED || connectionState === 'connecting') {
|
||||||
|
logger.info('Peer recovered before disconnect grace expired', {
|
||||||
|
peerId,
|
||||||
|
state: connectionState
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('Peer still disconnected after grace period; recreating connection', {
|
||||||
|
peerId,
|
||||||
|
state: connectionState
|
||||||
|
});
|
||||||
|
|
||||||
|
trackDisconnectedPeer(state, peerId);
|
||||||
|
handlers.removePeer(peerId, { preserveReconnectState: true });
|
||||||
|
schedulePeerReconnect(context, peerId, handlers);
|
||||||
|
}, PEER_DISCONNECT_GRACE_MS);
|
||||||
|
|
||||||
|
state.peerDisconnectGraceTimers.set(peerId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function schedulePeerReconnect(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
peerId: string,
|
||||||
|
handlers: RecoveryHandlers
|
||||||
|
): void {
|
||||||
|
const { callbacks, logger, state } = context;
|
||||||
|
|
||||||
|
if (state.peerReconnectTimers.has(peerId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
logger.info('Scheduling P2P reconnect', { peerId });
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const info = state.disconnectedPeerTracker.get(peerId);
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
clearPeerReconnectTimer(state, peerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info.reconnectAttempts++;
|
||||||
|
logger.info('P2P reconnect attempt', {
|
||||||
|
peerId,
|
||||||
|
attempt: info.reconnectAttempts
|
||||||
|
});
|
||||||
|
|
||||||
|
if (info.reconnectAttempts >= PEER_RECONNECT_MAX_ATTEMPTS) {
|
||||||
|
logger.info('P2P reconnect max attempts reached', { peerId });
|
||||||
|
clearPeerReconnectTimer(state, peerId);
|
||||||
|
state.disconnectedPeerTracker.delete(peerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!callbacks.isSignalingConnected()) {
|
||||||
|
logger.info('Skipping P2P reconnect - no signaling connection', { peerId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attemptPeerReconnect(state, peerId, handlers);
|
||||||
|
}, PEER_RECONNECT_INTERVAL_MS);
|
||||||
|
|
||||||
|
state.peerReconnectTimers.set(peerId, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attemptPeerReconnect(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
peerId: string,
|
||||||
|
handlers: RecoveryHandlers
|
||||||
|
): void {
|
||||||
|
if (state.activePeerConnections.has(peerId)) {
|
||||||
|
handlers.removePeer(peerId, { preserveReconnectState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.createPeerConnection(peerId, true);
|
||||||
|
void handlers.createAndSendOffer(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestVoiceStateFromPeer(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
logger: PeerConnectionManagerContext['logger'],
|
||||||
|
peerId: string
|
||||||
|
): void {
|
||||||
|
const peerData = state.activePeerConnections.get(peerId);
|
||||||
|
|
||||||
|
if (peerData?.dataChannel?.readyState === DATA_CHANNEL_STATE_OPEN) {
|
||||||
|
try {
|
||||||
|
peerData.dataChannel.send(JSON.stringify({ type: P2P_TYPE_VOICE_STATE_REQUEST }));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to request voice state', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a snapshot copy of the currently-connected peer IDs. */
|
||||||
|
export function getConnectedPeerIds(state: PeerConnectionManagerState): string[] {
|
||||||
|
return [...state.connectedPeersList];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addToConnectedPeers(state: PeerConnectionManagerState, peerId: string): void {
|
||||||
|
if (!state.connectedPeersList.includes(peerId)) {
|
||||||
|
state.connectedPeersList = [...state.connectedPeersList, peerId];
|
||||||
|
state.connectedPeersChanged$.next(state.connectedPeersList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a peer from the connected list and notify subscribers.
|
||||||
|
*/
|
||||||
|
export function removeFromConnectedPeers(
|
||||||
|
state: PeerConnectionManagerState,
|
||||||
|
peerId: string
|
||||||
|
): void {
|
||||||
|
state.connectedPeersList = state.connectedPeersList.filter(
|
||||||
|
(connectedId) => connectedId !== peerId
|
||||||
|
);
|
||||||
|
|
||||||
|
state.connectedPeersChanged$.next(state.connectedPeersList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the connected peers list to empty and notify subscribers. */
|
||||||
|
export function resetConnectedPeers(state: PeerConnectionManagerState): void {
|
||||||
|
state.connectedPeersList = [];
|
||||||
|
state.connectedPeersChanged$.next([]);
|
||||||
|
}
|
||||||
103
src/app/core/services/webrtc/peer-connection-manager/shared.ts
Normal file
103
src/app/core/services/webrtc/peer-connection-manager/shared.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { ChatEvent } from '../../../models';
|
||||||
|
import { WebRTCLogger } from '../webrtc-logger';
|
||||||
|
import {
|
||||||
|
DisconnectedPeerEntry,
|
||||||
|
IdentifyCredentials,
|
||||||
|
PeerData,
|
||||||
|
VoiceStateSnapshot
|
||||||
|
} from '../webrtc.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callbacks the PeerConnectionManager needs from the owning service.
|
||||||
|
* This keeps the manager decoupled from Angular DI / signals.
|
||||||
|
*/
|
||||||
|
export interface PeerConnectionCallbacks {
|
||||||
|
/** Send a raw JSON message via the signaling server. */
|
||||||
|
sendRawMessage(msg: Record<string, unknown>): void;
|
||||||
|
/** Get the current local media stream (mic audio). */
|
||||||
|
getLocalMediaStream(): MediaStream | null;
|
||||||
|
/** Whether signaling is currently connected. */
|
||||||
|
isSignalingConnected(): boolean;
|
||||||
|
/** Returns the current voice/screen state snapshot for broadcasting. */
|
||||||
|
getVoiceStateSnapshot(): VoiceStateSnapshot;
|
||||||
|
/** Returns the identify credentials (oderId + displayName). */
|
||||||
|
getIdentifyCredentials(): IdentifyCredentials | null;
|
||||||
|
/** Returns the local peer ID. */
|
||||||
|
getLocalPeerId(): string;
|
||||||
|
/** Whether screen sharing is active. */
|
||||||
|
isScreenSharingActive(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerConnectionManagerState {
|
||||||
|
activePeerConnections: Map<string, PeerData>;
|
||||||
|
remotePeerStreams: Map<string, MediaStream>;
|
||||||
|
disconnectedPeerTracker: Map<string, DisconnectedPeerEntry>;
|
||||||
|
peerReconnectTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
|
peerDisconnectGraceTimers: Map<string, ReturnType<typeof setTimeout>>;
|
||||||
|
pendingPings: Map<string, number>;
|
||||||
|
peerPingTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||||
|
peerLatencies: Map<string, number>;
|
||||||
|
peerLatencyChanged$: Subject<{ peerId: string; latencyMs: number }>;
|
||||||
|
peerNegotiationQueue: Map<string, Promise<void>>;
|
||||||
|
peerConnected$: Subject<string>;
|
||||||
|
peerDisconnected$: Subject<string>;
|
||||||
|
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
|
||||||
|
messageReceived$: Subject<ChatEvent>;
|
||||||
|
connectedPeersChanged$: Subject<string[]>;
|
||||||
|
connectedPeersList: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeerConnectionManagerContext {
|
||||||
|
readonly logger: WebRTCLogger;
|
||||||
|
readonly callbacks: PeerConnectionCallbacks;
|
||||||
|
readonly state: PeerConnectionManagerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemovePeerOptions {
|
||||||
|
preserveReconnectState?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionLifecycleHandlers {
|
||||||
|
clearPeerDisconnectGraceTimer(peerId: string): void;
|
||||||
|
addToConnectedPeers(peerId: string): void;
|
||||||
|
clearPeerReconnectTimer(peerId: string): void;
|
||||||
|
requestVoiceStateFromPeer(peerId: string): void;
|
||||||
|
schedulePeerDisconnectRecovery(peerId: string): void;
|
||||||
|
trackDisconnectedPeer(peerId: string): void;
|
||||||
|
removePeer(peerId: string, options?: RemovePeerOptions): void;
|
||||||
|
schedulePeerReconnect(peerId: string): void;
|
||||||
|
handleRemoteTrack(event: RTCTrackEvent, remotePeerId: string): void;
|
||||||
|
setupDataChannel(channel: RTCDataChannel, remotePeerId: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NegotiationHandlers {
|
||||||
|
createPeerConnection(remotePeerId: string, isInitiator: boolean): PeerData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoveryHandlers {
|
||||||
|
removePeer(peerId: string, options?: RemovePeerOptions): void;
|
||||||
|
createPeerConnection(peerId: string, isInitiator: boolean): PeerData;
|
||||||
|
createAndSendOffer(peerId: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPeerConnectionManagerState(): PeerConnectionManagerState {
|
||||||
|
return {
|
||||||
|
activePeerConnections: new Map<string, PeerData>(),
|
||||||
|
remotePeerStreams: new Map<string, MediaStream>(),
|
||||||
|
disconnectedPeerTracker: new Map<string, DisconnectedPeerEntry>(),
|
||||||
|
peerReconnectTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
||||||
|
peerDisconnectGraceTimers: new Map<string, ReturnType<typeof setTimeout>>(),
|
||||||
|
pendingPings: new Map<string, number>(),
|
||||||
|
peerPingTimers: new Map<string, ReturnType<typeof setInterval>>(),
|
||||||
|
peerLatencies: new Map<string, number>(),
|
||||||
|
peerLatencyChanged$: new Subject<{ peerId: string; latencyMs: number }>(),
|
||||||
|
peerNegotiationQueue: new Map<string, Promise<void>>(),
|
||||||
|
peerConnected$: new Subject<string>(),
|
||||||
|
peerDisconnected$: new Subject<string>(),
|
||||||
|
remoteStream$: new Subject<{ peerId: string; stream: MediaStream }>(),
|
||||||
|
messageReceived$: new Subject<ChatEvent>(),
|
||||||
|
connectedPeersChanged$: new Subject<string[]>(),
|
||||||
|
connectedPeersList: []
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { TRACK_KIND_VIDEO } from '../../webrtc.constants';
|
||||||
|
import { PeerConnectionManagerContext } from '../shared';
|
||||||
|
|
||||||
|
export function handleRemoteTrack(
|
||||||
|
context: PeerConnectionManagerContext,
|
||||||
|
event: RTCTrackEvent,
|
||||||
|
remotePeerId: string
|
||||||
|
): void {
|
||||||
|
const { logger, state } = context;
|
||||||
|
const track = event.track;
|
||||||
|
const settings =
|
||||||
|
typeof track.getSettings === 'function' ? track.getSettings() : ({} as MediaTrackSettings);
|
||||||
|
|
||||||
|
logger.info('Remote track', {
|
||||||
|
remotePeerId,
|
||||||
|
kind: track.kind,
|
||||||
|
id: track.id,
|
||||||
|
enabled: track.enabled,
|
||||||
|
readyState: track.readyState,
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.attachTrackDiagnostics(track, `remote:${remotePeerId}:${track.kind}`);
|
||||||
|
|
||||||
|
if (track.kind === TRACK_KIND_VIDEO && (!track.enabled || track.readyState !== 'live')) {
|
||||||
|
logger.info('Skipping inactive video track', {
|
||||||
|
remotePeerId,
|
||||||
|
enabled: track.enabled,
|
||||||
|
readyState: track.readyState
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compositeStream = buildCompositeRemoteStream(state, remotePeerId, track);
|
||||||
|
|
||||||
|
track.addEventListener('ended', () => removeRemoteTrack(state, remotePeerId, track.id));
|
||||||
|
|
||||||
|
state.remotePeerStreams.set(remotePeerId, compositeStream);
|
||||||
|
state.remoteStream$.next({
|
||||||
|
peerId: remotePeerId,
|
||||||
|
stream: compositeStream
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCompositeRemoteStream(
|
||||||
|
state: PeerConnectionManagerContext['state'],
|
||||||
|
remotePeerId: string,
|
||||||
|
incomingTrack: MediaStreamTrack
|
||||||
|
): MediaStream {
|
||||||
|
const existingStream = state.remotePeerStreams.get(remotePeerId);
|
||||||
|
|
||||||
|
let preservedTracks: MediaStreamTrack[] = [];
|
||||||
|
|
||||||
|
if (existingStream) {
|
||||||
|
preservedTracks = existingStream.getTracks().filter(
|
||||||
|
(existingTrack) =>
|
||||||
|
existingTrack.kind !== incomingTrack.kind && existingTrack.readyState === 'live'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new MediaStream([...preservedTracks, incomingTrack]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRemoteTrack(
|
||||||
|
state: PeerConnectionManagerContext['state'],
|
||||||
|
remotePeerId: string,
|
||||||
|
trackId: string
|
||||||
|
): void {
|
||||||
|
const currentStream = state.remotePeerStreams.get(remotePeerId);
|
||||||
|
|
||||||
|
if (!currentStream)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const remainingTracks = currentStream
|
||||||
|
.getTracks()
|
||||||
|
.filter((existingTrack) => existingTrack.id !== trackId && existingTrack.readyState === 'live');
|
||||||
|
|
||||||
|
if (remainingTracks.length === currentStream.getTracks().length)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (remainingTracks.length === 0) {
|
||||||
|
state.remotePeerStreams.delete(remotePeerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStream = new MediaStream(remainingTracks);
|
||||||
|
|
||||||
|
state.remotePeerStreams.set(remotePeerId, nextStream);
|
||||||
|
state.remoteStream$.next({
|
||||||
|
peerId: remotePeerId,
|
||||||
|
stream: nextStream
|
||||||
|
});
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user