react-native-webrtc
Version:
WebRTC for React Native
831 lines (647 loc) • 29.2 kB
text/typescript
import { EventTarget, Event, defineEventAttribute } from 'event-target-shim/index';
import { NativeModules } from 'react-native';
import { addListener, removeListener } from './EventEmitter';
import Logger from './Logger';
import MediaStream from './MediaStream';
import MediaStreamTrack from './MediaStreamTrack';
import MediaStreamTrackEvent from './MediaStreamTrackEvent';
import RTCDataChannel from './RTCDataChannel';
import RTCDataChannelEvent from './RTCDataChannelEvent';
import RTCIceCandidate from './RTCIceCandidate';
import RTCIceCandidateEvent from './RTCIceCandidateEvent';
import RTCRtpReceiveParameters from './RTCRtpReceiveParameters';
import RTCRtpReceiver from './RTCRtpReceiver';
import RTCRtpSendParameters from './RTCRtpSendParameters';
import RTCRtpSender from './RTCRtpSender';
import RTCRtpTransceiver from './RTCRtpTransceiver';
import RTCSessionDescription, { RTCSessionDescriptionInit } from './RTCSessionDescription';
import RTCTrackEvent from './RTCTrackEvent';
import * as RTCUtil from './RTCUtil';
const log = new Logger('pc');
const { WebRTCModule } = NativeModules;
type RTCSignalingState =
| 'stable'
| 'have-local-offer'
| 'have-remote-offer'
| 'have-local-pranswer'
| 'have-remote-pranswer'
| 'closed';
type RTCIceGatheringState = 'new' | 'gathering' | 'complete';
type RTCPeerConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
type RTCIceConnectionState = 'new' | 'checking' | 'connected' | 'completed' | 'failed' | 'disconnected' | 'closed';
type RTCDataChannelInit = {
ordered?: boolean,
maxPacketLifeTime?: number,
maxRetransmits?: number,
protocol?: string,
negotiated?: boolean,
id?: number
};
type RTCIceServer = {
credential?: string,
url?: string, // Deprecated.
urls?: string | string[],
username?: string
};
type RTCConfiguration = {
bundlePolicy?: 'balanced' | 'max-compat' | 'max-bundle',
iceCandidatePoolSize?: number,
iceServers?: RTCIceServer[],
iceTransportPolicy?: 'all' | 'relay',
rtcpMuxPolicy?: 'negotiate' | 'require'
};
type RTCPeerConnectionEventMap = {
connectionstatechange: Event<'connectionstatechange'>
icecandidate: RTCIceCandidateEvent<'icecandidate'>
icecandidateerror: RTCIceCandidateEvent<'icecandidateerror'>
iceconnectionstatechange: Event<'iceconnectionstatechange'>
icegatheringstatechange: Event<'icegatheringstatechange'>
negotiationneeded: Event<'negotiationneeded'>
signalingstatechange: Event<'signalingstatechange'>
datachannel: RTCDataChannelEvent<'datachannel'>
track: RTCTrackEvent<'track'>
error: Event<'error'>
}
let nextPeerConnectionId = 0;
export default class RTCPeerConnection extends EventTarget<RTCPeerConnectionEventMap> {
localDescription: RTCSessionDescription | null = null;
remoteDescription: RTCSessionDescription | null = null;
signalingState: RTCSignalingState = 'stable';
iceGatheringState: RTCIceGatheringState = 'new';
connectionState: RTCPeerConnectionState = 'new';
iceConnectionState: RTCIceConnectionState = 'new';
_pcId: number;
_transceivers: { order: number, transceiver: RTCRtpTransceiver }[];
_remoteStreams: Map<string, MediaStream>;
_pendingTrackEvents: any[];
constructor(configuration?: RTCConfiguration) {
super();
this._pcId = nextPeerConnectionId++;
// Sanitize ICE servers.
if (configuration) {
const servers = configuration?.iceServers ?? [];
for (const server of servers) {
let urls = server.url || server.urls;
delete server.url;
delete server.urls;
if (!urls) {
continue;
}
if (!Array.isArray(urls)) {
urls = [ urls ];
}
// Native WebRTC does case sensitive parsing.
server.urls = urls.map(url => url.toLowerCase());
}
// Filter out bogus servers.
configuration.iceServers = servers.filter(s => s.urls);
}
if (!WebRTCModule.peerConnectionInit(configuration, this._pcId)) {
throw new Error('Failed to initialize PeerConnection, check the native logs!');
}
this._transceivers = [];
this._remoteStreams = new Map();
this._pendingTrackEvents = [];
this._registerEvents();
log.debug(`${this._pcId} ctor`);
}
async createOffer(options) {
log.debug(`${this._pcId} createOffer`);
const {
sdpInfo,
newTransceivers,
transceiversInfo
} = await WebRTCModule.peerConnectionCreateOffer(this._pcId, RTCUtil.normalizeOfferOptions(options));
log.debug(`${this._pcId} createOffer OK`);
newTransceivers?.forEach(t => {
const { transceiverOrder, transceiver } = t;
const newSender = new RTCRtpSender({ ...transceiver.sender, track: null });
const remoteTrack
= transceiver.receiver.track ? new MediaStreamTrack(transceiver.receiver.track) : null;
const newReceiver = new RTCRtpReceiver({ ...transceiver.receiver, track: remoteTrack });
const newTransceiver = new RTCRtpTransceiver({
...transceiver,
sender: newSender,
receiver: newReceiver,
});
this._insertTransceiverSorted(transceiverOrder, newTransceiver);
});
this._updateTransceivers(transceiversInfo);
return sdpInfo;
}
async createAnswer() {
log.debug(`${this._pcId} createAnswer`);
const {
sdpInfo,
transceiversInfo
} = await WebRTCModule.peerConnectionCreateAnswer(this._pcId, {});
this._updateTransceivers(transceiversInfo);
return sdpInfo;
}
setConfiguration(configuration): void {
WebRTCModule.peerConnectionSetConfiguration(configuration, this._pcId);
}
async setLocalDescription(sessionDescription?: RTCSessionDescription | RTCSessionDescriptionInit): Promise<void> {
log.debug(`${this._pcId} setLocalDescription`);
let desc;
if (sessionDescription) {
desc = {
type: sessionDescription.type,
sdp: sessionDescription.sdp ?? ''
};
if (!RTCUtil.isSdpTypeValid(desc.type)) {
throw new Error(`Invalid session description: invalid type: ${desc.type}`);
}
} else {
desc = null;
}
const {
sdpInfo,
transceiversInfo
} = await WebRTCModule.peerConnectionSetLocalDescription(this._pcId, desc);
if (sdpInfo.type && sdpInfo.sdp) {
this.localDescription = new RTCSessionDescription(sdpInfo);
} else {
this.localDescription = null;
}
this._updateTransceivers(transceiversInfo, /* removeStopped */ desc?.type === 'answer');
log.debug(`${this._pcId} setLocalDescription OK`);
}
async setRemoteDescription(sessionDescription: RTCSessionDescription | RTCSessionDescriptionInit): Promise<void> {
log.debug(`${this._pcId} setRemoteDescription`);
if (!sessionDescription) {
return Promise.reject(new Error('No session description provided'));
}
const desc = {
type: sessionDescription.type,
sdp: sessionDescription.sdp ?? ''
};
if (!RTCUtil.isSdpTypeValid(desc.type ?? '')) {
throw new Error(`Invalid session description: invalid type: ${desc.type}`);
}
const {
sdpInfo,
newTransceivers,
transceiversInfo
} = await WebRTCModule.peerConnectionSetRemoteDescription(this._pcId, desc);
if (sdpInfo.type && sdpInfo.sdp) {
this.remoteDescription = new RTCSessionDescription(sdpInfo);
} else {
this.remoteDescription = null;
}
newTransceivers?.forEach(t => {
const { transceiverOrder, transceiver } = t;
const newSender = new RTCRtpSender({ ...transceiver.sender, track: null });
const remoteTrack
= transceiver.receiver.track ? new MediaStreamTrack(transceiver.receiver.track) : null;
const newReceiver = new RTCRtpReceiver({ ...transceiver.receiver, track: remoteTrack });
const newTransceiver = new RTCRtpTransceiver({
...transceiver,
sender: newSender,
receiver: newReceiver,
});
this._insertTransceiverSorted(transceiverOrder, newTransceiver);
});
this._updateTransceivers(transceiversInfo, /* removeStopped */ desc.type === 'answer');
// Fire track events. They must fire before sRD resolves.
const pendingTrackEvents = this._pendingTrackEvents;
this._pendingTrackEvents = [];
for (const ev of pendingTrackEvents) {
const [ transceiver ] = this
.getTransceivers()
.filter(t => t.receiver.id === ev.receiver.id);
// We need to fire this event for an existing track sometimes, like
// when the transceiver direction (on the sending side) switches from
// sendrecv to recvonly and then back.
// @ts-ignore
const track: MediaStreamTrack = transceiver.receiver.track;
transceiver._mid = ev.transceiver.mid;
transceiver._currentDirection = ev.transceiver.currentDirection;
transceiver._direction = ev.transceiver.direction;
// Get the stream object from the event. Create if necessary.
const streams: MediaStream[] = ev.streams.map(streamInfo => {
// Here we are making sure that we don't create stream objects that already exist
// So that event listeners do get the same object if it has been created before.
if (!this._remoteStreams.has(streamInfo.streamId)) {
const stream = new MediaStream({
streamId: streamInfo.streamId,
streamReactTag: streamInfo.streamReactTag,
tracks: []
});
this._remoteStreams.set(streamInfo.streamId, stream);
}
const stream = this._remoteStreams.get(streamInfo.streamId);
if (!stream?._tracks.includes(track)) {
stream?._tracks.push(track);
}
return stream;
});
const eventData = {
streams,
transceiver,
track,
receiver: transceiver.receiver
};
this.dispatchEvent(new RTCTrackEvent('track', eventData));
streams.forEach(stream => {
stream.dispatchEvent(new MediaStreamTrackEvent('addtrack', { track }));
});
// Dispatch an unmute event for the track.
track._setMutedInternal(false);
}
log.debug(`${this._pcId} setRemoteDescription OK`);
}
async addIceCandidate(candidate): Promise<void> {
log.debug(`${this._pcId} addIceCandidate`);
if (!candidate || !candidate.candidate) {
// XXX end-of candidates is not implemented: https://bugs.chromium.org/p/webrtc/issues/detail?id=9218
return;
}
if (
candidate.sdpMLineIndex === null ||
candidate.sdpMLineIndex === undefined ||
candidate.sdpMid === null ||
candidate.sdpMid === undefined
) {
throw new TypeError('`sdpMLineIndex` and `sdpMid` must not null or undefined');
}
const newSdp = await WebRTCModule.peerConnectionAddICECandidate(
this._pcId,
candidate.toJSON ? candidate.toJSON() : candidate
);
this.remoteDescription = new RTCSessionDescription(newSdp);
}
/**
* @brief Adds a new track to the {@link RTCPeerConnection},
* and indicates that it is contained in the specified {@link MediaStream}s.
* This method has to be synchronous as the W3C API expects a track to be returned
* @param {MediaStreamTrack} track The track to be added
* @param {...MediaStream} streams One or more {@link MediaStream}s the track needs to be added to
* https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-addtrack
*/
addTrack(track: MediaStreamTrack, ...streams: MediaStream[]): RTCRtpSender {
log.debug(`${this._pcId} addTrack`);
if (this.connectionState === 'closed') {
throw new Error('Peer Connection is closed');
}
if (this._trackExists(track)) {
throw new Error('Track already exists in a sender');
}
const streamIds = streams.map(s => s.id);
const result = WebRTCModule.peerConnectionAddTrack(this._pcId, track.id, { streamIds });
if (result === null) {
throw new Error('Could not add sender');
}
const { transceiverOrder, transceiver, sender } = result;
// According to the W3C docs, the sender could have been reused, and
// so we check if that is the case, and update accordingly.
const [ existingSender ] = this
.getSenders()
.filter(s => s.id === sender.id);
if (existingSender) {
// Update sender
existingSender._track = track;
// Update the corresponding transceiver as well
const [ existingTransceiver ] = this
.getTransceivers()
.filter(t => t.sender.id === existingSender.id);
existingTransceiver._direction = transceiver.direction;
existingTransceiver._currentDirection = transceiver.currentDirection;
return existingSender;
}
// This is a new transceiver, should create a transceiver for it and add it
const newSender = new RTCRtpSender({ ...transceiver.sender, track });
const remoteTrack = transceiver.receiver.track ? new MediaStreamTrack(transceiver.receiver.track) : null;
const newReceiver = new RTCRtpReceiver({ ...transceiver.receiver, track: remoteTrack });
const newTransceiver = new RTCRtpTransceiver({
...transceiver,
sender: newSender,
receiver: newReceiver,
});
this._insertTransceiverSorted(transceiverOrder, newTransceiver);
return newSender;
}
addTransceiver(source: 'audio' | 'video' | MediaStreamTrack, init): RTCRtpTransceiver {
log.debug(`${this._pcId} addTransceiver`);
let src = {};
if (source === 'audio') {
src = { type: 'audio' };
} else if (source === 'video') {
src = { type: 'video' };
} else {
src = { trackId: source.id };
}
// Extract the stream ids
if (init && init.streams) {
init.streamIds = init.streams.map(stream => stream.id);
}
const result = WebRTCModule.peerConnectionAddTransceiver(this._pcId, { ...src, init: { ...init } });
if (result === null) {
throw new Error('Transceiver could not be added');
}
const t = result.transceiver;
let track: MediaStreamTrack | null = null;
if (typeof source === 'string') {
if (t.sender.track) {
track = new MediaStreamTrack(t.sender.track);
}
} else {
// 'source' is a MediaStreamTrack
track = source;
}
const sender = new RTCRtpSender({ ...t.sender, track });
const remoteTrack = t.receiver.track ? new MediaStreamTrack(t.receiver.track) : null;
const receiver = new RTCRtpReceiver({ ...t.receiver, track: remoteTrack });
const transceiver = new RTCRtpTransceiver({
...result.transceiver,
sender,
receiver
});
this._insertTransceiverSorted(result.transceiverOrder, transceiver);
return transceiver;
}
removeTrack(sender: RTCRtpSender) {
log.debug(`${this._pcId} removeTrack`);
if (this._pcId !== sender._peerConnectionId) {
throw new Error('Sender does not belong to this peer connection');
}
if (this.connectionState === 'closed') {
throw new Error('Peer Connection is closed');
}
const existingSender = this
.getSenders()
.find(s => s === sender);
if (!existingSender) {
throw new Error('Sender does not exist');
}
if (existingSender.track === null) {
return;
}
// Blocking!
WebRTCModule.peerConnectionRemoveTrack(this._pcId, sender.id);
existingSender._track = null;
const [ existingTransceiver ] = this
.getTransceivers()
.filter(t => t.sender.id === existingSender.id);
existingTransceiver._direction = existingTransceiver.direction === 'sendrecv' ? 'recvonly' : 'inactive';
}
async getStats(selector?: MediaStreamTrack) {
log.debug(`${this._pcId} getStats`);
if (!selector) {
const data = await WebRTCModule.peerConnectionGetStats(this._pcId);
/**
* On both Android and iOS it is faster to construct a single
* JSON string representing the Map of StatsReports and have it
* pass through the React Native bridge rather than the Map of
* StatsReports. While the implementations do try to be faster in
* general, the stress is on being faster to pass through the React
* Native bridge which is a bottleneck that tends to be visible in
* the UI when there is congestion involving UI-related passing.
*/
return new Map(JSON.parse(data));
} else {
const senders = this.getSenders().filter(s => s.track === selector);
const receivers = this.getReceivers().filter(r => r.track === selector);
const matches = senders.length + receivers.length;
if (matches === 0) {
throw new Error('Invalid selector: could not find matching sender / receiver');
} else if (matches > 1) {
throw new Error('Invalid selector: multiple matching senders / receivers');
} else {
const sr = senders[0] || receivers[0];
return sr.getStats();
}
}
}
getTransceivers(): RTCRtpTransceiver[] {
return this._transceivers.map(e => e.transceiver);
}
getSenders(): RTCRtpSender[] {
// @ts-ignore
return this._transceivers.map(e => !e.transceiver.stopped && e.transceiver.sender).filter(Boolean);
}
getReceivers(): RTCRtpReceiver[] {
// @ts-ignore
return this._transceivers.map(e => !e.transceiver.stopped && e.transceiver.receiver).filter(Boolean);
}
close(): void {
log.debug(`${this._pcId} close`);
if (this.connectionState === 'closed') {
return;
}
WebRTCModule.peerConnectionClose(this._pcId);
// Mark transceivers as stopped.
this._transceivers.forEach(({ transceiver })=> {
transceiver._setStopped();
});
}
restartIce(): void {
WebRTCModule.peerConnectionRestartIce(this._pcId);
}
_registerEvents(): void {
addListener(this, 'peerConnectionOnRenegotiationNeeded', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
this.dispatchEvent(new Event('negotiationneeded'));
});
addListener(this, 'peerConnectionIceConnectionChanged', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
this.iceConnectionState = ev.iceConnectionState;
this.dispatchEvent(new Event('iceconnectionstatechange'));
});
addListener(this, 'peerConnectionStateChanged', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
this.connectionState = ev.connectionState;
this.dispatchEvent(new Event('connectionstatechange'));
if (ev.connectionState === 'closed') {
// This PeerConnection is done, clean up.
removeListener(this);
WebRTCModule.peerConnectionDispose(this._pcId);
}
});
addListener(this, 'peerConnectionSignalingStateChanged', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
this.signalingState = ev.signalingState;
this.dispatchEvent(new Event('signalingstatechange'));
});
// Consider moving away from this event: https://github.com/WebKit/WebKit/pull/3953
addListener(this, 'peerConnectionOnTrack', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
log.debug(`${this._pcId} ontrack`);
// NOTE: We need to make sure the track event fires right before sRD completes,
// so we queue them up here and dispatch the events when sRD fires, but before completing it.
// In the future we should probably implement out own logic and drop this event altogether.
this._pendingTrackEvents.push(ev);
});
addListener(this, 'peerConnectionOnRemoveTrack', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
log.debug(`${this._pcId} onremovetrack ${ev.receiverId}`);
const receiver = this.getReceivers().find(r => r.id === ev.receiverId);
const track = receiver?.track;
if (receiver && track) {
// As per the spec:
// - Remove the track from any media streams that were previously passed to the `track` event.
// https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-removetrack,
// - Mark the track as muted:
// https://w3c.github.io/webrtc-pc/#process-remote-track-removal
for (const stream of this._remoteStreams.values()) {
if (stream._tracks.includes(track)) {
const trackIdx = stream._tracks.indexOf(track);
log.debug(`${this._pcId} removetrack ${track.id}`);
stream._tracks.splice(trackIdx, 1);
stream.dispatchEvent(new MediaStreamTrackEvent('removetrack', { track }));
// Dispatch a mute event for the track.
track._setMutedInternal(true);
}
}
}
});
addListener(this, 'peerConnectionGotICECandidate', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
const sdpInfo = ev.sdp;
// Can happen when doing a rollback.
if (sdpInfo.type && sdpInfo.sdp) {
this.localDescription = new RTCSessionDescription(sdpInfo);
} else {
this.localDescription = null;
}
const candidate = new RTCIceCandidate(ev.candidate);
this.dispatchEvent(new RTCIceCandidateEvent('icecandidate', { candidate }));
});
addListener(this, 'peerConnectionIceGatheringChanged', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
this.iceGatheringState = ev.iceGatheringState;
if (this.iceGatheringState === 'complete') {
const sdpInfo = ev.sdp;
// Can happen when doing a rollback.
if (sdpInfo.type && sdpInfo.sdp) {
this.localDescription = new RTCSessionDescription(sdpInfo);
} else {
this.localDescription = null;
}
this.dispatchEvent(new RTCIceCandidateEvent('icecandidate', { candidate: null }));
}
this.dispatchEvent(new Event('icegatheringstatechange'));
});
addListener(this, 'peerConnectionDidOpenDataChannel', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
const channel = new RTCDataChannel(ev.dataChannel);
this.dispatchEvent(new RTCDataChannelEvent('datachannel', { channel }));
// Send 'open' event. Native doesn't update the state since it's already
// set at this point.
channel.dispatchEvent(new RTCDataChannelEvent('open', { channel }));
});
addListener(this, 'mediaStreamTrackMuteChanged', (ev: any) => {
if (ev.pcId !== this._pcId) {
return;
}
const [
track
] = this.getReceivers().map(r => r.track).filter(t => t?.id === ev.trackId);
if (track) {
track._setMutedInternal(ev.muted);
}
});
}
/**
* Creates a new RTCDataChannel object with the given label. The
* RTCDataChannelInit dictionary can be used to configure properties of the
* underlying channel such as data reliability.
*
* @param {string} label - the value with which the label attribute of the new
* instance is to be initialized
* @param {RTCDataChannelInit} dataChannelDict - an optional dictionary of
* values with which to initialize corresponding attributes of the new
* instance such as id
*/
createDataChannel(label: string, dataChannelDict?: RTCDataChannelInit): RTCDataChannel {
if (dataChannelDict && 'id' in dataChannelDict) {
const id = dataChannelDict.id;
if (typeof id !== 'number') {
throw new TypeError('DataChannel id must be a number: ' + id);
}
}
const channelInfo = WebRTCModule.createDataChannel(this._pcId, label, dataChannelDict);
if (channelInfo === null) {
throw new TypeError('Failed to create new DataChannel');
}
return new RTCDataChannel(channelInfo);
}
/**
* Check whether a media stream track exists already in a sender.
* See https://w3c.github.io/webrtc-pc/#dom-rtcpeerconnection-addtrack for more information
*/
_trackExists(track: MediaStreamTrack): boolean {
const [ sender ] = this
.getSenders()
.filter(
sender => sender.track?.id === track.id
);
return sender? true : false;
}
/**
* Updates transceivers after offer/answer updates if necessary.
*/
_updateTransceivers(transceiverUpdates, removeStopped = false) {
for (const update of transceiverUpdates) {
const [ transceiver ] = this
.getTransceivers()
.filter(t => t.sender.id === update.transceiverId);
if (!transceiver) {
continue;
}
if (update.currentDirection) {
transceiver._currentDirection = update.currentDirection;
}
transceiver._mid = update.mid;
transceiver._stopped = Boolean(update.isStopped);
transceiver._sender._rtpParameters = new RTCRtpSendParameters(update.senderRtpParameters);
transceiver._receiver._rtpParameters = new RTCRtpReceiveParameters(update.receiverRtpParameters);
}
if (removeStopped) {
const stopped = this.getTransceivers().filter(t => t.stopped);
const newTransceivers = this._transceivers.filter(t => !stopped.includes(t.transceiver));
this._transceivers = newTransceivers;
}
}
/**
* Inserts transceiver into the transceiver array in the order they are created (timestamp).
* @param order an index that refers to when it it was created relatively.
* @param transceiver the transceiver object to be inserted.
*/
_insertTransceiverSorted(order: number, transceiver: RTCRtpTransceiver) {
this._transceivers.push({ order, transceiver });
this._transceivers.sort((a, b) => a.order - b.order);
}
}
/**
* Define the `onxxx` event handlers.
*/
const proto = RTCPeerConnection.prototype;
defineEventAttribute(proto, 'connectionstatechange');
defineEventAttribute(proto, 'icecandidate');
defineEventAttribute(proto, 'icecandidateerror');
defineEventAttribute(proto, 'iceconnectionstatechange');
defineEventAttribute(proto, 'icegatheringstatechange');
defineEventAttribute(proto, 'negotiationneeded');
defineEventAttribute(proto, 'signalingstatechange');
defineEventAttribute(proto, 'datachannel');
defineEventAttribute(proto, 'track');
defineEventAttribute(proto, 'error');