diff --git a/lib/call.js b/lib/call.js index 00edb40..b3574b8 100644 --- a/lib/call.js +++ b/lib/call.js @@ -1,357 +1,356 @@ 'use strict'; import debug from 'debug'; import uuid from 'node-uuid'; -import rtcninja from 'rtcninja'; 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._localIdentity = new utils.Identity(account.id, account.displayName); this._remoteIdentity = null; this._dtmfSender = null; // 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; } getLocalStreams() { if (this._pc !== null) { return this._pc.getLocalStreams(); } else { return []; } } getRemoteStreams() { if (this._pc !== null) { return this._pc.getRemoteStreams(); } 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 rtcninja.RTCSessionDescription({type: 'offer', sdp: this._incomingSdp}), + 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(); } ); } 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 = uuid.v4(); 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'; DEBUG('Remote SDP: %s', sdp); } _handleEvent(message) { DEBUG('Call event: %o', message); switch (message.event) { case 'state': const oldState = this._state; const newState = message.data.state; this._state = newState; const data = {}; if (newState === 'accepted' && this._direction === 'outgoing') { const sdp = utils.mungeSdp(message.data.sdp); DEBUG('Remote SDP: %s', sdp); this._pc.setRemoteDescription( - new rtcninja.RTCSessionDescription({type: 'answer', sdp: sdp}), + new RTCSessionDescription({type: 'answer', sdp: sdp}), // success () => { // fake 'established' state change this._state = 'established'; this.emit('stateChanged', oldState, 'established', {}); }, // failure (error) => { DEBUG('Error accepting call: %s', error); this.terminate(); } ); DEBUG('Call accepted'); this.emit('stateChanged', oldState, newState, data); } else if (newState === 'established' && this._direction === 'outgoing') { // TODO: remove this this._state = oldState; } else { if (newState === 'terminated') { data.reason = message.data.reason; this._terminated = true; this._account._calls.delete(this.id); this._closeRTCPeerConnection(); } this.emit('stateChanged', oldState, newState, data); } break; default: break; } } _initRTCPeerConnection(pcConfig) { if (this._pc !== null) { throw new Error('RTCPeerConnection already initialized'); } - this._pc = new rtcninja.RTCPeerConnection(pcConfig); - this._pc.onaddstream = (event, stream) => { + this._pc = new RTCPeerConnection(pcConfig); + this._pc.addEventListener('addstream', (event) => { DEBUG('Stream added'); - this.emit('streamAdded', stream); - }; - this._pc.onicecandidate = (event) => { + 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); } this._terminated = true; }); 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, null); } _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) { for (let stream of this._pc.getLocalStreams()) { - rtcninja.closeMediaStream(stream); + utils.closeMediaStream(stream); } for (let stream of this._pc.getRemoteStreams()) { - rtcninja.closeMediaStream(stream); + 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 80de1a8..c8a0241 100644 --- a/lib/conference.js +++ b/lib/conference.js @@ -1,511 +1,510 @@ 'use strict'; import debug from 'debug'; import uuid from 'node-uuid'; -import rtcninja from 'rtcninja'; import utils from './utils'; import { EventEmitter } from 'events'; const DEBUG = debug('sylkrtc:Conference'); class Participant extends EventEmitter { constructor(publisherId, identity, conference) { super(); this._id = uuid.v4(); this._publisherId = publisherId; this._identity = identity; this._conference = conference; this._state = null; this._pc = null; } get id() { return this._id; } get identity() { return this._identity; } get conference() { return this._conference; } get state() { return this._state; } get streams() { if (this._pc !== null) { return this._pc.getRemoteStreams(); } else { return []; } } attach() { if (this._state !== null) { return; } this._setState('progress'); this._sendAttach(); } detach() { if (this._state !== null) { this._sendDetach(); } } _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 rtcninja.RTCPeerConnection(pcConfig); - pc.onaddstream = (event, stream) => { + const pc = new RTCPeerConnection(pcConfig); + pc.addEventListener('addstream', (event) => { DEBUG('Stream added'); - this.emit('streamAdded', stream); - }; - pc.onicecandidate = (event) => { + 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 rtcninja.RTCSessionDescription({type: 'offer', sdp: offerSdp}), + 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-ctl', session: this.conference.id, option: 'feed-attach', feed_attach: { session: this.id, publisher: this._publisherId } }; 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-ctl', session: this.conference.id, option: 'feed-detach', feed_detach: { session: 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-ctl', session: this.conference.id, option: 'trickle', trickle: { session: this.id, candidates: candidate !== null ? [candidate] : [] } }; this.conference._sendRequest(req, null); } _sendAnswer(sdp) { const req = { sylkrtc: 'videoroom-ctl', session: this.conference.id, option: 'feed-answer', feed_answer: { session: this.id, sdp: sdp } }; this.conference._sendRequest(req, (error) => { if (error) { DEBUG('Answer error: %s', error); this._close(); } }); } _close() { DEBUG('Closing Participant RTCPeerConnection'); if (this._pc !== null) { for (let stream of this._pc.getLocalStreams()) { - rtcninja.closeMediaStream(stream); + utils.closeMediaStream(stream); } for (let stream of this._pc.getRemoteStreams()) { - rtcninja.closeMediaStream(stream); + 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._remoteIdentity = null; this._pcConfig = null; // saved on initialize, used later for subscriptions } 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(this._participants.values()); } getLocalStreams() { if (this._pc !== null) { return this._pc.getLocalStreams(); } else { return []; } } getRemoteStreams() { let streams = []; for (let participant of this._participants) { streams = streams.contact(participant.streams); } return streams; } 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-ctl', session: this.id, option: 'invite-participants', invite_participants: { 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 = uuid.v4(); 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 rtcninja.RTCPeerConnection(pcConfig); - this._pc.onicecandidate = (event) => { + 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); switch (message.event) { case 'state': const oldState = this._state; const newState = message.data.state; this._state = newState; let data = {}; let participant; if (newState === 'accepted') { let sdp = utils.mungeSdp(message.data.sdp); DEBUG('Remote SDP: %s', sdp); this._pc.setRemoteDescription( - new rtcninja.RTCSessionDescription({type: 'answer', sdp: sdp}), + new RTCSessionDescription({type: 'answer', sdp: sdp}), // success () => { DEBUG('Conference accepted'); this.emit('stateChanged', oldState, newState, data); 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 === 'terminated') { data.reason = message.data.reason; this._terminated = true; this._close(); } this.emit('stateChanged', oldState, newState, data); } break; case 'initial_publishers': // this comes between 'accepted' and 'established' states for (let p of message.data.publishers) { if (!this._participants.has(p.id)) { participant = new Participant(p.id, new utils.Identity(p.uri, p.display_name), this); this._participants.set(participant.id, participant); } } break; case 'publishers_joined': for (let p of message.data.publishers) { if (!this._participants.has(p.id)) { 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.emit('participantJoined', participant); } } break; case 'publishers_left': for (let pId of message.data.publishers) { for (participant of this._participants.values()) { if (pId === participant._publisherId) { this._participants.delete(participant.id); this.emit('participantLeft', participant); } } } break; case 'feed_attached': participant = this._participants.get(message.data.subscription); if (participant) { participant._handleOffer(message.data.sdp); } break; case 'feed_established': participant = this._participants.get(message.data.subscription); if (participant) { participant._setState('established'); } break; default: break; } } _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-terminate', session: this.id }; this._sendRequest(req, (error) => { if (error) { DEBUG('Error terminating conference: %s', error); this._localTerminate(error); } this._terminated = true; }); setTimeout(() => { if (!this._terminated) { DEBUG('Timeout terminating call'); this._localTerminate(''); } this._terminated = true; }, 150); } _sendTrickle(candidate) { const req = { sylkrtc: 'videoroom-ctl', session: this.id, option: 'trickle', trickle: { candidates: candidate !== null ? [candidate] : [] } }; this._sendRequest(req, null); } _sendRequest(req, cb) { this._account._sendRequest(req, cb); } _close() { DEBUG('Closing RTCPeerConnection'); if (this._pc !== null) { for (let stream of this._pc.getLocalStreams()) { - rtcninja.closeMediaStream(stream); + utils.closeMediaStream(stream); } for (let stream of this._pc.getRemoteStreams()) { - rtcninja.closeMediaStream(stream); + 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 }; diff --git a/lib/sylkrtc.js b/lib/sylkrtc.js index 1dd4509..a79f796 100644 --- a/lib/sylkrtc.js +++ b/lib/sylkrtc.js @@ -1,22 +1,22 @@ 'use strict'; -import rtcninja from 'rtcninja'; +import adapter from 'webrtc-adapter'; import { Connection } from './connection'; // Public API function createConnection(options = {}) { - if (!rtcninja.hasWebRTC()) { + if (!window.RTCPeerConnection) { throw new Error('WebRTC support not detected'); } const conn = new Connection(options); conn._initialize(); return conn; } export { createConnection }; diff --git a/lib/utils.js b/lib/utils.js index a64003e..05a971a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,111 +1,139 @@ 'use strict'; import transform from 'sdp-transform'; class Identity { constructor(uri, displayName='') { this._uri = uri; this._displayName = displayName; } get uri() { return this._uri; } get displayName() { return this._displayName; } toString() { if (!this._displayName) { return this._uri; } else { return `${this._displayName} <${this._uri}>`; } } } function createLocalSdp(pc, type, options) { if (type !== 'offer' && type !== 'answer') { return Promise.reject('type must be "offer" or "answer", but "' +type+ '" was given'); } let p = new Promise(function(resolve, reject) { let createFunc; if (type === 'offer' ) { createFunc = pc.createOffer; } else { createFunc = pc.createAnswer; } createFunc.call( pc, // success function(desc) { pc.setLocalDescription( desc, // success function() { resolve(mungeSdp(pc.localDescription.sdp)); }, // failure function(error) { reject('Error setting local description: ' + error); } ); }, // failure function(error) { reject('Error creating local SDP: ' + error); }, options ); }); return p; } function mungeSdp(sdp) { let parsedSdp = transform.parse(sdp); let h264payload = null; let hasProfileLevelId = false; // try to fix H264 support for (let media of parsedSdp.media) { if (media.type === 'video') { for (let rtp of media.rtp) { if (rtp.codec === 'H264') { h264payload = rtp.payload; break; } } if (h264payload !== null) { for (let fmtp of media.fmtp) { if (fmtp.payload === h264payload && fmtp.config.indexOf('profile-level-id') !== -1) { hasProfileLevelId = true; break; } } if (!hasProfileLevelId) { media.fmtp.push({payload: h264payload, config: 'profile-level-id=42e01f;packetization-mode=1;level-asymmetry-allowed=1'}); } break; } } } // remove bogus rtcp-fb elements for (let media of parsedSdp.media) { let payloads = String(media.payloads).split(' '); if (media.rtcpFb) { media.rtcpFb = media.rtcpFb.filter((item) => { return payloads.indexOf(String(item.payload)) !== -1; }); } } return transform.write(parsedSdp); } -export default { Identity, createLocalSdp, mungeSdp }; +function closeMediaStream(stream) { + if (!stream) { + return; + } + + // Latest spec states that MediaStream has no stop() method and instead must + // call stop() on every MediaStreamTrack. + if (MediaStreamTrack && MediaStreamTrack.prototype && MediaStreamTrack.prototype.stop) { + if (stream.getTracks) { + for (let track of stream.getTracks()) { + track.stop(); + } + } else { + for (let track of stream.getAudioTracks()) { + track.stop(); + } + + for (let track of stream.getVideoTracks()) { + track.stop(); + } + } + // Deprecated by the spec, but still in use. + } else if (typeof stream.stop === 'function') { + stream.stop(); + } +} + + +export default { Identity, createLocalSdp, mungeSdp, closeMediaStream }; diff --git a/package.json b/package.json index e989108..827f0a3 100644 --- a/package.json +++ b/package.json @@ -1,48 +1,48 @@ { "name": "sylkrtc", "version": "0.8.0", "main": "lib/sylkrtc.js", "description": "SylkServer WebRTC Gateway client library", "repository": { "type": "git", "url": "git://github.com/AGProjects/sylkrtc.git" }, "keywords": [], "author": "AG Projects", "contributors": [ "Tijmen de Mes ", "Saúl Ibarra Corretgé " ], "license": "MIT", "readmeFilename": "README.md", "browserify": { "transform": [ "babelify" ] }, "dependencies": { "blueimp-md5": "^2.3.0", "bowser": "^1.4.1", "debug": "^2.2.0", "node-uuid": "^1.4.7", - "rtcninja": "^0.6.8", "sdp-transform": "^1.6.2", + "webrtc-adapter": "^2.0.7", "websocket": "^1.0.23" }, "devDependencies": { "babel-preset-es2015": "^6.9.0", "babelify": "^7.3.0", "browserify": "^13.0.1", "gulp": "git+https://github.com/gulpjs/gulp.git#4.0", "gulp-filelog": "^0.4.1", "gulp-header": "^1.8.7", "gulp-jshint": "^2.0.1", "gulp-sourcemaps": "^1.6.0", "gulp-uglify": "^1.5.4", "gulp-util": "^3.0.7", "jshint": "^2.9.4", "jshint-stylish": "^2.2.0", "vinyl-buffer": "^1.0.0", "vinyl-source-stream": "^1.1.0" } }