UNPKG

@jellyfish-dev/ts-client-sdk

Version:
1,183 lines 55.7 kB
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