diff --git a/API.md b/API.md index fbf37e1..d8555de 100644 --- a/API.md +++ b/API.md @@ -1,617 +1,630 @@ ## 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. * `sanatizeHtml`: function to XSS sanitize html strings ### 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.startScreensharing(newTrack) Start sharing a screen/window. `newTrack` should be a `RTCMediaStreamTrack` containing the screen/window. Internally it will call replace track with the keep flag enabled and it will set the state so it can be tracked. #### Call.stopScreensharing() Stop sharing a screen/window and restore the previousTrack. #### Call.replaceTrack(oldTrack, newTrack, keep=false, cb=null) 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. The callback will be called with a true value once the operation completes. #### 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.sharingScreen Getter property which returns the screen sharing state. #### 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`. * **fileSharing**: emitted when a participant in the room shares files. A single argument is provided: a list of instances of `SharedFile`. * **message**: emitted when a message is received. A single argument is provided, an instance of `Message`. * **sendingMessage**: emitted when a message will be sent. A single argument is provided, an instance of `Message`. * **composingIndication**: emitted when somebody in the room is typing. A single argument is provided, an object with `refresh`, `sender` and `state`. The `sender` is an `Identity`. -* **muteAudio**: emitted when a participant requests to `muteAudioParticipants`. +* **muteAudio**: emitted when a `Participant` requests to `muteAudioParticipants`. +* **raisedHands**: emitted when a `Participant` raises or lower his hand. A single argument is provided: a list of `raisedHands`. + The list contains instances of `Participant`. #### Conference.startScreensharing(newTrack) Start sharing a screen/window. `newTrack` should be a `RTCMediaStreamTrack` containing the screen/window. Internally it will call replace track with the keep flag enabled and it will set the state so it can be tracked. #### Conference.stopScreensharing() Stop sharing a screen/window and restore the previousTrack. #### Conference.sendMessage(message, type) Send a chat message to the conference. `message` should contain a string, `type` should contain the message content type like 'text/plain', 'text/html', 'image/png'. The function returns an instance of `Message`. #### Conference.sendComposing(state) Send a composing indication to the conference. `state` should be either `active` or `idle`. #### Conference.replaceTrack(oldTrack, newTrack, keep=false, cb=null) 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. The callback will be called with a true value once the operation completes. #### 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.muteAudioParticipants() Request muting for all participants. All participants in the room will get a `muteAudio` event from the server. +#### Conference.toggleHand(participantSession) + +Raise/Lower your hand. An optional participant session can be provided, so the hand of this specific session is raised/lowered. +Calling this function will trigger a `raisedHands` event to all participants in the room. + + #### 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.sharedFiles Getter property for the Shared Files which returns an array of `SharedFile` objects in the conference. #### Conference.messages Getter property for the Messages which returns an array of `Message` objects in the conference. +#### Conference.raisedHands + +Getter property for the Raised Hands which returns an array of `Participant` objects. + + #### 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.sharingScreen Getter property which returns the screen sharing state. #### 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 '. ### SharedFile Object representing a shared file. #### SharedFile.filename The filename of the shared file #### SharedFile.filesize The filesize in bytes of the shared file #### SharedFile.uploader The `Identity` of the uploader. #### SharedFile.session The session UUID which was used to upload the file ### Message Object representing a message. Events emitted: * **stateChanged**: indicates the message state has changed. Two arguments are provided: `oldState`, `newState`. `oldState` and `newState` indicate the previous and current state respectively. Possible states: * received: the message was received * pending: the message is pending delivery * delivered: the message has been delivered * failed: something went wrong, either it is not delivered, or it could not be sent #### Message.id Getter property for id the message #### Message.content Getter property for the content of the message. In case content type of the message is 'text/html', it will be sanatized. #### Message.content Getter property for the content type of the message. #### Message.sender Getter property for the `Identity` of the message sender. #### Message.timestamp Getter property for the `Date` object of the message. #### Message.type Getter property for the type of the message, it can be `normal` or `status`. #### Message.state Getter property for the state of the message. It can be `received`, `pending`, `delivered`, `failed`. diff --git a/lib/conference.js b/lib/conference.js index 0141e47..db51174 100644 --- a/lib/conference.js +++ b/lib/conference.js @@ -1,958 +1,993 @@ '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 Message extends EventEmitter { constructor(message, identity, state=null) { super(); this._id = uuidv4(); this._contentType = message.content_type; this._sender = identity; this._type = message.type; this._timestamp = new Date(message.timestamp); this._state = state; if (message.content_type === 'text/html') { this._content = utils.sanatizeHtml(message.content); } else { this._content = message.content; } } get id() { return this._id; } get content() { return this._content; } get contentType() { return this._contentType; } get sender() { return this._sender; } get timestamp() { return this._timestamp; } get type() { return this._type; } get state() { return this._state; } _setState(newState) { const oldState = this._state; this._state = newState; DEBUG(`Message ${this.id} state change: ${oldState} -> ${newState}`); this.emit('stateChanged', oldState, newState); } } 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._sharingScreen = false; this._activeParticpants = []; this._sharedFiles = []; + this._raisedHands = []; this._messages = new Map(); 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 sharingScreen() { return this._sharingScreen; } get sharedFiles () { return this._sharedFiles; } + get raisedHands () { + return this._raisedHands; + } + 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; } get messages() { return Array.from(this._messages.values()); } 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() { 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, (value) => { this._sharingScreen = value; }); } stopScreensharing() { let oldTrack = this.getLocalStreams()[0].getVideoTracks()[0]; this.replaceTrack(oldTrack, this._previousTrack); this._sharingScreen = false; } replaceTrack(oldTrack, newTrack, keep=false, cb=null) { 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); if (cb) { cb(true); } }).catch((error)=> { DEBUG('Error replacing track: %s', error); }); } 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); } sendMessage(message, type) { return this._sendMessage(message, type); } sendComposing(state) { return this._sendComposing(state); } muteAudioParticipants() { DEBUG('Muting audio for all partcipants'); const req = { sylkrtc: 'videoroom-mute-audio-participants', session: this.id }; this._sendRequest(req, null); } + toggleHand(session) { + DEBUG('Toggle hand state'); + const req = { + sylkrtc: 'videoroom-toggle-hand', + session: this.id + }; + if (session) { + req.session_id = session; + } + 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; case 'file-sharing': const mappedFiles = message.files.map((file) => { return new utils.SharedFile( file.filename, file.filesize, new utils.Identity(file.uploader.uri, file.uploader.display_name), file.session ); }); this._sharedFiles = this._sharedFiles.concat(mappedFiles); this.emit('fileSharing', mappedFiles); break; case 'message': const mappedMessage = new Message( message, new utils.Identity(message.sender.uri, message.sender.display_name), 'received' ); this._messages.set(mappedMessage.id, mappedMessage); this.emit('message', mappedMessage); break; case 'message-delivery': const outgoingMessage = this._messages.get(message.message_id); if (outgoingMessage) { if (message.delivered) { outgoingMessage._setState('delivered'); } else { outgoingMessage._setState('failed'); } } break; case 'composing-indication': const mappedComposing = { refresh: message.refresh, sender: new utils.Identity(message.sender.uri, message.sender.display_name), state: message.state }; this.emit('composingIndication', mappedComposing); break; case 'mute-audio': let identity; const mappedIdentity = this._participants.get(message.originator); if (mappedIdentity) { identity = mappedIdentity.identity; } else if (message.originator === this.id) { identity = this.localIdentity; } this.emit('muteAudio', {originator: identity}); break; + case 'raised-hands': + let raisedHands = []; + for (let pId of message.raised_hands) { + participant = this._participants.get(pId); + if (participant) { + raisedHands.push(participant); + } else if (pId === this.id) { + raisedHands.push({ + id: this.id, + publisherId: this.id, + identity: this.localIdentity, + streams: this.getLocalStreams() + }); + } + } + this._raisedHands = raisedHands; + this.emit('raisedHands', {raisedHands: this._raisedHands}); + 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); } }); } _sendMessage(message, contentType='text/plain') { const outgoingMessage = new Message({ content: message, content_type: contentType, timestamp: new Date().toISOString(), type: 'normal' }, this._localIdentity, 'pending'); const req = { sylkrtc: 'videoroom-message', session: this.id, message_id: outgoingMessage.id, content: outgoingMessage.content, content_type: outgoingMessage.contentType }; this._messages.set(outgoingMessage.id, outgoingMessage); this.emit('sendingMessage', outgoingMessage); DEBUG('Sending message: %o', outgoingMessage); this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending message: %s', error); outgoingMessage._setState('failed'); } }); return outgoingMessage; } _sendComposing(state) { const req = { sylkrtc: 'videoroom-composing-indication', session: this.id, state: state, }; this._sendRequest(req, (error) => { if (error) { DEBUG('Error sending message: %s', 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 };