Page MenuHomePhabricator

No OneTemporary

diff --git a/API.md b/API.md
index 0be6628..e03f79f 100644
--- a/API.md
+++ b/API.md
@@ -1,466 +1,475 @@
## API
The entrypoint to the library is the `sylkrtc` object. Several objects (`Connection`, `Account` and `Call`) inherit from Node's `EventEmitter` class, you may want to check [its documentation](https://nodejs.org/api/events.html).
### sylkrtc
The main entrypoint to the library. It exposes the main function to connect to SylkServer and some utility functions for general use.
#### sylkrtc.createConnection(options={})
Creates a `sylkrtc` connection towards a SylkServer instance. The only supported option (at the moment) is "server", which should point to the WebSocket endpoint of the WebRTC gateway application. Example: `wss://1.2.3.4:8088/webrtcgateway/ws`.
It returns a `Connection` object.
Example:
let connection = sylkrtc.createConnection({server: 'wss://1.2.3.4:8088/webrtcgateway/ws'});
#### sylkrtc.utils
Helper module with utility functions.
* `attachMediaStream`: function to easily attach a media stream to an element. It reexports [attachmediastream](https://github.com/otalk/attachMediaStream).
* `closeMediaStream`: function to close the given media stream.
### Connection
Object representing the interaction with SylkServer. Multiple connections can be created with
`sylkrtc.createConnection`, but typically only one is needed. Reconnecting in case the connection is interrupted is
taken care of automatically.
Events emitted:
* **stateChanged**: indicates the WebSocket connection state has changed. Two arguments are provided: `oldState` and
`newState`, the old connection state and the new connection state, respectively. Possible state values are: null,
connecting, connected, ready, disconnected and closed. If the connection is involuntarily interrupted the state will
transition to disconnected and the connection will be retried. Once the closed state is set, as a result of the user
calling Connection.close(), the connection can no longer be used or reconnected.
#### Connection.addAccount(options={}, cb=null)
Configures an `Account` to be used through `sylkrtc`. 2 options are required: *account* (the account ID) and
*password*. An optional *displayName* can be set. The account won't be registered, it will just be created.
Optionally *realm* can be passed, which will be used instead of the domain for the HA1 calculation.
The *password* won't be stored or transmitted as given, the HA1 hash (as used in
[Digest access authentication](https://en.wikipedia.org/wiki/Digest_access_authentication)) is created and used instead.
The `cb` argument is a callback which will be called with an error and the account object
itself.
Example:
connection.addAccount({account: saghul@sip2sip.info, password: 1234}, function(error, account) {
if (error) {
console.log('Error adding account!' + account);
} else {
console.log('Account added!');
}
});
#### Connection.removeAccount(account, cb=null)
Removes the given account. The callback will be called once the operation completes (it
cannot fail). The callback will be called with an error object.
Example:
connection.removeAccount(account, function(error) {
console('Account removed!');
});
#### Connection.reconnect()
Starts reconnecting immediately if the state was 'disconnected';
#### Connection.close()
Close the connection with SylkServer. All accounts will be unbound.
#### Connection.state
Getter property returning the current connection state.
### Account
Object representing a SIP account which will be used for making / receiving calls.
Events emitted:
* **registrationStateChanged**: indicates the SIP registration state has changed. Three arguments are provided:
`oldState`, `newState` and `data`. `oldState` and `newState` represent the old registration state and the new
registration state, respectively, and `data` is a generic per-state data object. Possible states:
* null: registration hasn't started or it has ended
* registering: registration is in progress
* registered
* failed: registration failed, the `data` object will contain a 'reason' property.
* **outgoingCall**: emitted when an outgoing call is made. A single argument is provided: the `Call` object.
* **incomingCall**: emitted when an incoming call is received. Two arguments are provided: the `Call` object and a
`mediaTypes` object, which has 2 boolean properties: `audio` and `video`, indicating if those media types were
present in the initial SDP.
* **missedCall**: emitted when an incoming call is missed. A `data` object is provided, which contains an `originator`
attribute, which is an `Identity` object.
* **conferenceInvite**: emitted when someone invites us to join a conference. A `data` object is provided, which contains
an `originator` attribute indicating who invited us, and a `room` attribute indicating what conference we have been invited to.
#### Account.register()
Start the SIP registration process for the account. Progress will be reported via the
*registrationStateChanged* event.
Note: it's not necessary to be registered to make an outgoing call.
#### Account.unregister()
Unregister the account. Progress will be reported via the
*registrationStateChanged* event.
#### Account.call(uri, options={})
Start an outgoing call. Supported options:
* pcConfig: configuration options for `RTCPeerConnection`. [Reference](http://w3c.github.io/webrtc-pc/#configuration).
* offerOptions: `RTCOfferOptions`. [Reference](http://w3c.github.io/webrtc-pc/#idl-def-RTCOfferOptions).
* localStream: user provided local media stream (acquired with `getUserMedia` TODO).
Example:
const call = account.call('3333@sip2sip.info', {localStream: stream});
#### Account.joinConference(uri, options={})
Join (or create in case it doesn't exist) a multi-party video conference at the given URI. Supported options:
* pcConfig: configuration options for `RTCPeerConnection`. [Reference](http://w3c.github.io/webrtc-pc/#configuration).
* offerOptions: `RTCOfferOptions`. [Reference](http://w3c.github.io/webrtc-pc/#idl-def-RTCOfferOptions).
* localStream: user provided local media stream (acquired with `getUserMedia` TODO).
Example:
const conf = account.joinConference('test123@conference.sip2sip.info', {localStream: stream});
#### Account.id
Getter property returning the account ID.
#### Account.displayName
Getter property returning the account display name.
#### Account.password
Getter property returning the HA1 password for the account.
#### Account.registrationState
Getter property returning the current registration state.
#### Account.setDeviceToken(oldToken, newToken)
Set the current device token for this account. The device token is an opaque string usually provided by the Firebase SDK
which SylkServer can use to send push notifications.
### Call
Object representing a audio/video call. Signalling is done using SIP underneath.
Events emitted:
* **localStreamAdded**: emitted when the local stream is added to the call. A single argument is provided: the stream itself.
* **streamAdded**: emitted when a remote stream is added to the call. A single argument is provided: the stream itself.
* **stateChanged**: indicates the call state has changed. Three arguments are provided: `oldState`, `newState` and
`data`. `oldState` and `newState` indicate the previous and current state respectively, and `data` is a generic
per-state data object. Possible states:
* terminated: the call has ended (the `data` object contains a `reason` attribute)
* accepted: the call has been accepted (either locally or remotely)
* incoming: initial state for incoming calls
* progress: initial state for outgoing calls
* established: call media has been established
* **dtmfToneSent**: emitted when one of the tones passed to `sendDtmf` is actually sent. An empty tone indicates all tones have
finished playing.
#### Call.answer(options={})
Answer an incoming call. Supported options:
* pcConfig: configuration options for `RTCPeerConnection`. [Reference](http://w3c.github.io/webrtc-pc/#configuration).
* answerOptions: `RTCAnswerOptions`. [Reference](http://w3c.github.io/webrtc-pc/#idl-def-RTCAnswerOptions).
* localStream: user provided local media stream (acquired with `getUserMedia` TODO).
+### Call.replaceTrack(oldTrack, newTrack, keep=false)
+
+Replace a local track inside a call. If the keep flag is set, it will store the replaced track internally so it
+can be used later.
#### Call.terminate()
End the call.
#### Call.getLocalStreams()
Returns an array of *local* `RTCMediaStream` objects.
#### Call.getRemoteStreams()
Returns an array of *remote* `RTCMediaStream` objects.
#### Call.getSenders()
Returns an array of `RTCRtpSender` objects.
#### Call.getReceivers()
Returns an array of `RTCRtpReceiver` objects.
#### Call.sendDtmf(tones, duration=100, interToneGap=70)
Sends the given DTMF tones over the active audio stream track.
**Note**: This feature requires browser support for `RTCPeerConnection.createDTMFSender`.
#### Call.account
Getter property which returns the `Account` object associated with this call.
#### Call.id
Getter property which returns the ID for this call. Note: this is not related to the SIP Call-ID header.
#### Call.direction
Getter property which returns the call direction: "incoming" or "outgoing". Note: this is not related to the SDP
"a=" direction attribute.
#### Call.state
Getter property which returns the call state.
#### Call.localIdentity
Getter property which returns the local identity. (See the `Identity` object).
#### Call.remoteIdentity
Getter property which returns the remote identity. (See the `Identity` object).
#### Call.remoteMediaDirections
Getter property which returns an object with the directions of the remote streams. Note: this **is** related to the SDP "a=" direction attribute.
### Conference
Object representing a multi-party audio/video conference.
Events emitted:
* **localStreamAdded**: emitted when the local stream is added to the call. A single argument is provided: the stream itself.
* **stateChanged**: indicates the conference state has changed. Three arguments are provided: `oldState`, `newState` and
`data`. `oldState` and `newState` indicate the previous and current state respectively, and `data` is a generic
per-state data object. Possible states:
* terminated: the conference has ended
* accepted: the initial offer has been accepted
* progress: initial state
* established: conference has been established and media is flowing
* **participantJoined**: emitted when a participant joined the conference. A single argument is provided: an instance of
`Participant`. Note that this event is only emitted when new participants join, `Conference.participants` should be checked
upon the initial join to check what participants are already in the conference.
* **participantLeft**: emitted when a participant leaves the conference. A single argument is provided: an instance of
`Participant`.
* **roomConfigured**: emitted when the room is configured by the server. A single argument is provided: an object with the
`originator` of the message which is an `Identity` or string and a list of `activeParticipants`. The list contains
instances of `Participant`.
+### Conference.replaceTrack(oldTrack, newTrack, keep=false)
+
+Replace a local track inside the conference. If the keep flag is set, it will store the replaced track internally so it
+can be used later.
+
#### Conference.getLocalStreams()
Returns an array of *local* `RTCMediaStream` objects. These are the streams being published to the conference.
#### Conference.getRemoteStreams()
Returns an array of *remote* `RTCMediaStream` objects. These are the streams published by all other participants in the conference.
#### Conference.getSenders()
Returns an array of `RTCRtpSender` objects. The sender objects get the *local* tracks being published to the conference.
#### Conference.getReceivers()
Returns an array of `RTCRtpReceiver` objects. The receiver objects get the *remote* tracks published by all other
participants in the conference.
#### Conference.scaleLocalTrack(track, divider)
Scale the given local video track by a given divider. Currently this function will not work, since browser support is lacking.
#### Conference.configureRoom(participants, cb=null)
Configure the room. `Participants` is a list with the publisher session ids of the new active participants. The active participants
will get more bandwidth and the other participants will get a limited bandwidth. On success the *roomConfigured* event is emitted.
The `cb` argument is a callback which will be called on an error with error as argument.
#### Conference.participants
Getter property which returns an array of `Participant` objects in the conference.
#### Conference.activeParticipants
Getter property for the Active Participants which returns an array of `Participant` objects in the conference.
#### Conference.account
Getter property which returns the `Account` object associated with this conference.
#### Conference.id
Getter property which returns the ID for this conference. Note: this is not related to the URI.
#### Conference.direction
Dummy property always returning "outgoing", in order to provide the same API as `Call`.
#### Conference.state
Getter property which returns the conference state.
#### Conference.localIdentity
Getter property which returns the local identity. (See the `Identity` object). This will always be built from the account.
#### Conference.remoteIdentity
Getter property which returns the remote identity. (See the `Identity` object). This will always be built from the remote URI.
### Participant
Object representing another user connected to the same conference.
Events emitted:
* **streamAdded**: emitted when a remote stream is added. A single argument is provided: the stream itself.
* **stateChanged**: indicates the participant state has changed. Three arguments are provided: `oldState`, `newState` and
`data`. `oldState` and `newState` indicate the previous and current state respectively, and `data` is a generic
per-state data object. Possible states:
* null: initial state
* progress: the participant is being attached to, this will happen as a result to `Participant.attach`
* established: media is flowing from this participant
#### Participant.id
Getter property which returns the ID for this participant. Note this an abstract ID.
#### Participant.state
Getter property which returns the participant state.
#### Participant.identity
Getter property which returns the participant's identity. (See the `Identity` object).
#### Participant.publisherId
Getter property which returns the participant's publisher session id.
#### Participant.streams
Getter property which returns the audio / video streams for this participant.
#### Participant.videoPaused
Getter property which returns true / false when the video subscription is paused / not paused
#### Participant.getReceivers()
Returns an array of `RTCRtpReceiver` objects. The receiver objects get the *remote* tracks published by the
participant.
#### Participant.attach()
Start receiving audio / video from this participant. Once attached the participant's state will switch to 'established'
and its audio /video stream(s) will be available in `Participant.streams`. If a participant is not attached to, no
audio or video will be received from them.
#### Participant.detach(isRemoved=false)
Stop receiving audio / video from this participant. The opposite of `Participant.attach()`. The isRemoved
option needs to be true used when the participant has already left. This is the case when you receive the
'participantLeft' event.
#### Participant.pauseVideo()
Stop receiving video from this participant. The opposite of `Participant.resumeVideo()`.
#### Participant.resumeVideo()
Resume receiving video from this participant. The opposite of `Participant.pauseVideo()`.
### Identity
Object representing the identity of the caller / callee.
#### Identity.uri
SIP URI, without the 'sip:' prefix.
#### Identity.displayName
Display name assiciated with the identity. Set to '' if absent.
#### Identity.toString()
Function returning a string representation of the identity. It can take 2 forms
depending on the availability of the display name: 'bob@biloxi.com' or
'Bob <bob@biloxi.com>'.
diff --git a/lib/call.js b/lib/call.js
index 53178ba..43cedb5 100644
--- a/lib/call.js
+++ b/lib/call.js
@@ -1,446 +1,481 @@
'use strict';
import debug from 'debug';
import uuidv4 from 'uuid/v4';
import utils from './utils';
import { EventEmitter } from 'events';
const DEBUG = debug('sylkrtc:Call');
class Call extends EventEmitter {
constructor(account) {
super();
this._account = account;
this._id = null;
this._direction = null;
this._pc = null;
this._state = null;
this._terminated = false;
this._incomingSdp = null;
this._remoteMediaDirections = {};
this._localIdentity = new utils.Identity(account.id, account.displayName);
this._remoteIdentity = null;
this._remoteStreams = new MediaStream();
this._localStreams = new MediaStream();
+ this._previousTrack = null;
this._dtmfSender = null;
this._delay_established = false; // set to true when we need to delay posting the state change to 'established'
this._setup_in_progress = false; // set while we set the remote description and setup the peer copnnection
// bind some handlers to this instance
this._onDtmf = this._onDtmf.bind(this);
}
get account() {
return this._account;
}
get id() {
return this._id;
}
get direction() {
return this._direction;
}
get state() {
return this._state;
}
get localIdentity() {
return this._localIdentity;
}
get remoteIdentity() {
return this._remoteIdentity;
}
get remoteMediaDirections() {
return this._remoteMediaDirections;
}
getLocalStreams() {
if (this._pc !== null) {
if (this._pc.getSenders) {
this._pc.getSenders().forEach((e) => {
if (e.track != null) {
if (e.track.readyState !== "ended") {
this._localStreams.addTrack(e.track);
} else {
this._localStreams.removeTrack(e.track);
}
}
});
return [this._localStreams];
} else {
return this._pc.getLocalStreams();
}
} else {
return [];
}
}
getRemoteStreams() {
if (this._pc !== null) {
if (this._pc.getReceivers) {
this._pc.getReceivers().forEach((e) => {
if (e.track.readyState !== "ended") {
this._remoteStreams.addTrack(e.track);
}
});
return [this._remoteStreams];
} else {
return this._pc.getRemoteStreams();
}
} else {
return [];
}
}
getSenders() {
if (this._pc !== null) {
return this._pc.getSenders();
} else {
return [];
}
}
getReceivers() {
if (this._pc !== null) {
return this._pc.getReceivers();
} else {
return [];
}
}
answer(options = {}) {
if (this._state !== 'incoming') {
throw new Error('Call is not in the incoming state: ' + this._state);
}
if (!options.localStream) {
throw new Error('Missing localStream');
}
const pcConfig = options.pcConfig || {iceServers:[]};
const answerOptions = options.answerOptions;
// Create the RTCPeerConnection
this._initRTCPeerConnection(pcConfig);
this._pc.addStream(options.localStream);
this.emit('localStreamAdded', options.localStream);
this._pc.setRemoteDescription(
new RTCSessionDescription({type: 'offer', sdp: this._incomingSdp}),
// success
() => {
utils.createLocalSdp(this._pc, 'answer', answerOptions)
.then((sdp) => {
DEBUG('Local SDP: %s', sdp);
this._sendAnswer(sdp);
})
.catch((reason) => {
DEBUG(reason);
this.terminate();
});
},
// failure
(error) => {
DEBUG('Error setting remote description: %s', error);
this.terminate();
}
);
}
+ replaceTrack(oldTrack, newTrack, keep=false) {
+ let sender;
+ for (sender of this._pc.getSenders()) {
+ if (sender.track === oldTrack) {
+ break;
+ }
+ }
+
+ sender.replaceTrack(newTrack)
+ .then(() => {
+ if (keep) {
+ this._previousTrack = oldTrack;
+ } else {
+ if (oldTrack) {
+ oldTrack.stop();
+ }
+ if (newTrack === this._previousTrack) {
+ this._previousTrack = null;
+ }
+ }
+
+ if (oldTrack) {
+ this._localStreams.removeTrack(oldTrack);
+ }
+ this._localStreams.addTrack(newTrack);
+ });
+ }
+
terminate() {
if (this._terminated) {
return;
}
DEBUG('Terminating call');
this._sendTerminate();
}
sendDtmf(tones, duration=100, interToneGap=70) {
DEBUG('sendDtmf()');
if (this._dtmfSender === null) {
if (this._pc !== null) {
let track = null;
try {
track = this._pc.getLocalStreams()[0].getAudioTracks()[0];
} catch (e) {
// ignore
}
if (track !== null) {
DEBUG('Creating DTMF sender');
this._dtmfSender = this._pc.createDTMFSender(track);
if (this._dtmfSender) {
this._dtmfSender.addEventListener('tonechange', this._onDtmf);
}
}
}
}
if (this._dtmfSender) {
DEBUG('Sending DTMF tones');
this._dtmfSender.insertDTMF(tones, duration, interToneGap);
}
}
// Private API
_initOutgoing(uri, options={}) {
if (uri.indexOf('@') === -1) {
throw new Error('Invalid URI');
}
if (!options.localStream) {
throw new Error('Missing localStream');
}
this._id = uuidv4();
this._direction = 'outgoing';
this._remoteIdentity = new utils.Identity(uri);
const pcConfig = options.pcConfig || {iceServers:[]};
const offerOptions = options.offerOptions;
// Create the RTCPeerConnection
this._initRTCPeerConnection(pcConfig);
this._pc.addStream(options.localStream);
this.emit('localStreamAdded', options.localStream);
utils.createLocalSdp(this._pc, 'offer', offerOptions)
.then((sdp) => {
DEBUG('Local SDP: %s', sdp);
this._sendCall(uri, sdp);
})
.catch((reason) => {
DEBUG(reason);
this._localTerminate(reason);
});
}
_initIncoming(id, caller, sdp) {
this._id = id;
this._remoteIdentity = new utils.Identity(caller.uri, caller.display_name);
this._incomingSdp = sdp;
this._direction = 'incoming';
this._state = 'incoming';
this._remoteMediaDirections = Object.assign(
{audio: [], video:[]}, utils.getMediaDirections(sdp)
);
DEBUG('Remote SDP: %s', sdp);
}
_handleEvent(message) {
DEBUG('Call event: %o', message);
switch (message.event) {
case 'state':
let oldState = this._state;
let newState = message.state;
this._state = newState;
if (newState === 'accepted' && this._direction === 'outgoing') {
DEBUG('Call accepted');
this.emit('stateChanged', oldState, newState, {});
const sdp = utils.mungeSdp(message.sdp);
DEBUG('Remote SDP: %s', sdp);
this._remoteMediaDirections = Object.assign(
{audio: [], video:[]}, utils.getMediaDirections(sdp)
);
this._setup_in_progress = true;
this._pc.setRemoteDescription(
new RTCSessionDescription({type: 'answer', sdp: sdp}),
// success
() => {
this._setup_in_progress = false;
if (!this._terminated) {
if (this._delay_established) {
oldState = this._state;
this._state = 'established';
DEBUG('Setting delayed established state!');
this.emit('stateChanged', oldState, this._state, {});
this._delay_established = false;
}
}
},
// failure
(error) => {
DEBUG('Error accepting call: %s', error);
this.terminate();
}
);
} else if (newState === 'established' && this._direction === 'outgoing') {
if (this._setup_in_progress) {
this._delay_established = true;
} else {
this.emit('stateChanged', oldState, newState, {});
}
} else if (newState === 'terminated') {
this.emit('stateChanged', oldState, newState, {reason: message.reason});
this._terminated = true;
this._account._calls.delete(this.id);
this._closeRTCPeerConnection();
} else {
this.emit('stateChanged', oldState, newState, {});
}
break;
default:
break;
}
}
_initRTCPeerConnection(pcConfig) {
if (this._pc !== null) {
throw new Error('RTCPeerConnection already initialized');
}
this._pc = new RTCPeerConnection(pcConfig);
this._pc.addEventListener('addstream', (event) => {
DEBUG('Stream added');
this.emit('streamAdded', event.stream);
});
this._pc.addEventListener('icecandidate', (event) => {
if (event.candidate !== null) {
DEBUG('New ICE candidate %o', event.candidate);
} else {
DEBUG('ICE candidate gathering finished');
}
this._sendTrickle(event.candidate);
});
}
_sendRequest(req, cb) {
this._account._sendRequest(req, cb);
}
_sendCall(uri, sdp) {
const req = {
sylkrtc: 'session-create',
account: this.account.id,
session: this.id,
uri: uri,
sdp: sdp
};
this._sendRequest(req, (error) => {
if (error) {
DEBUG('Call error: %s', error);
this._localTerminate(error);
}
});
}
_sendTerminate() {
const req = {
sylkrtc: 'session-terminate',
session: this.id
};
this._sendRequest(req, (error) => {
if (error) {
DEBUG('Error terminating call: %s', error);
this._localTerminate(error);
}
});
setTimeout(() => {
if (!this._terminated) {
DEBUG('Timeout terminating call');
this._localTerminate('200 OK');
}
this._terminated = true;
}, 150);
}
_sendTrickle(candidate) {
const req = {
sylkrtc: 'session-trickle',
session: this.id,
candidates: candidate !== null ? [candidate] : [],
};
this._sendRequest(req, (error) => {
if (error) {
DEBUG('Trickle error: %s', error);
this._localTerminate(error);
}
});
}
_sendAnswer(sdp) {
const req = {
sylkrtc: 'session-answer',
session: this.id,
sdp: sdp
};
this._sendRequest(req, (error) => {
if (error) {
DEBUG('Answer error: %s', error);
this.terminate();
}
});
}
_closeRTCPeerConnection() {
DEBUG('Closing RTCPeerConnection');
if (this._pc !== null) {
let tempStream;
if (this._pc.getSenders) {
let tracks = [];
for (let track of this._pc.getSenders()) {
if (track.track != null ) {
tracks = tracks.concat(track.track);
}
+ if (this._previousTrack !== null) {
+ tracks = tracks.concat(this._previousTrack);
+ }
}
if (tracks.length != 0) {
tempStream = new MediaStream(tracks);
utils.closeMediaStream(tempStream);
}
} else {
for (let stream of this._pc.getLocalStreams()) {
+ if (this._previousTrack !== null) {
+ stream = stream.concat(this._previousTrack);
+ }
utils.closeMediaStream(stream);
}
}
if (this._pc.getReceivers) {
let tracks = [];
for (let track of this._pc.getReceivers()) {
tracks = tracks.concat(track.track);
}
tempStream = new MediaStream(tracks);
utils.closeMediaStream(tempStream);
} else {
for (let stream of this._pc.getRemoteStreams()) {
utils.closeMediaStream(stream);
}
}
this._pc.close();
this._pc = null;
if (this._dtmfSender !== null) {
this._dtmfSender.removeEventListener('tonechange', this._onDtmf);
this._dtmfSender = null;
}
}
}
_localTerminate(error) {
if (this._terminated) {
return;
}
DEBUG('Local terminate');
this._account._calls.delete(this.id);
this._terminated = true;
const oldState = this._state;
const newState = 'terminated';
const data = {
reason: error.toString()
};
this._closeRTCPeerConnection();
this.emit('stateChanged', oldState, newState, data);
}
_onDtmf(event) {
DEBUG('Sent DTMF tone %s', event.tone);
this.emit('dtmfToneSent', event.tone);
}
}
export { Call };
diff --git a/lib/conference.js b/lib/conference.js
index edea565..28082e9 100644
--- a/lib/conference.js
+++ b/lib/conference.js
@@ -1,724 +1,771 @@
'use strict';
import debug from 'debug';
import uuidv4 from 'uuid/v4';
import utils from './utils';
import { EventEmitter } from 'events';
const DEBUG = debug('sylkrtc:Conference');
class Participant extends EventEmitter {
constructor(publisherId, identity, conference) {
super();
this._id = uuidv4();
this._publisherId = publisherId;
this._identity = identity;
this._conference = conference;
this._state = null;
this._pc = null;
this._stream = new MediaStream();
this._videoSubscriptionPaused = false;
this._audioSubscriptionPaused = false;
this._videoPublishingPaused = false;
this._audioPublishingPaused = false;
}
get id() {
return this._id;
}
get publisherId() {
return this._publisherId;
}
get identity() {
return this._identity;
}
get conference() {
return this._conference;
}
get videoPaused() {
return this._videoSubscriptionPaused;
}
get state() {
return this._state;
}
getReceivers() {
if (this._pc !== null) {
return this._pc.getReceivers();
} else {
return [];
}
}
get streams() {
if (this._pc !== null) {
if (this._pc.getReceivers) {
this._pc.getReceivers().forEach((e) => {
this._stream.addTrack(e.track);
});
return [this._stream];
} else {
return this._pc.getRemoteStreams();
}
} else {
return [];
}
}
attach() {
if (this._state !== null) {
return;
}
this._setState('progress');
this._sendAttach();
}
detach(isRemoved=false) {
if (this._state !== null) {
if (!isRemoved) {
this._sendDetach();
} else {
this._close();
}
}
}
pauseVideo() {
this._sendUpdate({video: false});
this._videoSubscriptionPaused = true;
}
resumeVideo() {
this._sendUpdate({video: true});
this._videoSubscriptionPaused = false;
}
_setState(newState) {
const oldState = this._state;
this._state = newState;
DEBUG(`Participant ${this.id} state change: ${oldState} -> ${newState}`);
this.emit('stateChanged', oldState, newState);
}
_handleOffer(offerSdp) {
DEBUG('Handling SDP for participant offer: %s', offerSdp);
// Create the RTCPeerConnection
const pcConfig = this.conference._pcConfig;
const pc = new RTCPeerConnection(pcConfig);
pc.addEventListener('addstream', (event) => {
DEBUG('Stream added');
this.emit('streamAdded', event.stream);
});
pc.addEventListener('icecandidate', (event) => {
if (event.candidate !== null) {
DEBUG('New ICE candidate %o', event.candidate);
} else {
DEBUG('ICE candidate gathering finished');
}
this._sendTrickle(event.candidate);
});
this._pc = pc;
// no need for a local stream since we are only going to receive media here
pc.setRemoteDescription(
new RTCSessionDescription({type: 'offer', sdp: offerSdp}),
// success
() => {
utils.createLocalSdp(pc, 'answer')
.then((sdp) => {
DEBUG('Local SDP: %s', sdp);
this._sendAnswer(sdp);
})
.catch((reason) => {
DEBUG(reason);
this._close();
});
},
// failure
(error) => {
DEBUG('Error setting remote description: %s', error);
this._close();
}
);
}
_sendAttach() {
const req = {
sylkrtc: 'videoroom-feed-attach',
session: this.conference.id,
publisher: this._publisherId,
feed: this.id
};
DEBUG('Sending request: %o', req);
this.conference._sendRequest(req, (error) => {
if (error) {
DEBUG('Error attaching to participant %s: %s', this._publisherId, error);
}
});
}
_sendDetach() {
const req = {
sylkrtc: 'videoroom-feed-detach',
session: this.conference.id,
feed: this.id
};
DEBUG('Sending request: %o', req);
this.conference._sendRequest(req, (error) => {
if (error) {
DEBUG('Error detaching to participant %s: %s', this._publisherId, error);
}
this._close();
});
}
_sendTrickle(candidate) {
const req = {
sylkrtc: 'videoroom-session-trickle',
session: this.id,
candidates: candidate !== null ? [candidate] : []
};
this.conference._sendRequest(req, (error) => {
if (error) {
DEBUG('Trickle error: %s', error);
this._close();
}
});
}
_sendAnswer(sdp) {
const req = {
sylkrtc: 'videoroom-feed-answer',
session: this.conference.id,
feed: this.id,
sdp: sdp
};
DEBUG('Sending request: %o', req);
this.conference._sendRequest(req, (error) => {
if (error) {
DEBUG('Answer error: %s', error);
this._close();
}
});
}
_sendUpdate(options = {}) {
const req = {
sylkrtc: 'videoroom-session-update',
session: this.id,
options: options
};
DEBUG('Sending update participant request %o', req);
this.conference._sendRequest(req, (error) => {
if (error) {
DEBUG('Answer error: %s', error);
}
});
}
_close() {
DEBUG('Closing Participant RTCPeerConnection');
if (this._pc !== null) {
let tempStream;
if (this._pc.getSenders) {
let tracks = [];
for (let track of this._pc.getSenders()) {
if (track.track != null) {
tracks = tracks.concat(track.track);
}
}
if (tracks.length != 0) {
tempStream = new MediaStream(tracks);
utils.closeMediaStream(tempStream);
}
} else {
for (let stream of this._pc.getLocalStreams()) {
utils.closeMediaStream(stream);
}
}
if (this._pc.getReceivers) {
let tracks = [];
for (let track of this._pc.getReceivers()) {
tracks = tracks.concat(track.track);
}
tempStream = new MediaStream(tracks);
utils.closeMediaStream(tempStream);
} else {
for (let stream of this._pc.getRemoteStreams()) {
utils.closeMediaStream(stream);
}
}
this._pc.close();
this._pc = null;
this._setState(null);
}
}
}
class ConferenceCall extends EventEmitter {
constructor(account) {
super();
this._account = account;
this._id = null;
this._pc = null;
this._participants = new Map();
this._terminated = false;
this._state = null;
this._localIdentity = new utils.Identity(account.id, account.displayName);
this._localStreams = new MediaStream();
+ this._previousTrack = null;
this._remoteIdentity = null;
this._activeParticpants = [];
this._pcConfig = null; // saved on initialize, used later for subscriptions
this._delay_established = false; // set to true when we need to delay posting the state change to 'established'
this._setup_in_progress = false; // set while we set the remote description and setup the peer copnnection
}
get account() {
return this._account;
}
get id() {
return this._id;
}
get direction() {
// make this object API compatible with `Call`
return 'outgoing';
}
get state() {
return this._state;
}
get localIdentity() {
return this._localIdentity;
}
get remoteIdentity() {
return this._remoteIdentity;
}
get participants() {
return Array.from(new Set(this._participants.values()));
}
get activeParticipants() {
return this._activeParticpants;
}
getLocalStreams() {
if (this._pc !== null) {
if (this._pc.getSenders) {
this._pc.getSenders().forEach((e) => {
this._localStreams.addTrack(e.track);
});
return [this._localStreams];
} else {
return this._pc.getLocalStreams();
}
} else {
return [];
}
}
getRemoteStreams() {
let streams = [];
for (let participant of new Set(this._participants.values())) {
streams = streams.concat(participant.streams);
}
return streams;
}
getSenders() {
if (this._pc !== null) {
return this._pc.getSenders();
} else {
return [];
}
}
getReceivers() {
let receivers = [];
for (let participant of new Set(this._participants.values())) {
receivers = receivers.concat(participant.getReceivers());
}
return receivers;
}
scaleLocalTrack(oldTrack, divider) {
DEBUG('Scaling track by %d', divider);
let sender;
for (sender of this._pc.getSenders()) {
if (sender.track === oldTrack) {
DEBUG('Found sender to modify track %o', sender);
break;
}
}
sender.setParameters({encodings: [{scaleResolutionDownBy: divider}]})
.then(() => {
DEBUG("Scale set to %o", divider);
DEBUG('Active encodings %o', sender.getParameters().encodings);
})
.catch((error) => {
DEBUG('Error %o', error);
});
}
+ startScreensharing(newTrack) {
+ let oldTrack = this.getLocalStreams()[0].getVideoTracks()[0];
+ this.replaceTrack(oldTrack, newTrack, true);
+ this._sharingScreen = true;
+ }
+
+ stopScreensharing() {
+ let oldTrack = this.getLocalStreams()[0].getVideoTracks()[0];
+ this.replaceTrack(oldTrack, this._previousTrack);
+ this._sharingScreen = false;
+ }
+
+ replaceTrack(oldTrack, newTrack, keep=false) {
+ let sender;
+ for (sender of this._pc.getSenders()) {
+ if (sender.track === oldTrack) {
+ break;
+ }
+ }
+
+ sender.replaceTrack(newTrack)
+ .then(() => {
+ if (keep) {
+ this._previousTrack = oldTrack;
+ } else {
+ if (oldTrack) {
+ oldTrack.stop();
+ }
+ if (newTrack === this._previousTrack) {
+ this._previousTrack = null;
+ }
+ }
+
+ if (oldTrack) {
+ this._localStreams.removeTrack(oldTrack);
+ }
+ this._localStreams.addTrack(newTrack);
+ });
+ }
+
configureRoom(ps, cb=null) {
if (!Array.isArray(ps)) {
return;
}
this._sendConfigureRoom(ps, cb);
}
terminate() {
if (this._terminated) {
return;
}
DEBUG('Terminating conference');
this._sendTerminate();
}
inviteParticipants(ps) {
if (this._terminated) {
return;
}
if (!Array.isArray(ps) || ps.length === 0) {
return;
}
DEBUG('Inviting participants: %o', ps);
const req = {
sylkrtc: 'videoroom-invite',
session: this.id,
participants: ps
};
this._sendRequest(req, null);
}
// Private API
_initialize(uri, options={}) {
if (this._id !== null) {
throw new Error('Already initialized');
}
if (uri.indexOf('@') === -1) {
throw new Error('Invalid URI');
}
if (!options.localStream) {
throw new Error('Missing localStream');
}
this._id = uuidv4();
this._remoteIdentity = new utils.Identity(uri);
options = Object.assign({}, options);
const pcConfig = options.pcConfig || {iceServers:[]};
this._pcConfig = pcConfig;
this._initialParticipants = options.initialParticipants || [];
const offerOptions = options.offerOptions || {};
// only send audio / video through the publisher connection
offerOptions.offerToReceiveAudio = false;
offerOptions.offerToReceiveVideo = false;
delete offerOptions.mandatory;
// Create the RTCPeerConnection
this._pc = new RTCPeerConnection(pcConfig);
this._pc.addEventListener('icecandidate', (event) => {
if (event.candidate !== null) {
DEBUG('New ICE candidate %o', event.candidate);
} else {
DEBUG('ICE candidate gathering finished');
}
this._sendTrickle(event.candidate);
});
this._pc.addStream(options.localStream);
this.emit('localStreamAdded', options.localStream);
DEBUG('Offer options: %o', offerOptions);
utils.createLocalSdp(this._pc, 'offer', offerOptions)
.then((sdp) => {
DEBUG('Local SDP: %s', sdp);
this._sendJoin(sdp);
})
.catch((reason) => {
this._localTerminate(reason);
});
}
_handleEvent(message) {
DEBUG('Conference event: %o', message);
let participant;
switch (message.event) {
case 'session-state':
let oldState = this._state;
let newState = message.state;
this._state = newState;
if (newState === 'accepted') {
this.emit('stateChanged', oldState, newState, {});
const sdp = utils.mungeSdp(message.sdp);
DEBUG('Remote SDP: %s', sdp);
this._setup_in_progress = true;
this._pc.setRemoteDescription(
new RTCSessionDescription({type: 'answer', sdp: sdp}),
// success
() => {
this._setup_in_progress = false;
if (!this._terminated) {
if (this._delay_established) {
oldState = this._state;
this._state = 'established';
DEBUG('Setting delayed established state!');
this.emit('stateChanged', oldState, this._state, {});
this._delay_established = false;
}
DEBUG('Conference accepted');
if (this._initialParticipants.length > 0 ) {
setTimeout(() => {
this.inviteParticipants(this._initialParticipants);
}, 50);
}
}
},
// failure
(error) => {
DEBUG('Error processing conference accept: %s', error);
this.terminate();
}
);
} else if (newState === 'established') {
if (this._setup_in_progress) {
this._delay_established = true;
} else {
this.emit('stateChanged', oldState, newState, {});
}
} else if (newState === 'terminated') {
this.emit('stateChanged', oldState, newState, {reason: message.reason});
this._terminated = true;
this._close();
} else {
this.emit('stateChanged', oldState, newState, {});
}
break;
case 'initial-publishers':
// this comes between 'accepted' and 'established' states
for (let p of message.publishers) {
participant = new Participant(p.id, new utils.Identity(p.uri, p.display_name), this);
this._participants.set(participant.id, participant);
this._participants.set(p.id, participant);
}
break;
case 'publishers-joined':
for (let p of message.publishers) {
DEBUG('Participant joined: %o', p);
participant = new Participant(p.id, new utils.Identity(p.uri, p.display_name), this);
this._participants.set(participant.id, participant);
this._participants.set(p.id, participant);
this.emit('participantJoined', participant);
}
break;
case 'publishers-left':
for (let pId of message.publishers) {
participant = this._participants.get(pId);
if (participant) {
this._participants.delete(participant.id);
this._participants.delete(pId);
this.emit('participantLeft', participant);
}
}
break;
case 'feed-attached':
participant = this._participants.get(message.feed);
if (participant) {
participant._handleOffer(message.sdp);
}
break;
case 'feed-established':
participant = this._participants.get(message.feed);
if (participant) {
participant._setState('established');
}
break;
case 'configure':
let activeParticipants = [];
let originator;
const mappedOriginator = this._participants.get(message.originator);
if (mappedOriginator) {
originator = mappedOriginator.identity;
} else if (message.originator === this.id) {
originator = this.localIdentity;
} else if (message.originator === 'videoroom'){
originator = message.originator;
}
for (let pId of message.active_participants) {
participant = this._participants.get(pId);
if (participant) {
activeParticipants.push(participant);
} else if (pId === this.id) {
activeParticipants.push({
id: this.id,
publisherId: this.id,
identity: this.localIdentity,
streams: this.getLocalStreams()
});
}
}
this._activeParticpants = activeParticipants;
const roomConfig = {originator: originator, activeParticipants: this._activeParticpants};
this.emit('roomConfigured', roomConfig);
break;
default:
break;
}
}
_sendConfigureRoom(ps, cb = null) {
const req = {
sylkrtc: 'videoroom-configure',
session: this.id,
active_participants: ps
};
this._sendRequest(req, (error) => {
if (error) {
DEBUG('Error configuring room: %s', error);
if (cb) {
cb(error);
}
} else {
DEBUG('Configure room send: %o', ps);
}
});
}
_sendJoin(sdp) {
const req = {
sylkrtc: 'videoroom-join',
account: this.account.id,
session: this.id,
uri: this.remoteIdentity.uri,
sdp: sdp
};
DEBUG('Sending request: %o', req);
this._sendRequest(req, (error) => {
if (error) {
this._localTerminate(error);
}
});
}
_sendTerminate() {
const req = {
sylkrtc: 'videoroom-leave',
session: this.id
};
this._sendRequest(req, (error) => {
if (error) {
DEBUG('Error terminating conference: %s', error);
this._localTerminate(error);
}
});
setTimeout(() => {
if (!this._terminated) {
DEBUG('Timeout terminating call');
this._localTerminate('');
}
this._terminated = true;
}, 150);
}
_sendTrickle(candidate) {
const req = {
sylkrtc: 'videoroom-session-trickle',
session: this.id,
candidates: candidate !== null ? [candidate] : []
};
this._sendRequest(req, (error) => {
if (error) {
DEBUG('Trickle error: %s', error);
this._localTerminate(error);
}
});
}
_sendRequest(req, cb) {
this._account._sendRequest(req, cb);
}
_close() {
DEBUG('Closing RTCPeerConnection');
if (this._pc !== null) {
let tempStream;
if (this._pc.getSenders) {
let tracks = [];
for (let track of this._pc.getSenders()) {
tracks = tracks.concat(track.track);
}
+ if (this._previousTrack !== null) {
+ tracks = tracks.concat(this._previousTrack);
+ }
tempStream = new MediaStream(tracks);
utils.closeMediaStream(tempStream);
} else {
for (let stream of this._pc.getLocalStreams()) {
+ if (this._previousTrack !== null) {
+ stream = stream.concat(this._previousTrack);
+ }
utils.closeMediaStream(stream);
}
}
if (this._pc.getReceivers) {
let tracks = [];
for (let track of this._pc.getReceivers()) {
tracks = tracks.concat(track.track);
}
tempStream = new MediaStream(tracks);
utils.closeMediaStream(tempStream);
} else {
for (let stream of this._pc.getRemoteStreams()) {
utils.closeMediaStream(stream);
}
}
this._pc.close();
this._pc = null;
}
const participants = this.participants;
this._participants = [];
for (let p of participants) {
p._close();
}
}
_localTerminate(reason) {
if (this._terminated) {
return;
}
DEBUG(`Local terminate, reason: ${reason}`);
this._account._confCalls.delete(this.id);
this._terminated = true;
const oldState = this._state;
const newState = 'terminated';
const data = {
reason: reason.toString()
};
this._close();
this.emit('stateChanged', oldState, newState, data);
}
}
export { ConferenceCall };

File Metadata

Mime Type
text/x-diff
Expires
Sat, Feb 1, 1:29 PM (1 d, 9 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
3489353
Default Alt Text
(56 KB)

Event Timeline