mattermost-mobile/app/products/calls/connection/simple-peer.ts
Daniel Espino García 5fae120826
Add support for review app (#6772)
* Add app review

* Use overlay instead of modal

* Add fixes for ios

* i18n-extract

* Add to milliseconds function

* Address review feedback

* Add try to queryGlobalValue

* added app review illustration

* add feedback illustration

* Add animations and feedback bot message

* Restrict reviews to build environment variable

* Fix bug with "dont ask anymore"

* Add check for only supported servers

* Add missing change

* Use for await

Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local>
Co-authored-by: Matthew Birtch <mattbirtch@gmail.com>
2022-11-24 18:52:15 +01:00

1034 lines
31 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/*! based on simple-peer. MIT License. Feross Aboukhadijeh
* <https://feross.org/opensource> */
import {Buffer} from 'buffer';
import {
RTCPeerConnection,
RTCIceCandidate,
RTCSessionDescription,
MediaStream,
MediaStreamTrack,
EventOnCandidate,
EventOnAddStream,
RTCDataChannel,
RTCSessionDescriptionType,
MessageEvent,
RTCIceCandidateType,
} from 'react-native-webrtc';
import stream from 'readable-stream';
import {toMilliseconds} from '@utils/datetime';
import type {ICEServersConfigs} from '@calls/types/calls';
const queueMicrotask = (callback: any) => {
Promise.resolve().then(callback).catch((e) => setTimeout(() => {
throw e;
}));
};
const errCode = (err: Error, code: string) => {
Object.defineProperty(err, 'code', {value: code, enumerable: true, configurable: true});
return err;
};
function generateId(): string {
// Implementation taken from http://stackoverflow.com/a/2117523
let id = 'xxxxxxxxxxxxxxxxxxxx';
id = id.replace(/[xy]/g, (c) => {
const r = Math.floor(Math.random() * 16);
let v;
if (c === 'x') {
v = r;
} else {
v = (r & 0x3) | 0x8;
}
return v.toString(16);
});
return id;
}
const MAX_BUFFERED_AMOUNT = 64 * 1024;
const ICECOMPLETE_TIMEOUT = toMilliseconds({seconds: 5});
const CHANNEL_CLOSING_TIMEOUT = toMilliseconds({seconds: 5});
/**
* WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods.
* Duplex stream.
* @param {Object} opts
*/
export default class Peer extends stream.Duplex {
destroyed = false;
destroying = false;
connecting = false;
isConnected = false;
id = generateId().slice(0, 7);
channelName = generateId();
streams: MediaStream[];
private pcReady = false;
private channelReady = false;
private iceComplete = false; // ice candidate trickle done (got null candidate)
private iceCompleteTimer: ReturnType<typeof setTimeout>|null = null; // send an offer/answer anyway after some timeout
private channel: RTCDataChannel|null = null;
private pendingCandidates: RTCIceCandidateType[] = [];
private isNegotiating = false; // is this peer waiting for negotiation to complete?
private batchedNegotiation = false; // batch synchronous negotiations
private queuedNegotiation = false; // is there a queued negotiation request?
private sendersAwaitingStable = [];
private senderMap = new Map();
private closingInterval: ReturnType<typeof setInterval>|null = null;
private remoteTracks: MediaStreamTrack[] = [];
private remoteStreams: MediaStream[] = [];
private chunk = null;
private cb: ((error?: Error | null) => void) | null = null;
private interval: ReturnType<typeof setInterval>|null = null;
private pc: RTCPeerConnection|null = null;
private onFinishBound?: () => void;
constructor(localStream: MediaStream | null, iceServers: ICEServersConfigs) {
super({allowHalfOpen: false});
this.streams = localStream ? [localStream] : [];
this.onFinishBound = () => {
this.onFinish();
};
const connConfig = {
iceServers,
sdpSemantics: 'unified-plan',
};
try {
this.pc = new RTCPeerConnection(connConfig);
} catch (err) {
this.destroy(errCode(err as Error, 'ERR_PC_CONSTRUCTOR'));
return;
}
// We prefer feature detection whenever possible, but sometimes that's not
// possible for certain implementations.
this.pc.oniceconnectionstatechange = () => {
this.onIceStateChange();
};
this.pc.onicegatheringstatechange = () => {
this.onIceStateChange();
};
this.pc.onconnectionstatechange = () => {
this.onConnectionStateChange();
};
this.pc.onsignalingstatechange = () => {
this.onSignalingStateChange();
};
this.pc.onicecandidate = (event: EventOnCandidate) => {
this.onIceCandidate(event);
};
// Other spec events, unused by this implementation:
// - onconnectionstatechange
// - onicecandidateerror
// - onfingerprintfailure
// - onnegotiationneeded
this.setupData(this.pc.createDataChannel(this.channelName, {}));
if (this.streams) {
this.streams.forEach((s) => {
this.addStream(s);
});
}
this.pc.onaddstream = (event: EventOnAddStream) => {
this.onStream(event);
};
this.needsNegotiation();
this.once('finish', this.onFinishBound);
}
get bufferSize() {
return (this.channel && this.channel.bufferedAmount) || 0;
}
// HACK: it's possible channel.readyState is "closing" before peer.destroy() fires
// https://bugs.chromium.org/p/chromium/issues/detail?id=882743
get connected() {
return this.isConnected && this.channel?.readyState === 'open';
}
signal(dataIn: string | any) {
if (this.destroying) {
return;
}
if (this.destroyed) {
throw errCode(
new Error('cannot signal after peer is destroyed'),
'ERR_DESTROYED',
);
}
let data = dataIn;
if (typeof data === 'string') {
try {
data = JSON.parse(dataIn);
} catch (err) {
data = {};
}
}
if (data.renegotiate) {
this.needsNegotiation();
}
if (data.transceiverRequest) {
this.addTransceiver(
data.transceiverRequest.kind,
data.transceiverRequest.init,
);
}
if (data.candidate) {
if (this.pc?.remoteDescription && this.pc?.remoteDescription.type) {
this.addIceCandidate(data.candidate);
} else {
this.pendingCandidates.push(data.candidate);
}
}
if (data.sdp) {
this.pc?.
setRemoteDescription(
new RTCSessionDescription(data),
).
then(() => {
if (this.destroyed) {
return;
}
this.pendingCandidates.forEach((candidate: RTCIceCandidateType) => {
this.addIceCandidate(candidate);
});
this.pendingCandidates = [];
if (this.pc?.remoteDescription.type === 'offer') {
this.createAnswer();
}
}).
catch((err: Error) => {
this.destroy(errCode(err, 'ERR_SET_REMOTE_DESCRIPTION'));
});
}
if (
!data.sdp &&
!data.candidate &&
!data.renegotiate &&
!data.transceiverRequest
) {
this.destroy(
errCode(
new Error('signal() called with invalid signal data'),
'ERR_SIGNALING',
),
);
}
}
addIceCandidate(candidate: RTCIceCandidateType) {
const iceCandidateObj = new RTCIceCandidate(candidate);
this.pc?.addIceCandidate(iceCandidateObj).catch((err: Error) => {
this.destroy(errCode(err, 'ERR_ADD_ICE_CANDIDATE'));
});
}
/**
* Send text/binary data to the remote peer.
* @param {ArrayBufferView|ArrayBuffer|Buffer|string|Blob} chunk
*/
send(chunk: string | ArrayBuffer | ArrayBufferView) {
if (this.destroying) {
return;
}
if (this.destroyed) {
throw errCode(
new Error('cannot send after peer is destroyed'),
'ERR_DESTROYED',
);
}
this.channel?.send(chunk);
}
/**
* Add a Transceiver to the connection.
* @param {String} kind
* @param {Object} init
*/
addTransceiver(kind: 'audio'|'video'|MediaStreamTrack, init: any) {
if (this.destroying) {
return;
}
if (this.destroyed) {
throw errCode(
new Error('cannot addTransceiver after peer is destroyed'),
'ERR_DESTROYED',
);
}
try {
this.pc?.addTransceiver(kind, init);
this.needsNegotiation();
} catch (err) {
this.destroy(errCode(err as Error, 'ERR_ADD_TRANSCEIVER'));
}
}
/**
* Add a MediaStream to the connection.
* @param {MediaStream} s
*/
addStream(s: MediaStream) {
if (this.destroying) {
return;
}
if (this.destroyed) {
throw errCode(
new Error('cannot addStream after peer is destroyed'),
'ERR_DESTROYED',
);
}
s.getTracks().forEach((track: MediaStreamTrack) => {
this.addTrack(track, s);
});
}
/**
* Add a MediaStreamTrack to the connection.
* @param {MediaStreamTrack} track
* @param {MediaStream} s
*/
async addTrack(track: MediaStreamTrack, s: MediaStream) {
if (this.destroying) {
return;
}
if (this.destroyed || !this.pc) {
throw errCode(
new Error('cannot addTrack after peer is destroyed'),
'ERR_DESTROYED',
);
}
const submap = this.senderMap.get(track) || new Map(); // nested Maps map [track, stream] to sender
const sender = submap.get(s);
if (!sender) {
const transceiver = await this.pc.addTransceiver(track, {direction: 'sendrecv'}) as any;
/* eslint-disable no-underscore-dangle */
submap.set(s, transceiver._sender);
this.senderMap.set(track, submap);
this.needsNegotiation();
} else if (sender.removed) {
throw errCode(
new Error(
'Track has been removed. You should enable/disable tracks that you want to re-add.',
),
'ERR_SENDER_REMOVED',
);
} else {
throw errCode(
new Error('Track has already been added to that stream.'),
'ERR_SENDER_ALREADY_ADDED',
);
}
}
/**
* Replace a MediaStreamTrack by another in the connection.
* @param {MediaStreamTrack} oldTrack
* @param {MediaStreamTrack} newTrack
* @param {MediaStream} stream
*/
replaceTrack(oldTrack: MediaStreamTrack, newTrack: MediaStreamTrack | null, s: MediaStream) {
if (this.destroying) {
return;
}
if (this.destroyed) {
throw errCode(new Error('cannot replaceTrack after peer is destroyed'), 'ERR_DESTROYED');
}
const submap = this.senderMap.get(oldTrack);
const sender = submap ? submap.get(s) : null;
if (!sender) {
throw errCode(new Error('Cannot replace track that was never added.'), 'ERR_TRACK_NOT_ADDED');
}
if (newTrack) {
this.senderMap.set(newTrack, submap);
}
if (sender.replaceTrack == null) {
this.destroy(errCode(new Error('replaceTrack is not supported in this browser'), 'ERR_UNSUPPORTED_REPLACETRACK'));
} else {
sender.replaceTrack(newTrack);
}
}
needsNegotiation() {
if (this.batchedNegotiation) {
return;
} // batch synchronous renegotiations
this.batchedNegotiation = true;
queueMicrotask(() => {
this.batchedNegotiation = false;
this.negotiate();
});
}
negotiate() {
if (this.destroying) {
return;
}
if (this.destroyed) {
throw errCode(
new Error('cannot negotiate after peer is destroyed'),
'ERR_DESTROYED',
);
}
if (this.isNegotiating) {
this.queuedNegotiation = true;
} else {
setTimeout(() => {
// HACK: Chrome crashes if we immediately call createOffer
this.createOffer();
}, 0);
}
this.isNegotiating = true;
}
destroy(err?: Error, cb?: (error: Error | null) => void, cbPCClose?: () => void): this {
this._destroy(err, cb, cbPCClose);
return this;
}
_destroy(err?: Error | null, cb?: (error: Error | null) => void, cbPcClose?: () => void) {
if (this.destroyed || this.destroying) {
return;
}
this.destroying = true;
setTimeout(() => {
// allow events concurrent with the call to _destroy() to fire (see #692)
this.destroyed = true;
this.destroying = false;
this.readable = false;
this.writable = false;
// if (!this._readableState?.ended) this.push(null);
// if (!this._writableState?.finished) this.end();
this.isConnected = false;
this.pcReady = false;
this.channelReady = false;
this.remoteTracks = [];
this.remoteStreams = [];
this.senderMap = new Map();
if (this.closingInterval) {
clearInterval(this.closingInterval);
}
this.closingInterval = null;
if (this.interval) {
clearInterval(this.interval);
}
this.interval = null;
this.chunk = null;
this.cb = null;
if (this.onFinishBound) {
this.removeListener('finish', this.onFinishBound);
}
this.onFinishBound = undefined;
if (this.channel) {
try {
this.channel.close();
} catch (err) {} // eslint-disable-line
// allow events concurrent with destruction to be handled
this.channel.onmessage = undefined;
this.channel.onopen = undefined;
this.channel.onclose = undefined;
this.channel.onerror = undefined;
}
if (this.pc) {
try {
this.pc.close(cbPcClose);
} catch (err) {} // eslint-disable-line
// allow events concurrent with destruction to be handled
this.pc.oniceconnectionstatechange = () => undefined;
this.pc.onicegatheringstatechange = () => undefined;
this.pc.onsignalingstatechange = () => undefined;
this.pc.onicecandidate = () => undefined;
}
this.pc = null;
this.channel = null;
if (err) {
this.emit('error', err);
}
this.emit('close');
cb?.(null);
}, 0);
}
setupData(channel: RTCDataChannel) {
if (!channel) {
// In some situations `pc.createDataChannel()` returns `undefined` (in wrtc),
// which is invalid behavior. Handle it gracefully.
// See: https://github.com/feross/simple-peer/issues/163
this.destroy(
errCode(
new Error(
'Data channel is missing `channel` property',
),
'ERR_DATA_CHANNEL',
),
);
return;
}
this.channel = channel;
this.channel.binaryType = 'arraybuffer';
if (typeof this.channel.bufferedAmountLowThreshold === 'number') {
this.channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT;
}
this.channelName = this.channel.label;
this.channel.onmessage = (e: MessageEvent) => {
this.onChannelMessage(e);
};
this.channel.onbufferedamountlow = () => {
this.onChannelBufferedAmountLow();
};
this.channel.onopen = () => {
this.onChannelOpen();
};
this.channel.onclose = () => {
this.onChannelClose();
};
this.channel.onerror = (e: any) => {
const err =
e.error instanceof Error ? e.error : new Error(
`Datachannel error: ${e.message} ${e.filename}:${e.lineno}:${e.colno}`,
);
this.destroy(errCode(err, 'ERR_DATA_CHANNEL'));
};
// HACK: Chrome will sometimes get stuck in readyState "closing", let's check for this condition
// https://bugs.chromium.org/p/chromium/issues/detail?id=882743
let isClosing = false;
this.closingInterval = setInterval(() => {
// No "onclosing" event
if (this.channel && this.channel.readyState === 'closing') {
if (isClosing) {
this.onChannelClose();
} // closing timed out: equivalent to onclose firing
isClosing = true;
} else {
isClosing = false;
}
}, CHANNEL_CLOSING_TIMEOUT);
}
_read() {
return null;
}
_write(chunk: any, encoding: string, cb: (error?: Error | null) => void): void {
if (this.destroyed) {
cb(
errCode(
new Error('cannot write after peer is destroyed'),
'ERR_DATA_CHANNEL',
),
);
return;
}
if (this.isConnected) {
try {
this.send(chunk);
} catch (err) {
this.destroy(errCode(err as Error, 'ERR_DATA_CHANNEL'));
return;
}
if (this.channel?.bufferedAmount && this.channel?.bufferedAmount > MAX_BUFFERED_AMOUNT) {
this.cb = cb;
} else {
cb(null);
}
} else {
this.chunk = chunk;
this.cb = cb;
}
}
// When stream finishes writing, close socket. Half open connections are not
// supported.
onFinish() {
if (this.destroyed) {
return;
}
// Wait a bit before destroying so the socket flushes.
const destroySoon = () => {
setTimeout(() => this.destroy(), 1000);
};
if (this.isConnected) {
destroySoon();
} else {
this.once('connect', destroySoon);
}
}
startIceCompleteTimeout() {
if (this.destroyed) {
return;
}
if (this.iceCompleteTimer) {
return;
}
this.iceCompleteTimer = setTimeout(() => {
if (!this.iceComplete) {
this.iceComplete = true;
this.emit('iceTimeout');
this.emit('iceComplete');
}
}, ICECOMPLETE_TIMEOUT);
}
createOffer() {
if (this.destroyed) {
return;
}
this.pc?.
createOffer({}).
then((offer: RTCSessionDescriptionType) => {
if (this.destroyed) {
return;
}
const sendOffer = () => {
if (this.destroyed) {
return;
}
const signal = this.pc?.localDescription || offer;
this.emit('signal', {
type: signal.type,
sdp: signal.sdp,
});
};
const onSuccess = () => {
if (this.destroyed) {
return;
}
sendOffer();
};
const onError = (err: Error) => {
this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION'));
};
this.pc?.
setLocalDescription(offer).
then(onSuccess).
catch(onError);
}).
catch((err: Error) => {
this.destroy(errCode(err, 'ERR_CREATE_OFFER'));
});
}
createAnswer() {
if (this.destroyed) {
return;
}
this.pc?.
createAnswer({}).
then((answer: RTCSessionDescriptionType) => {
if (this.destroyed) {
return;
}
const sendAnswer = () => {
if (this.destroyed) {
return;
}
const signal = this.pc?.localDescription || answer;
this.emit('signal', {
type: signal.type,
sdp: signal.sdp,
});
};
const onSuccess = () => {
if (this.destroyed) {
return;
}
sendAnswer();
};
const onError = (err: Error) => {
this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION'));
};
this.pc?.
setLocalDescription(answer).
then(onSuccess).
catch(onError);
}).
catch((err: Error) => {
this.destroy(errCode(err, 'ERR_CREATE_ANSWER'));
});
}
onConnectionStateChange() {
if (this.destroyed) {
return;
}
if (this.pc?.connectionState === 'failed') {
this.destroy(
errCode(
new Error('Connection failed.'),
'ERR_CONNECTION_FAILURE',
),
);
}
}
onIceStateChange() {
if (this.destroyed) {
return;
}
const iceConnectionState = this.pc?.iceConnectionState;
const iceGatheringState = this.pc?.iceGatheringState;
this.emit('iceStateChange', iceConnectionState, iceGatheringState);
if (
iceConnectionState === 'connected' ||
iceConnectionState === 'completed'
) {
this.pcReady = true;
this.maybeReady();
}
if (iceConnectionState === 'failed') {
this.destroy(
errCode(
new Error('Ice connection failed.'),
'ERR_ICE_CONNECTION_FAILURE',
),
);
}
if (iceConnectionState === 'closed') {
this.destroy(
errCode(
new Error('Ice connection closed.'),
'ERR_ICE_CONNECTION_CLOSED',
),
);
}
}
getStats(cb: (error: Error|null, reports?: any) => void) {
// statreports can come with a value array instead of properties
const flattenValues = (report: any) => {
if (
Object.prototype.toString.call(report.values) ===
'[object Array]'
) {
report.values.forEach((value: any) => {
Object.assign(report, value);
});
}
return report;
};
this.pc?.getStats().then(
(res: any) => {
const reports: any[] = [];
res.forEach((report: any) => {
reports.push(flattenValues(report));
});
cb(null, reports);
},
(err: Error) => cb(err),
);
}
maybeReady() {
if (
this.isConnected ||
this.connecting ||
!this.pcReady ||
!this.channelReady
) {
return;
}
this.connecting = true;
// HACK: We can't rely on order here, for details see https://github.com/js-platform/node-webrtc/issues/339
const findCandidatePair = () => {
if (this.destroyed) {
return;
}
this.getStats((err, itemsParam) => {
if (this.destroyed) {
return;
}
let items = itemsParam;
// Treat getStats error as non-fatal. It's not essential.
if (err) {
items = [];
}
const remoteCandidates: {[key: string]: any} = {};
const localCandidates: {[key: string]: any} = {};
const candidatePairs: {[key: string]: any} = {};
let foundSelectedCandidatePair = false;
items.forEach((item: any) => {
if (
item.type === 'remotecandidate' ||
item.type === 'remote-candidate'
) {
remoteCandidates[item.id] = item;
}
if (
item.type === 'localcandidate' ||
item.type === 'local-candidate'
) {
localCandidates[item.id] = item;
}
if (
item.type === 'candidatepair' ||
item.type === 'candidate-pair'
) {
candidatePairs[item.id] = item;
}
});
items.forEach((item: any) => {
if (
(item.type === 'transport' &&
item.selectedCandidatePairId) ||
(item.type === 'googCandidatePair' &&
item.googActiveConnection === 'true') ||
((item.type === 'candidatepair' ||
item.type === 'candidate-pair') &&
item.selected)
) {
foundSelectedCandidatePair = true;
}
});
// Ignore candidate pair selection in browsers like Safari 11 that do not have any local or remote candidates
// But wait until at least 1 candidate pair is available
if (
!foundSelectedCandidatePair &&
(!Object.keys(candidatePairs).length ||
Object.keys(localCandidates).length)
) {
setTimeout(findCandidatePair, 100);
return;
}
this.connecting = false;
this.isConnected = true;
if (this.chunk) {
try {
this.send(this.chunk);
} catch (err2) {
this.destroy(errCode(err2 as Error, 'ERR_DATA_CHANNEL'));
return;
}
this.chunk = null;
const cb = this.cb;
this.cb = null;
if (cb) {
cb(null);
}
}
// If `bufferedAmountLowThreshold` and 'onbufferedamountlow' are unsupported,
// fallback to using setInterval to implement backpressure.
if (
typeof this.channel?.bufferedAmountLowThreshold !== 'number'
) {
this.interval = setInterval(() => this.onInterval(), 150);
if (this.interval.unref) {
this.interval.unref();
}
}
this.emit('connect');
});
};
findCandidatePair();
}
onInterval() {
if (
!this.cb ||
!this.channel ||
this.channel.bufferedAmount > MAX_BUFFERED_AMOUNT
) {
return;
}
this.onChannelBufferedAmountLow();
}
onSignalingStateChange() {
if (this.destroyed) {
return;
}
if (this.pc?.signalingState === 'stable') {
this.isNegotiating = false;
// HACK: Firefox doesn't yet support removing tracks when signalingState !== 'stable'
this.sendersAwaitingStable.forEach((sender) => {
this.pc?.removeTrack(sender);
this.queuedNegotiation = true;
});
this.sendersAwaitingStable = [];
if (this.queuedNegotiation) {
this.queuedNegotiation = false;
this.needsNegotiation(); // negotiate again
} else {
this.emit('negotiated');
}
}
this.emit('signalingStateChange', this.pc?.signalingState);
}
onIceCandidate(event: EventOnCandidate) {
if (this.destroyed) {
return;
}
if (event.candidate) {
this.emit('signal', {
type: 'candidate',
candidate: {
candidate: event.candidate.candidate,
sdpMLineIndex: event.candidate.sdpMLineIndex,
sdpMid: event.candidate.sdpMid,
},
});
} else if (!event.candidate && !this.iceComplete) {
this.iceComplete = true;
this.emit('iceComplete');
}
// as soon as we've received one valid candidate start timeout
if (event.candidate) {
this.startIceCompleteTimeout();
}
}
onChannelMessage(event: MessageEvent) {
if (this.destroyed) {
return;
}
let data = event.data;
if (data instanceof ArrayBuffer) {
data = Buffer.from(data);
}
this.push(data);
}
onChannelBufferedAmountLow() {
if (this.destroyed || !this.cb) {
return;
}
const cb = this.cb;
this.cb = null;
cb(null);
}
onChannelOpen() {
if (this.isConnected || this.destroyed) {
return;
}
this.channelReady = true;
this.maybeReady();
}
onChannelClose() {
if (this.destroyed) {
return;
}
this.destroy();
}
onStream(event: EventOnAddStream) {
if (this.destroyed) {
return;
}
event.target._remoteStreams.forEach((eventStream: MediaStream) => { // eslint-disable-line
eventStream._tracks.forEach((eventTrack: MediaStreamTrack) => { // eslint-disable-line
if (
this.remoteTracks.some((remoteTrack: MediaStreamTrack) => { // eslint-disable-line
return remoteTrack.id === eventTrack.id;
})
) {
return;
} // Only fire one 'stream' event, even though there may be multiple tracks per stream
if (event.track) {
this.remoteTracks.push(event.track);
this.emit('track', eventTrack, eventStream);
}
});
if (
this.remoteStreams.some((remoteStream) => {
return remoteStream.id === eventStream.id;
})
) {
return;
} // Only fire one 'stream' event, even though there may be multiple tracks per stream
this.remoteStreams.push(eventStream);
queueMicrotask(() => {
this.emit('stream', eventStream); // ensure all tracks have been added
});
});
}
}