@jellyfish-dev/ts-client-sdk
Version:
Typescript client library for Jellyfish.
1,183 lines • 55.7 kB
JavaScript
import { deserializeMediaEvent, generateCustomEvent, generateMediaEvent, serializeMediaEvent, } from "./mediaEvent";
import { v4 as uuidv4 } from "uuid";
import EventEmitter from "events";
import { defaultBitrates, defaultSimulcastBitrates, simulcastTransceiverConfig } from "./const";
import { Deferred } from "./deferred";
const vadStatuses = ["speech", "silence"];
class TrackContextImpl extends EventEmitter {
endpoint;
trackId;
track = null;
stream = null;
metadata;
rawMetadata;
metadataParsingError;
simulcastConfig;
maxBandwidth = 0;
encoding;
encodingReason;
vadStatus = "silence";
negotiationStatus = "awaiting";
// Indicates that metadata were changed when in "offered" negotiationStatus
// and `updateTrackMetadata` Media Event should be sent after the transition to "done"
pendingMetadataUpdate = false;
constructor(endpoint, trackId, metadata, simulcastConfig, metadataParser) {
super();
this.endpoint = endpoint;
this.trackId = trackId;
try {
this.metadata = metadataParser(metadata);
}
catch (error) {
this.metadataParsingError = error;
}
this.rawMetadata = metadata;
this.simulcastConfig = simulcastConfig;
}
}
/**
* Main class that is responsible for connecting to the RTC Engine, sending and receiving media.
*/
export class WebRTCEndpoint extends EventEmitter {
trackIdToTrack = new Map();
connection;
idToEndpoint = new Map();
localEndpoint = {
id: "",
type: "webrtc",
metadata: undefined,
rawMetadata: undefined,
tracks: new Map(),
};
localTrackIdToTrack = new Map();
midToTrackId = new Map();
disabledTrackEncodings = new Map();
rtcConfig = {
bundlePolicy: "max-bundle",
iceServers: [],
iceTransportPolicy: "relay",
};
bandwidthEstimation = BigInt(0);
/**
* Indicates if an ongoing renegotiation is active.
* During renegotiation, both parties are expected to actively exchange events: renegotiateTracks, offerData, sdpOffer, sdpAnswer.
*/
ongoingRenegotiation = false;
ongoingTrackReplacement = false;
commandsQueue = [];
commandResolutionNotifier = null;
endpointMetadataParser;
trackMetadataParser;
constructor(config) {
super();
this.endpointMetadataParser = config?.endpointMetadataParser ?? ((x) => x);
this.trackMetadataParser = config?.trackMetadataParser ?? ((x) => x);
}
/**
* Tries to connect to the RTC Engine. If user is successfully connected then {@link WebRTCEndpointEvents.connected}
* will be emitted.
*
* @param metadata - Any information that other endpoints will receive in {@link WebRTCEndpointEvents.endpointAdded}
* after accepting this endpoint
*
* @example
* ```ts
* let webrtc = new WebRTCEndpoint();
* webrtc.connect({displayName: "Bob"});
* ```
*/
connect = (metadata) => {
try {
this.localEndpoint.metadata = this.endpointMetadataParser(metadata);
this.localEndpoint.metadataParsingError = undefined;
}
catch (error) {
this.localEndpoint.metadata = undefined;
this.localEndpoint.metadataParsingError = error;
throw error;
}
this.localEndpoint.rawMetadata = metadata;
const mediaEvent = generateMediaEvent("connect", {
metadata: this.localEndpoint.metadata,
});
this.sendMediaEvent(mediaEvent);
};
/**
* Feeds media event received from RTC Engine to {@link WebRTCEndpoint}.
* This function should be called whenever some media event from RTC Engine
* was received and can result in {@link WebRTCEndpoint} generating some other
* media events.
*
* @param mediaEvent - String data received over custom signalling layer.
*
* @example
* This example assumes phoenix channels as signalling layer.
* As phoenix channels require objects, RTC Engine encapsulates binary data into
* map with one field that is converted to object with one field on the TS side.
* ```ts
* webrtcChannel.on("mediaEvent", (event) => webrtc.receiveMediaEvent(event.data));
* ```
*/
receiveMediaEvent = (mediaEvent) => {
const deserializedMediaEvent = deserializeMediaEvent(mediaEvent);
switch (deserializedMediaEvent.type) {
case "connected": {
this.localEndpoint.id = deserializedMediaEvent.data.id;
const endpoints = deserializedMediaEvent.data.otherEndpoints;
const otherEndpoints = endpoints.map((endpoint) => {
const tracks = this.mapMediaEventTracksToTrackContextImpl(new Map(Object.entries(endpoint.tracks)), endpoint);
try {
return {
id: endpoint.id,
type: endpoint.type,
metadata: this.endpointMetadataParser(endpoint.metadata),
rawMetadata: endpoint.metadata,
metadataParsingError: undefined,
tracks,
};
}
catch (error) {
return {
id: endpoint.id,
type: endpoint.type,
metadata: undefined,
rawMetadata: endpoint.metadata,
metadataParsingError: error,
tracks,
};
}
});
this.emit("connected", deserializedMediaEvent.data.id, otherEndpoints);
otherEndpoints.forEach((endpoint) => this.idToEndpoint.set(endpoint.id, endpoint));
otherEndpoints.forEach((endpoint) => {
endpoint.tracks.forEach((ctx, trackId) => {
this.trackIdToTrack.set(trackId, ctx);
this.emit("trackAdded", ctx);
});
});
break;
}
default:
if (this.localEndpoint.id != null)
this.handleMediaEvent(deserializedMediaEvent);
}
};
/**
* Retrieves statistics related to the RTCPeerConnection.
* These statistics provide insights into the performance and status of the connection.
*
* @return {Promise<RTCStatsReport>}
*
* @external RTCPeerConnection#getStats()
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getStats | MDN Web Docs: RTCPeerConnection.getStats()}
*/
async getStatistics(selector) {
return (await this.connection?.getStats(selector)) ?? new Map();
}
/**
* Returns a snapshot of currently received remote tracks.
*
* @example
* if (webRTCEndpoint.getRemoteTracks()[trackId]?.simulcastConfig?.enabled) {
* webRTCEndpoint.setTargetTrackEncoding(trackId, encoding);
* }
*/
getRemoteTracks() {
return Object.fromEntries(this.trackIdToTrack.entries());
}
/**
* Returns a snapshot of currently received remote endpoints.
*/
getRemoteEndpoints() {
return Object.fromEntries(this.idToEndpoint.entries());
}
getLocalEndpoint() {
return this.localEndpoint;
}
getBandwidthEstimation() {
return this.bandwidthEstimation;
}
handleMediaEvent = (deserializedMediaEvent) => {
let endpoint;
let data;
switch (deserializedMediaEvent.type) {
case "offerData": {
const turnServers = deserializedMediaEvent.data.integratedTurnServers;
this.setTurns(turnServers);
const offerData = new Map(Object.entries(deserializedMediaEvent.data.tracksTypes));
this.onOfferData(offerData);
break;
}
case "tracksAdded": {
this.ongoingRenegotiation = true;
data = deserializedMediaEvent.data;
if (this.getEndpointId() === data.endpointId)
return;
data.tracks = new Map(Object.entries(data.tracks));
endpoint = this.idToEndpoint.get(data.endpointId);
const oldTracks = endpoint.tracks;
data.tracks = this.mapMediaEventTracksToTrackContextImpl(data.tracks, endpoint);
endpoint.tracks = new Map([...endpoint.tracks, ...data.tracks]);
this.idToEndpoint.set(endpoint.id, endpoint);
Array.from(endpoint.tracks.entries()).forEach(([trackId, ctx]) => {
if (!oldTracks.has(trackId)) {
this.trackIdToTrack.set(trackId, ctx);
this.emit("trackAdded", ctx);
}
});
break;
}
case "tracksRemoved": {
this.ongoingRenegotiation = true;
data = deserializedMediaEvent.data;
const endpointId = data.endpointId;
if (this.getEndpointId() === endpointId)
return;
const trackIds = data.trackIds;
trackIds.forEach((trackId) => {
const trackContext = this.trackIdToTrack.get(trackId);
this.eraseTrack(trackId, endpointId);
this.emit("trackRemoved", trackContext);
});
break;
}
case "sdpAnswer":
this.midToTrackId = new Map(Object.entries(deserializedMediaEvent.data.midToTrackId));
for (const trackId of Object.values(deserializedMediaEvent.data.midToTrackId)) {
const track = this.localTrackIdToTrack.get(trackId);
// if is local track
if (track) {
track.negotiationStatus = "done";
if (track.pendingMetadataUpdate) {
const mediaEvent = generateMediaEvent("updateTrackMetadata", {
trackId,
trackMetadata: track.metadata,
});
this.sendMediaEvent(mediaEvent);
}
track.pendingMetadataUpdate = false;
}
}
this.onAnswer(deserializedMediaEvent.data);
this.ongoingRenegotiation = false;
this.processNextCommand();
break;
case "candidate":
this.onRemoteCandidate(deserializedMediaEvent.data);
break;
case "endpointAdded":
endpoint = deserializedMediaEvent.data;
if (endpoint.id === this.getEndpointId())
return;
endpoint.rawMetadata = endpoint.metadata;
try {
endpoint.metadataParsingError = undefined;
endpoint.metadata = this.endpointMetadataParser(endpoint.rawMetadata);
}
catch (error) {
endpoint.metadataParsingError = error;
endpoint.metadata = undefined;
}
this.addEndpoint(endpoint);
this.emit("endpointAdded", endpoint);
break;
case "endpointRemoved":
if (deserializedMediaEvent.data.id === this.localEndpoint.id) {
this.cleanUp();
this.emit("disconnected");
return;
}
endpoint = this.idToEndpoint.get(deserializedMediaEvent.data.id);
if (endpoint === undefined)
return;
Array.from(endpoint.tracks.keys()).forEach((trackId) => {
this.emit("trackRemoved", this.trackIdToTrack.get(trackId));
});
this.eraseEndpoint(endpoint);
this.emit("endpointRemoved", endpoint);
break;
case "endpointUpdated":
if (this.getEndpointId() === deserializedMediaEvent.data.id)
return;
endpoint = this.idToEndpoint.get(deserializedMediaEvent.data.id);
try {
endpoint.metadata = this.endpointMetadataParser(deserializedMediaEvent.data.metadata);
endpoint.metadataParsingError = undefined;
}
catch (error) {
endpoint.metadata = undefined;
endpoint.metadataParsingError = error;
}
endpoint.rawMetadata = deserializedMediaEvent.data.metadata;
this.addEndpoint(endpoint);
this.emit("endpointUpdated", endpoint);
break;
case "trackUpdated": {
if (this.getEndpointId() === deserializedMediaEvent.data.endpointId)
return;
endpoint = this.idToEndpoint.get(deserializedMediaEvent.data.endpointId);
if (endpoint == null)
throw `Endpoint with id: ${deserializedMediaEvent.data.endpointId} doesn't exist`;
const trackId = deserializedMediaEvent.data.trackId;
const trackMetadata = deserializedMediaEvent.data.metadata;
let newTrack = endpoint.tracks.get(trackId);
const trackContext = this.trackIdToTrack.get(trackId);
try {
const parsedMetadata = this.trackMetadataParser(trackMetadata);
newTrack = { ...newTrack, metadata: parsedMetadata, metadataParsingError: undefined };
trackContext.metadata = parsedMetadata;
trackContext.metadataParsingError = undefined;
}
catch (error) {
newTrack = { ...newTrack, metadata: undefined, metadataParsingError: error };
trackContext.metadataParsingError = error;
trackContext.metadata = undefined;
}
newTrack = { ...newTrack, rawMetadata: trackMetadata };
trackContext.rawMetadata = trackMetadata;
endpoint.tracks.set(trackId, newTrack);
this.emit("trackUpdated", trackContext);
break;
}
case "trackEncodingDisabled": {
if (this.getEndpointId() === deserializedMediaEvent.data.endpointId)
return;
endpoint = this.idToEndpoint.get(deserializedMediaEvent.data.endpointId);
if (endpoint == null)
throw `Endpoint with id: ${deserializedMediaEvent.data.endpointId} doesn't exist`;
const trackId = deserializedMediaEvent.data.trackId;
const encoding = deserializedMediaEvent.data.encoding;
const trackContext = endpoint.tracks.get(trackId);
this.emit("trackEncodingDisabled", trackContext, encoding);
break;
}
case "trackEncodingEnabled": {
if (this.getEndpointId() === deserializedMediaEvent.data.endpointId)
return;
endpoint = this.idToEndpoint.get(deserializedMediaEvent.data.endpointId);
if (endpoint == null)
throw `Endpoint with id: ${deserializedMediaEvent.data.endpointId} doesn't exist`;
const trackId = deserializedMediaEvent.data.trackId;
const encoding = deserializedMediaEvent.data.encoding;
const trackContext = endpoint.tracks.get(trackId);
this.emit("trackEncodingEnabled", trackContext, encoding);
break;
}
case "tracksPriority": {
const enabledTracks = deserializedMediaEvent.data.tracks.map((trackId) => this.trackIdToTrack.get(trackId));
const disabledTracks = Array.from(this.trackIdToTrack.values()).filter((track) => !enabledTracks.includes(track));
this.emit("tracksPriorityChanged", enabledTracks, disabledTracks);
break;
}
case "encodingSwitched": {
const trackId = deserializedMediaEvent.data.trackId;
const trackContext = this.trackIdToTrack.get(trackId);
trackContext.encoding = deserializedMediaEvent.data.encoding;
trackContext.encodingReason = deserializedMediaEvent.data.reason;
trackContext.emit("encodingChanged", trackContext);
break;
}
case "custom":
this.handleMediaEvent(deserializedMediaEvent.data);
break;
case "error":
this.emit("connectionError", deserializedMediaEvent.data.message);
this.disconnect();
break;
case "vadNotification": {
const trackId = deserializedMediaEvent.data.trackId;
const ctx = this.trackIdToTrack.get(trackId);
const vadStatus = deserializedMediaEvent.data.status;
if (vadStatuses.includes(vadStatus)) {
ctx.vadStatus = vadStatus;
ctx.emit("voiceActivityChanged", ctx);
}
else {
console.warn("Received unknown vad status: ", vadStatus);
}
break;
}
case "bandwidthEstimation": {
this.bandwidthEstimation = deserializedMediaEvent.data.estimation;
this.emit("bandwidthEstimationChanged", this.bandwidthEstimation);
break;
}
default:
console.warn("Received unknown media event: ", deserializedMediaEvent.type);
break;
}
};
/**
* Adds track that will be sent to the RTC Engine.
* @param track - Audio or video track e.g. from your microphone or camera.
* @param stream - Stream that this track belongs to.
* @param trackMetadata - Any information about this track that other endpoints will
* receive in {@link WebRTCEndpointEvents.endpointAdded}. E.g. this can source of the track - whether it's
* screensharing, webcam or some other media device.
* @param simulcastConfig - Simulcast configuration. By default simulcast is disabled.
* For more information refer to {@link SimulcastConfig}.
* @param maxBandwidth - maximal bandwidth this track can use.
* Defaults to 0 which is unlimited.
* This option has no effect for simulcast and audio tracks.
* For simulcast tracks use `{@link WebRTCEndpoint.setTrackBandwidth}.
* @returns {string} Returns id of added track
* @example
* ```ts
* let localStream: MediaStream = new MediaStream();
* try {
* localAudioStream = await navigator.mediaDevices.getUserMedia(
* AUDIO_CONSTRAINTS
* );
* localAudioStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get microphone permission:", error);
* }
*
* try {
* localVideoStream = await navigator.mediaDevices.getUserMedia(
* VIDEO_CONSTRAINTS
* );
* localVideoStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get camera permission:", error);
* }
*
* localStream
* .getTracks()
* .forEach((track) => webrtc.addTrack(track, localStream));
* ```
*/
addTrack(track, stream, trackMetadata, simulcastConfig = { enabled: false, activeEncodings: [], disabledEncodings: [] }, maxBandwidth = 0) {
const resolutionNotifier = new Deferred();
const trackId = this.getTrackId(uuidv4());
let metadata;
try {
const parsedMetadata = this.trackMetadataParser(trackMetadata);
metadata = parsedMetadata;
this.pushCommand({
commandType: "ADD-TRACK",
trackId,
track,
stream,
trackMetadata: parsedMetadata,
simulcastConfig,
maxBandwidth,
resolutionNotifier,
});
}
catch (error) {
resolutionNotifier.reject(error);
}
return resolutionNotifier.promise.then(() => {
this.emit("localTrackAdded", {
trackId,
track,
stream,
trackMetadata: metadata,
simulcastConfig,
maxBandwidth,
});
return trackId;
});
}
pushCommand(command) {
this.commandsQueue.push(command);
this.processNextCommand();
}
handleCommand(command) {
switch (command.commandType) {
case "ADD-TRACK":
this.addTrackHandler(command);
break;
case "REMOVE-TRACK":
this.removeTrackHandler(command);
break;
case "REPLACE-TRACK":
this.replaceTrackHandler(command);
break;
}
}
processNextCommand() {
if (this.ongoingRenegotiation || this.ongoingTrackReplacement)
return;
if (this.connection &&
(this.connection.signalingState !== "stable" ||
this.connection.connectionState !== "connected" ||
this.connection.iceConnectionState !== "connected"))
return;
this.resolvePreviousCommand();
const command = this.commandsQueue.shift();
if (!command)
return;
this.commandResolutionNotifier = command.resolutionNotifier;
this.handleCommand(command);
}
resolvePreviousCommand() {
if (this.commandResolutionNotifier) {
this.commandResolutionNotifier.resolve();
this.commandResolutionNotifier = null;
}
}
addTrackHandler(addTrackCommand) {
const { simulcastConfig, maxBandwidth, track, stream, trackMetadata, trackId } = addTrackCommand;
const isUsedTrack = this.connection?.getSenders().some((val) => val.track === track);
let error;
if (isUsedTrack) {
error = "This track was already added to peerConnection, it can't be added again!";
}
if (!simulcastConfig.enabled && !(typeof maxBandwidth === "number"))
error = "Invalid type of `maxBandwidth` argument for a non-simulcast track, expected: number";
if (this.getEndpointId() === "")
error = "Cannot add tracks before being accepted by the server";
if (error) {
this.commandResolutionNotifier?.reject(error);
this.commandResolutionNotifier = null;
this.processNextCommand();
return;
}
this.ongoingRenegotiation = true;
const trackContext = new TrackContextImpl(this.localEndpoint, trackId, trackMetadata, simulcastConfig, this.trackMetadataParser);
trackContext.track = track;
trackContext.stream = stream;
trackContext.maxBandwidth = maxBandwidth;
this.localEndpoint.tracks.set(trackId, trackContext);
this.localTrackIdToTrack.set(trackId, trackContext);
if (this.connection) {
this.addTrackToConnection(trackContext);
this.connection
.getTransceivers()
.forEach((transceiver) => (transceiver.direction = transceiver.direction === "sendrecv" ? "sendonly" : transceiver.direction));
}
const mediaEvent = generateCustomEvent({ type: "renegotiateTracks" });
this.sendMediaEvent(mediaEvent);
}
addTrackToConnection = (trackContext) => {
const transceiverConfig = this.createTransceiverConfig(trackContext);
const track = trackContext.track;
this.connection.addTransceiver(track, transceiverConfig);
};
createTransceiverConfig(trackContext) {
let transceiverConfig;
if (trackContext.track.kind === "audio") {
transceiverConfig = this.createAudioTransceiverConfig(trackContext);
}
else {
transceiverConfig = this.createVideoTransceiverConfig(trackContext);
}
return transceiverConfig;
}
createAudioTransceiverConfig(trackContext) {
return { direction: "sendonly", streams: trackContext.stream ? [trackContext.stream] : [] };
}
createVideoTransceiverConfig(trackContext) {
let transceiverConfig;
if (trackContext.simulcastConfig.enabled) {
transceiverConfig = simulcastTransceiverConfig;
const trackActiveEncodings = trackContext.simulcastConfig.activeEncodings;
const disabledTrackEncodings = [];
transceiverConfig.sendEncodings?.forEach((encoding) => {
if (trackActiveEncodings.includes(encoding.rid)) {
encoding.active = true;
}
else {
disabledTrackEncodings.push(encoding.rid);
}
});
this.disabledTrackEncodings.set(trackContext.trackId, disabledTrackEncodings);
}
else {
transceiverConfig = {
direction: "sendonly",
sendEncodings: [
{
active: true,
},
],
streams: trackContext.stream ? [trackContext.stream] : [],
};
}
if (trackContext.maxBandwidth && transceiverConfig.sendEncodings)
this.applyBandwidthLimitation(transceiverConfig.sendEncodings, trackContext.maxBandwidth);
return transceiverConfig;
}
applyBandwidthLimitation(encodings, max_bandwidth) {
if (typeof max_bandwidth === "number") {
// non-simulcast limitation
this.splitBandwidth(encodings, max_bandwidth * 1024);
}
else {
// simulcast bandwidth limit
encodings
.filter((encoding) => encoding.rid)
.forEach((encoding) => {
const limit = max_bandwidth.get(encoding.rid) || 0;
if (limit > 0) {
encoding.maxBitrate = limit * 1024;
}
else
delete encoding.maxBitrate;
});
}
}
splitBandwidth(encodings, bandwidth) {
if (bandwidth === 0) {
encodings.forEach((encoding) => delete encoding.maxBitrate);
return;
}
if (encodings.length == 0) {
// This most likely is a race condition. Log an error and prevent catastrophic failure
console.error("Attempted to limit bandwidth of the track that doesn't have any encodings");
return;
}
// We are solving the following equation:
// x + (k0/k1)^2 * x + (k0/k2)^2 * x + ... + (k0/kn)^2 * x = bandwidth
// where x is the bitrate for the first encoding, kn are scaleResolutionDownBy factors
// square is dictated by the fact that k0/kn is a scale factor, but we are interested in the total number of pixels in the image
const firstScaleDownBy = encodings[0].scaleResolutionDownBy || 1;
const bitrate_parts = encodings.reduce((acc, value) => acc + (firstScaleDownBy / (value.scaleResolutionDownBy || 1)) ** 2, 0);
const x = bandwidth / bitrate_parts;
encodings.forEach((value) => {
value.maxBitrate = x * (firstScaleDownBy / (value.scaleResolutionDownBy || 1)) ** 2;
});
}
/**
* Replaces a track that is being sent to the RTC Engine.
* @param trackId - Audio or video track.
* @param {string} trackId - Id of audio or video track to replace.
* @param {MediaStreamTrack} newTrack
* @param {any} [newTrackMetadata] - Optional track metadata to apply to the new track. If no
* track metadata is passed, the old track metadata is retained.
* @returns {Promise<boolean>} success
* @example
* ```ts
* // setup camera
* let localStream: MediaStream = new MediaStream();
* try {
* localVideoStream = await navigator.mediaDevices.getUserMedia(
* VIDEO_CONSTRAINTS
* );
* localVideoStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get camera permission:", error);
* }
* let oldTrackId;
* localStream
* .getTracks()
* .forEach((track) => trackId = webrtc.addTrack(track, localStream));
*
* // change camera
* const oldTrack = localStream.getVideoTracks()[0];
* let videoDeviceId = "abcd-1234";
* navigator.mediaDevices.getUserMedia({
* video: {
* ...(VIDEO_CONSTRAINTS as {}),
* deviceId: {
* exact: videoDeviceId,
* },
* }
* })
* .then((stream) => {
* let videoTrack = stream.getVideoTracks()[0];
* webrtc.replaceTrack(oldTrackId, videoTrack);
* })
* .catch((error) => {
* console.error('Error switching camera', error);
* })
* ```
*/
async replaceTrack(trackId, newTrack, newTrackMetadata) {
const resolutionNotifier = new Deferred();
try {
const newMetadata = newTrackMetadata !== undefined ? this.trackMetadataParser(newTrackMetadata) : undefined;
this.pushCommand({
commandType: "REPLACE-TRACK",
trackId,
newTrack,
newTrackMetadata: newMetadata,
resolutionNotifier,
});
}
catch (error) {
resolutionNotifier.reject(error);
}
return resolutionNotifier.promise.then(() => {
this.emit("localTrackReplaced", {
trackId,
track: newTrack,
metadata: newTrackMetadata,
});
});
}
replaceTrackHandler(command) {
const { trackId, newTrack, newTrackMetadata } = command;
const trackContext = this.localTrackIdToTrack.get(trackId);
const sender = this.findSender(trackContext.track.id);
if (sender) {
this.ongoingTrackReplacement = true;
sender
.replaceTrack(newTrack)
.then(() => {
trackContext.track = newTrack;
if (newTrackMetadata) {
this.updateTrackMetadata(trackId, newTrackMetadata);
}
})
.finally(() => {
this.resolvePreviousCommand();
this.ongoingTrackReplacement = false;
this.processNextCommand();
});
}
}
/**
* Updates maximum bandwidth for the track identified by trackId.
* This value directly translates to quality of the stream and, in case of video, to the amount of RTP packets being sent.
* In case trackId points at the simulcast track bandwidth is split between all of the variant streams proportionally to their resolution.
*
* @param {string} trackId
* @param {BandwidthLimit} bandwidth in kbps
* @returns {Promise<boolean>} success
*/
setTrackBandwidth(trackId, bandwidth) {
// FIXME: maxBandwidth in TrackContext is not updated
const trackContext = this.localTrackIdToTrack.get(trackId);
if (!trackContext) {
return Promise.reject(`Track '${trackId}' doesn't exist`);
}
const sender = this.findSender(trackContext.track.id);
const parameters = sender.getParameters();
if (parameters.encodings.length === 0) {
parameters.encodings = [{}];
}
else {
this.applyBandwidthLimitation(parameters.encodings, bandwidth);
}
return sender
.setParameters(parameters)
.then(() => {
const mediaEvent = generateCustomEvent({
type: "trackVariantBitrates",
data: {
trackId: trackId,
variantBitrates: this.getTrackBitrates(trackId),
},
});
this.sendMediaEvent(mediaEvent);
this.emit("localTrackBandwidthSet", {
trackId,
bandwidth,
});
return true;
})
.catch((_error) => false);
}
/**
* Updates maximum bandwidth for the given simulcast encoding of the given track.
*
* @param {string} trackId - id of the track
* @param {string} rid - rid of the encoding
* @param {BandwidthLimit} bandwidth - desired max bandwidth used by the encoding (in kbps)
* @returns
*/
setEncodingBandwidth(trackId, rid, bandwidth) {
const trackContext = this.localTrackIdToTrack.get(trackId);
if (!trackContext) {
return Promise.reject(`Track '${trackId}' doesn't exist`);
}
const sender = this.findSender(trackContext.track.id);
const parameters = sender.getParameters();
const encoding = parameters.encodings.find((encoding) => encoding.rid === rid);
if (!encoding) {
return Promise.reject(`Encoding with rid '${rid}' doesn't exist`);
}
else if (bandwidth === 0) {
delete encoding.maxBitrate;
}
else {
encoding.maxBitrate = bandwidth * 1024;
}
return sender
.setParameters(parameters)
.then(() => {
const mediaEvent = generateCustomEvent({
type: "trackVariantBitrates",
data: {
trackId: trackId,
variantBitrates: this.getTrackBitrates(trackId),
},
});
this.sendMediaEvent(mediaEvent);
this.emit("localTrackEncodingBandwidthSet", {
trackId,
rid,
bandwidth,
});
return true;
})
.catch((_error) => false);
}
/**
* Removes a track from connection that was sent to the RTC Engine.
* @param {string} trackId - Id of audio or video track to remove.
* @example
* ```ts
* // setup camera
* let localStream: MediaStream = new MediaStream();
* try {
* localVideoStream = await navigator.mediaDevices.getUserMedia(
* VIDEO_CONSTRAINTS
* );
* localVideoStream
* .getTracks()
* .forEach((track) => localStream.addTrack(track));
* } catch (error) {
* console.error("Couldn't get camera permission:", error);
* }
*
* let trackId
* localStream
* .getTracks()
* .forEach((track) => trackId = webrtc.addTrack(track, localStream));
*
* // remove track
* webrtc.removeTrack(trackId)
* ```
*/
removeTrack(trackId) {
const resolutionNotifier = new Deferred();
this.pushCommand({
commandType: "REMOVE-TRACK",
trackId,
resolutionNotifier,
});
return resolutionNotifier.promise.then(() => {
this.emit("localTrackRemoved", {
trackId,
});
});
}
removeTrackHandler(command) {
const { trackId } = command;
const trackContext = this.localTrackIdToTrack.get(trackId);
const sender = this.findSender(trackContext.track.id);
this.ongoingRenegotiation = true;
this.connection.removeTrack(sender);
const mediaEvent = generateCustomEvent({ type: "renegotiateTracks" });
this.sendMediaEvent(mediaEvent);
this.localTrackIdToTrack.delete(trackId);
this.localEndpoint.tracks.delete(trackId);
}
/**
* Sets track variant that server should send to the client library.
*
* The variant will be sent whenever it is available.
* If chosen variant is temporarily unavailable, some other variant
* will be sent until the chosen variant becomes active again.
*
* @param {string} trackId - id of track
* @param {TrackEncoding} variant - variant to receive
* @example
* ```ts
* webrtc.setTargetTrackEncoding(incomingTrackCtx.trackId, "l")
* ```
*/
setTargetTrackEncoding(trackId, variant) {
const trackContext = this.trackIdToTrack.get(trackId);
if (!trackContext?.simulcastConfig?.enabled || !trackContext.simulcastConfig.activeEncodings.includes(variant)) {
console.warn("The track does not support changing its target variant");
return;
}
const mediaEvent = generateCustomEvent({
type: "setTargetTrackVariant",
data: {
trackId: trackId,
variant,
},
});
this.sendMediaEvent(mediaEvent);
this.emit("targetTrackEncodingRequested", {
trackId,
variant,
});
}
/**
* Enables track encoding so that it will be sent to the server.
* @param {string} trackId - id of track
* @param {TrackEncoding} encoding - encoding that will be enabled
* @example
* ```ts
* const trackId = webrtc.addTrack(track, stream, {}, {enabled: true, activeEncodings: ["l", "m", "h"]});
* webrtc.disableTrackEncoding(trackId, "l");
* // wait some time
* webrtc.enableTrackEncoding(trackId, "l");
* ```
*/
enableTrackEncoding(trackId, encoding) {
const track = this.localTrackIdToTrack.get(trackId)?.track;
// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain
const newDisabledTrackEncodings = this.disabledTrackEncodings.get(trackId)?.filter((en) => en !== encoding);
this.disabledTrackEncodings.set(trackId, newDisabledTrackEncodings);
const sender = this.connection?.getSenders().filter((sender) => sender.track === track)[0];
const params = sender?.getParameters();
params.encodings.filter((en) => en.rid == encoding)[0].active = true;
sender?.setParameters(params);
const mediaEvent = generateMediaEvent("enableTrackEncoding", { trackId: trackId, encoding: encoding });
this.sendMediaEvent(mediaEvent);
this.emit("localTrackEncodingEnabled", {
trackId,
encoding,
});
}
/**
* Disables track encoding so that it will be no longer sent to the server.
* @param {string} trackId - id of track
* @param {rackEncoding} encoding - encoding that will be disabled
* @example
* ```ts
* const trackId = webrtc.addTrack(track, stream, {}, {enabled: true, activeEncodings: ["l", "m", "h"]});
* webrtc.disableTrackEncoding(trackId, "l");
* ```
*/
disableTrackEncoding(trackId, encoding) {
const track = this.localTrackIdToTrack.get(trackId)?.track;
this.disabledTrackEncodings.get(trackId).push(encoding);
const sender = this.connection?.getSenders().filter((sender) => sender.track === track)[0];
const params = sender?.getParameters();
params.encodings.filter((en) => en.rid == encoding)[0].active = false;
sender?.setParameters(params);
const mediaEvent = generateMediaEvent("disableTrackEncoding", { trackId: trackId, encoding: encoding });
this.sendMediaEvent(mediaEvent);
this.emit("localTrackEncodingDisabled", {
trackId,
encoding,
});
}
findSender(trackId) {
return this.connection.getSenders().find((sender) => sender.track && sender.track.id === trackId);
}
/**
* Updates the metadata for the current endpoint.
* @param metadata - Data about this endpoint that other endpoints will receive upon being added.
*
* If the metadata is different from what is already tracked in the room, the optional
* event `endpointUpdated` will be emitted for other endpoint in the room.
*/
updateEndpointMetadata = (metadata) => {
this.localEndpoint.metadata = this.endpointMetadataParser(metadata);
this.localEndpoint.rawMetadata = this.localEndpoint.metadata;
this.localEndpoint.metadataParsingError = undefined;
const mediaEvent = generateMediaEvent("updateEndpointMetadata", {
metadata: this.localEndpoint.metadata,
});
this.sendMediaEvent(mediaEvent);
this.emit("localEndpointMetadataChanged", {
metadata,
});
};
/**
* Updates the metadata for a specific track.
* @param trackId - trackId (generated in addTrack) of audio or video track.
* @param trackMetadata - Data about this track that other endpoint will receive upon being added.
*
* If the metadata is different from what is already tracked in the room, the optional
* event `trackUpdated` will be emitted for other endpoints in the room.
*/
updateTrackMetadata = (trackId, trackMetadata) => {
const trackContext = this.localTrackIdToTrack.get(trackId);
const prevTrack = this.localEndpoint.tracks.get(trackId);
try {
trackContext.metadata = this.trackMetadataParser(trackMetadata);
trackContext.rawMetadata = trackMetadata;
trackContext.metadataParsingError = undefined;
this.localEndpoint.tracks.set(trackId, trackContext);
}
catch (error) {
trackContext.metadata = undefined;
trackContext.metadataParsingError = error;
this.localEndpoint.tracks.set(trackId, { ...prevTrack, metadata: undefined, metadataParsingError: error });
throw error;
}
this.localTrackIdToTrack.set(trackId, trackContext);
const mediaEvent = generateMediaEvent("updateTrackMetadata", {
trackId,
trackMetadata: trackContext.metadata,
});
switch (trackContext.negotiationStatus) {
case "done":
this.sendMediaEvent(mediaEvent);
this.emit("localTrackMetadataChanged", {
trackId,
metadata: trackMetadata,
});
break;
case "offered":
trackContext.pendingMetadataUpdate = true;
break;
case "awaiting":
// We don't need to do anything
break;
}
};
getMidToTrackId = () => {
const localTrackMidToTrackId = {};
if (!this.connection)
return null;
this.connection.getTransceivers().forEach((transceiver) => {
const localTrackId = transceiver.sender.track?.id;
const mid = transceiver.mid;
if (localTrackId && mid) {
const trackContext = Array.from(this.localTrackIdToTrack.values()).find((trackContext) => trackContext.track.id === localTrackId);
localTrackMidToTrackId[mid] = trackContext.trackId;
}
});
return localTrackMidToTrackId;
};
/**
* Disconnects from the room. This function should be called when user disconnects from the room
* in a clean way e.g. by clicking a dedicated, custom button `disconnect`.
* As a result there will be generated one more media event that should be
* sent to the RTC Engine. Thanks to it each other endpoint will be notified
* that endpoint was removed in {@link WebRTCEndpointEvents.endpointRemoved},
*/
disconnect = () => {
const mediaEvent = generateMediaEvent("disconnect");
this.sendMediaEvent(mediaEvent);
this.emit("disconnectRequested", {});
this.cleanUp();
};
/**
* Cleans up {@link WebRTCEndpoint} instance.
*/
cleanUp = () => {
if (this.connection) {
this.connection.onicecandidate = null;
this.connection.ontrack = null;
this.connection.onconnectionstatechange = null;
this.connection.onicecandidateerror = null;
this.connection.oniceconnectionstatechange = null;
this.connection.close();
this.commandResolutionNotifier?.reject("Disconnected");
this.commandResolutionNotifier = null;
this.commandsQueue = [];
this.ongoingTrackReplacement = false;
this.ongoingRenegotiation = false;
}
this.connection = undefined;
};
getTrackId(uuid) {
return `${this.getEndpointId()}:${uuid}`;
}
sendMediaEvent = (mediaEvent) => {
const serializedMediaEvent = serializeMediaEvent(mediaEvent);
this.emit("sendMediaEvent", serializedMediaEvent);
};
onAnswer = async (answer) => {
this.connection.ontrack = this.onTrack();
try {
await this.connection.setRemoteDescription(answer);
this.disabledTrackEncodings.forEach((encodings, trackId) => {
encodings.forEach((encoding) => this.disableTrackEncoding(trackId, encoding));
});
}
catch (err) {
console.error(err);
}
};
addTransceiversIfNeeded = (serverTracks) => {
const recvTransceivers = this.connection.getTransceivers().filter((elem) => elem.direction === "recvonly");
let toAdd = [];
const getNeededTransceiversTypes = (type) => {
let typeNumber = serverTracks.get(type);
typeNumber = typeNumber !== undefined ? typeNumber : 0;
const typeTransceiversNumber = recvTransceivers.filter((elem) => elem.receiver.track.kind === type).length;
return Array(typeNumber - typeTransceiversNumber).fill(type);
};
const audio = getNeededTransceiversTypes("audio");
const video = getNeededTransceiversTypes("video");
toAdd = toAdd.concat(audio);
toAdd = toAdd.concat(video);
for (const kind of toAdd)
this.connection?.addTransceiver(kind, { direction: "recvonly" });
};
async createAndSendOffer() {
const connection = this.connection;
if (!connection)
return;
try {
const offer = await connection.createOffer();
if (!this.connection) {
console.warn("RTCPeerConnection stopped or restarted");
return;
}
await connection.setLocalDescription(offer);
if (!this.connection) {
console.warn("RTCPeerConnection stopped or restarted");
return;
}
const mediaEvent = generateCustomEvent({
type: "sdpOffer",
data: {
sdpOffer: offer,
trackIdToTrackMetadata: this.getTrackIdToMetadata(),
trackIdToTrackBitrates: this.getTrackIdToTrackBitrates(),
midToTrackId: this.getMidToTrackId(),
},
});
this.sendMediaEvent(mediaEvent);
for (const track of this.localTrackIdToTrack.values()) {
track.negotiationStatus = "offered";
}
}
catch (error) {
console.error(error);
}
}
getTrackIdToMetadata = () => {
const trackIdToMetadata = {};
Array.from(this.localEndpoint.tracks.entries()).forEach(([trackId, { metadata }]) => {
trackIdToMetadata[trackId] = metadata;
});
return trackIdToMetadata;
};
getTrackBitrates = (trackId) => {
const trackContext = this.localTrackIdToTrack.get(trackId);
if (!trackContext)
throw "Track with id ${trackId} not present in 'localTrackIdToTrack'";
const kind = trackContext.track.kind;
const sender = this.findSender(trackContext.track.id);
const encodings = sender.getParameters().encodings;
if (encodings.length == 1 && !encodings[0].rid)
return encodings[0].maxBitrate || defaultBitrates[kind];
else if (kind == "audio")
throw "Audio track cannot have multiple encodings";
const bitrates = {};
encodings
.filter((encoding) => encoding.rid)
.forEach((encoding) => {
const rid = encoding.rid;
bitrates[rid] = encoding.maxBitrate || defaultSimulcastBitrates[rid];
});
return bitrates;
};
getTrackIdToTrackBitrates = () => {
const trackIdToTrackBitrates = {};
Array.from(this.localEndpoint.tracks.entries()).forEach(([trackId, _trackEntry]) => {
trackIdToTrackBitrates[trackId] = this.getTrackBitrates(trackId);
});
return trackIdToTrackBitrates;
};
checkIfTrackBelongToEndpoint = (trackId, endpoint) => Array.from(endpoint.tracks.keys()).some((track) => trackId.startsWith(track));
onOfferData = async (offerData) => {
if (!this.connection) {
this.connection = new RTCPeerConnection(this.rtcConfig);
this.connection.onicecandidate = this.onLocalCandidate();
this.connection.onicecandidateerror = this.onIceCandidateError;
this.connection.onconnectionstatechange = this.onConnectionStateChange;
this.connection.oniceconnectionstatechange = this.onIceConnectionStateChange;
this