Make demo public
This commit is contained in:
@@ -0,0 +1,897 @@
|
||||
'use strict';
|
||||
|
||||
import events from 'events';
|
||||
import browser from 'bowser';
|
||||
import sdpTransform from 'sdp-transform';
|
||||
import Logger from './Logger';
|
||||
import protooClient from 'protoo-client';
|
||||
import urlFactory from './urlFactory';
|
||||
import utils from './utils';
|
||||
|
||||
const logger = new Logger('Client');
|
||||
|
||||
const DO_GETUSERMEDIA = true;
|
||||
const ENABLE_SIMULCAST = false;
|
||||
const VIDEO_CONSTRAINS =
|
||||
{
|
||||
qvga : { width: { ideal: 320 }, height: { ideal: 240 }},
|
||||
vga : { width: { ideal: 640 }, height: { ideal: 480 }},
|
||||
hd : { width: { ideal: 1280 }, height: { ideal: 720 }}
|
||||
};
|
||||
|
||||
export default class Client extends events.EventEmitter
|
||||
{
|
||||
constructor(peerId, roomId)
|
||||
{
|
||||
logger.debug('constructor() [peerId:"%s", roomId:"%s"]', peerId, roomId);
|
||||
|
||||
super();
|
||||
this.setMaxListeners(Infinity);
|
||||
|
||||
let url = urlFactory.getProtooUrl(peerId, roomId);
|
||||
let transport = new protooClient.WebSocketTransport(url);
|
||||
|
||||
// protoo-client Peer instance.
|
||||
this._protooPeer = new protooClient.Peer(transport);
|
||||
|
||||
// RTCPeerConnection instance.
|
||||
this._peerconnection = null;
|
||||
|
||||
// Webcam map indexed by deviceId.
|
||||
this._webcams = new Map();
|
||||
|
||||
// Local Webcam device.
|
||||
this._webcam = null;
|
||||
|
||||
// Local MediaStream instance.
|
||||
this._localStream = null;
|
||||
|
||||
// Closed flag.
|
||||
this._closed = false;
|
||||
|
||||
// Local video resolution.
|
||||
this._localVideoResolution = 'vga';
|
||||
|
||||
this._protooPeer.on('open', () =>
|
||||
{
|
||||
logger.debug('protoo Peer "open" event');
|
||||
});
|
||||
|
||||
this._protooPeer.on('disconnected', () =>
|
||||
{
|
||||
logger.warn('protoo Peer "disconnected" event');
|
||||
|
||||
// Close RTCPeerConnection.
|
||||
try
|
||||
{
|
||||
this._peerconnection.close();
|
||||
}
|
||||
catch (error) {}
|
||||
|
||||
// Close local MediaStream.
|
||||
if (this._localStream)
|
||||
utils.closeMediaStream(this._localStream);
|
||||
|
||||
this.emit('disconnected');
|
||||
});
|
||||
|
||||
this._protooPeer.on('close', () =>
|
||||
{
|
||||
if (this._closed)
|
||||
return;
|
||||
|
||||
logger.warn('protoo Peer "close" event');
|
||||
|
||||
this.close();
|
||||
});
|
||||
|
||||
this._protooPeer.on('request', this._handleRequest.bind(this));
|
||||
}
|
||||
|
||||
close()
|
||||
{
|
||||
if (this._closed)
|
||||
return;
|
||||
|
||||
this._closed = true;
|
||||
|
||||
logger.debug('close()');
|
||||
|
||||
// Close protoo Peer.
|
||||
this._protooPeer.close();
|
||||
|
||||
// Close RTCPeerConnection.
|
||||
try
|
||||
{
|
||||
this._peerconnection.close();
|
||||
}
|
||||
catch (error) {}
|
||||
|
||||
// Close local MediaStream.
|
||||
if (this._localStream)
|
||||
utils.closeMediaStream(this._localStream);
|
||||
|
||||
// Emit 'close' event.
|
||||
this.emit('close');
|
||||
}
|
||||
|
||||
removeVideo(dontNegotiate)
|
||||
{
|
||||
logger.debug('removeVideo()');
|
||||
|
||||
let stream = this._localStream;
|
||||
let videoTrack = stream.getVideoTracks()[0];
|
||||
|
||||
if (!videoTrack)
|
||||
{
|
||||
logger.warn('removeVideo() | no video track');
|
||||
|
||||
return Promise.reject(new Error('no video track'));
|
||||
}
|
||||
|
||||
stream.removeTrack(videoTrack);
|
||||
videoTrack.stop();
|
||||
|
||||
// NOTE: For Firefox (modern WenRTC API).
|
||||
if (this._peerconnection.removeTrack)
|
||||
{
|
||||
let sender;
|
||||
|
||||
for (sender of this._peerconnection.getSenders())
|
||||
{
|
||||
if (sender.track === videoTrack)
|
||||
break;
|
||||
}
|
||||
|
||||
this._peerconnection.removeTrack(sender);
|
||||
}
|
||||
|
||||
if (!dontNegotiate)
|
||||
{
|
||||
this.emit('localstream', stream, null);
|
||||
|
||||
return this._requestRenegotiation();
|
||||
}
|
||||
}
|
||||
|
||||
addVideo()
|
||||
{
|
||||
logger.debug('addVideo()');
|
||||
|
||||
let stream = this._localStream;
|
||||
let videoTrack;
|
||||
let videoResolution = this._localVideoResolution; // Keep previous resolution.
|
||||
|
||||
if (stream)
|
||||
videoTrack = stream.getVideoTracks()[0];
|
||||
|
||||
if (videoTrack)
|
||||
{
|
||||
logger.warn('addVideo() | there is already a video track');
|
||||
|
||||
return Promise.reject(new Error('there is already a video track'));
|
||||
}
|
||||
|
||||
return this._getLocalStream(
|
||||
{
|
||||
video : VIDEO_CONSTRAINS[videoResolution]
|
||||
})
|
||||
.then((newStream) =>
|
||||
{
|
||||
let newVideoTrack = newStream.getVideoTracks()[0];
|
||||
|
||||
if (stream)
|
||||
{
|
||||
stream.addTrack(newVideoTrack);
|
||||
|
||||
// NOTE: For Firefox (modern WenRTC API).
|
||||
if (this._peerconnection.addTrack)
|
||||
this._peerconnection.addTrack(newVideoTrack, this._localStream);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._localStream = newStream;
|
||||
this._peerconnection.addStream(newStream);
|
||||
}
|
||||
|
||||
this.emit('localstream', this._localStream, videoResolution);
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
return this._requestRenegotiation();
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('addVideo() failed: %o', error);
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
changeWebcam()
|
||||
{
|
||||
logger.debug('changeWebcam()');
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() =>
|
||||
{
|
||||
return this._updateWebcams();
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
let array = Array.from(this._webcams.keys());
|
||||
let len = array.length;
|
||||
let deviceId = this._webcam ? this._webcam.deviceId : undefined;
|
||||
let idx = array.indexOf(deviceId);
|
||||
|
||||
if (idx < len - 1)
|
||||
idx++;
|
||||
else
|
||||
idx = 0;
|
||||
|
||||
this._webcam = this._webcams.get(array[idx]);
|
||||
|
||||
this._emitWebcamType();
|
||||
|
||||
if (len < 2)
|
||||
return;
|
||||
|
||||
logger.debug(
|
||||
'changeWebcam() | new selected webcam [deviceId:"%s"]',
|
||||
this._webcam.deviceId);
|
||||
|
||||
// Reset video resolution to VGA.
|
||||
this._localVideoResolution = 'vga';
|
||||
|
||||
// For Chrome (old WenRTC API).
|
||||
// Replace the track (so new SSRC) and renegotiate.
|
||||
if (!this._peerconnection.removeTrack)
|
||||
{
|
||||
this.removeVideo(true);
|
||||
|
||||
return this.addVideo();
|
||||
}
|
||||
// For Firefox (modern WebRTC API).
|
||||
// Avoid renegotiation.
|
||||
else
|
||||
{
|
||||
return this._getLocalStream(
|
||||
{
|
||||
video : VIDEO_CONSTRAINS[this._localVideoResolution]
|
||||
})
|
||||
.then((newStream) =>
|
||||
{
|
||||
let newVideoTrack = newStream.getVideoTracks()[0];
|
||||
let stream = this._localStream;
|
||||
let oldVideoTrack = stream.getVideoTracks()[0];
|
||||
let sender;
|
||||
|
||||
for (sender of this._peerconnection.getSenders())
|
||||
{
|
||||
if (sender.track === oldVideoTrack)
|
||||
break;
|
||||
}
|
||||
|
||||
sender.replaceTrack(newVideoTrack);
|
||||
stream.removeTrack(oldVideoTrack);
|
||||
oldVideoTrack.stop();
|
||||
stream.addTrack(newVideoTrack);
|
||||
|
||||
this.emit('localstream', stream, this._localVideoResolution);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('changeWebcam() failed: %o', error);
|
||||
});
|
||||
}
|
||||
|
||||
changeVideoResolution()
|
||||
{
|
||||
logger.debug('changeVideoResolution()');
|
||||
|
||||
let newVideoResolution;
|
||||
|
||||
switch (this._localVideoResolution)
|
||||
{
|
||||
case 'qvga':
|
||||
newVideoResolution = 'vga';
|
||||
break;
|
||||
case 'vga':
|
||||
newVideoResolution = 'hd';
|
||||
break;
|
||||
case 'hd':
|
||||
newVideoResolution = 'qvga';
|
||||
break;
|
||||
default:
|
||||
throw new Error(`unknown resolution "${this._localVideoResolution}"`);
|
||||
}
|
||||
|
||||
this._localVideoResolution = newVideoResolution;
|
||||
|
||||
// For Chrome (old WenRTC API).
|
||||
// Replace the track (so new SSRC) and renegotiate.
|
||||
if (!this._peerconnection.removeTrack)
|
||||
{
|
||||
this.removeVideo(true);
|
||||
|
||||
return this.addVideo();
|
||||
}
|
||||
// For Firefox (modern WebRTC API).
|
||||
// Avoid renegotiation.
|
||||
else
|
||||
{
|
||||
return this._getLocalStream(
|
||||
{
|
||||
video : VIDEO_CONSTRAINS[this._localVideoResolution]
|
||||
})
|
||||
.then((newStream) =>
|
||||
{
|
||||
let newVideoTrack = newStream.getVideoTracks()[0];
|
||||
let stream = this._localStream;
|
||||
let oldVideoTrack = stream.getVideoTracks()[0];
|
||||
let sender;
|
||||
|
||||
for (sender of this._peerconnection.getSenders())
|
||||
{
|
||||
if (sender.track === oldVideoTrack)
|
||||
break;
|
||||
}
|
||||
|
||||
sender.replaceTrack(newVideoTrack);
|
||||
stream.removeTrack(oldVideoTrack);
|
||||
oldVideoTrack.stop();
|
||||
stream.addTrack(newVideoTrack);
|
||||
|
||||
this.emit('localstream', stream, newVideoResolution);
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('changeVideoResolution() failed: %o', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getStats()
|
||||
{
|
||||
return this._peerconnection.getStats()
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('pc.getStats() failed: %o', error);
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
disableRemoteVideo(msid)
|
||||
{
|
||||
this._protooPeer.send('disableremotevideo', { msid, disable: true })
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.warn('disableRemoteVideo() failed: %o', error);
|
||||
});
|
||||
}
|
||||
|
||||
enableRemoteVideo(msid)
|
||||
{
|
||||
this._protooPeer.send('disableremotevideo', { msid, disable: false })
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.warn('enableRemoteVideo() failed: %o', error);
|
||||
});
|
||||
}
|
||||
|
||||
_handleRequest(request, accept, reject)
|
||||
{
|
||||
logger.debug('_handleRequest() [method:%s, data:%o]', request.method, request.data);
|
||||
|
||||
switch(request.method)
|
||||
{
|
||||
case 'joinme':
|
||||
{
|
||||
let videoResolution = this._localVideoResolution;
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
{
|
||||
return this._updateWebcams();
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
if (DO_GETUSERMEDIA)
|
||||
{
|
||||
return this._getLocalStream(
|
||||
{
|
||||
audio : true,
|
||||
video : VIDEO_CONSTRAINS[videoResolution]
|
||||
})
|
||||
.then((stream) =>
|
||||
{
|
||||
logger.debug('got local stream [resolution:%s]', videoResolution);
|
||||
|
||||
// Close local MediaStream if any.
|
||||
if (this._localStream)
|
||||
utils.closeMediaStream(this._localStream);
|
||||
|
||||
this._localStream = stream;
|
||||
|
||||
// Emit 'localstream' event.
|
||||
this.emit('localstream', stream, videoResolution);
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
return this._createPeerConnection();
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
return this._peerconnection.createOffer(
|
||||
{
|
||||
offerToReceiveAudio : 1,
|
||||
offerToReceiveVideo : 1
|
||||
});
|
||||
})
|
||||
.then((offer) =>
|
||||
{
|
||||
let capabilities = offer.sdp;
|
||||
let parsedSdp = sdpTransform.parse(capabilities);
|
||||
|
||||
logger.debug('capabilities [parsed:%O, sdp:%s]', parsedSdp, capabilities);
|
||||
|
||||
// Accept the protoo request.
|
||||
accept(
|
||||
{
|
||||
capabilities : capabilities,
|
||||
usePlanB : utils.isPlanB()
|
||||
});
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
logger.debug('"joinme" request accepted');
|
||||
|
||||
// Emit 'join' event.
|
||||
this.emit('join');
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('"joinme" request failed: %o', error);
|
||||
|
||||
reject(500, error.message);
|
||||
throw error;
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'peers':
|
||||
{
|
||||
this.emit('peers', request.data.peers);
|
||||
accept();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'addpeer':
|
||||
{
|
||||
this.emit('addpeer', request.data.peer);
|
||||
accept();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'updatepeer':
|
||||
{
|
||||
this.emit('updatepeer', request.data.peer);
|
||||
accept();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'removepeer':
|
||||
{
|
||||
this.emit('removepeer', request.data.peer);
|
||||
accept();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'offer':
|
||||
{
|
||||
let offer = new RTCSessionDescription(request.data.offer);
|
||||
let parsedSdp = sdpTransform.parse(offer.sdp);
|
||||
|
||||
logger.debug('received offer [parsed:%O, sdp:%s]', parsedSdp, offer.sdp);
|
||||
|
||||
Promise.resolve()
|
||||
.then(() =>
|
||||
{
|
||||
return this._peerconnection.setRemoteDescription(offer);
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
return this._peerconnection.createAnswer();
|
||||
})
|
||||
// Play with simulcast.
|
||||
.then((answer) =>
|
||||
{
|
||||
if (!ENABLE_SIMULCAST)
|
||||
return answer;
|
||||
|
||||
// Chrome Plan B simulcast.
|
||||
if (utils.isPlanB())
|
||||
{
|
||||
// Just for the initial offer.
|
||||
// NOTE: Otherwise Chrome crashes.
|
||||
// TODO: This prevents simulcast to be applied to new tracks.
|
||||
if (this._peerconnection.localDescription && this._peerconnection.localDescription.sdp)
|
||||
return answer;
|
||||
|
||||
// TODO: Should be done just for VP8.
|
||||
let parsedSdp = sdpTransform.parse(answer.sdp);
|
||||
let videoMedia;
|
||||
|
||||
for (let m of parsedSdp.media)
|
||||
{
|
||||
if (m.type === 'video')
|
||||
{
|
||||
videoMedia = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoMedia || !videoMedia.ssrcs)
|
||||
return answer;
|
||||
|
||||
logger.debug('setting video simulcast (PlanB)');
|
||||
|
||||
let ssrc1;
|
||||
let ssrc2;
|
||||
let ssrc3;
|
||||
let cname;
|
||||
let msid;
|
||||
|
||||
for (let ssrcObj of videoMedia.ssrcs)
|
||||
{
|
||||
// Chrome uses:
|
||||
// a=ssrc:xxxx msid:yyyy zzzz
|
||||
// a=ssrc:xxxx mslabel:yyyy
|
||||
// a=ssrc:xxxx label:zzzz
|
||||
// Where yyyy is the MediaStream.id and zzzz the MediaStreamTrack.id.
|
||||
switch (ssrcObj.attribute)
|
||||
{
|
||||
case 'cname':
|
||||
ssrc1 = ssrcObj.id;
|
||||
cname = ssrcObj.value;
|
||||
break;
|
||||
|
||||
case 'msid':
|
||||
msid = ssrcObj.value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ssrc2 = ssrc1 + 1;
|
||||
ssrc3 = ssrc1 + 2;
|
||||
|
||||
videoMedia.ssrcGroups =
|
||||
[
|
||||
{
|
||||
semantics : 'SIM',
|
||||
ssrcs : `${ssrc1} ${ssrc2} ${ssrc3}`
|
||||
}
|
||||
];
|
||||
|
||||
videoMedia.ssrcs =
|
||||
[
|
||||
{
|
||||
id : ssrc1,
|
||||
attribute : 'cname',
|
||||
value : cname,
|
||||
},
|
||||
{
|
||||
id : ssrc1,
|
||||
attribute : 'msid',
|
||||
value : msid,
|
||||
},
|
||||
{
|
||||
id : ssrc2,
|
||||
attribute : 'cname',
|
||||
value : cname,
|
||||
},
|
||||
{
|
||||
id : ssrc2,
|
||||
attribute : 'msid',
|
||||
value : msid,
|
||||
},
|
||||
{
|
||||
id : ssrc3,
|
||||
attribute : 'cname',
|
||||
value : cname,
|
||||
},
|
||||
{
|
||||
id : ssrc3,
|
||||
attribute : 'msid',
|
||||
value : msid,
|
||||
}
|
||||
];
|
||||
|
||||
let modifiedAnswer =
|
||||
{
|
||||
type : 'answer',
|
||||
sdp : sdpTransform.write(parsedSdp)
|
||||
};
|
||||
|
||||
return modifiedAnswer;
|
||||
}
|
||||
// Firefox way.
|
||||
else
|
||||
{
|
||||
let parsedSdp = sdpTransform.parse(answer.sdp);
|
||||
let videoMedia;
|
||||
|
||||
logger.debug('created answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp);
|
||||
|
||||
for (let m of parsedSdp.media)
|
||||
{
|
||||
if (m.type === 'video' && m.direction === 'sendonly')
|
||||
{
|
||||
videoMedia = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoMedia)
|
||||
return answer;
|
||||
|
||||
logger.debug('setting video simulcast (Unified-Plan)');
|
||||
|
||||
videoMedia.simulcast_03 =
|
||||
{
|
||||
value : 'send rid=1,2'
|
||||
};
|
||||
|
||||
videoMedia.rids =
|
||||
[
|
||||
{ id: '1', direction: 'send' },
|
||||
{ id: '2', direction: 'send' }
|
||||
];
|
||||
|
||||
let modifiedAnswer =
|
||||
{
|
||||
type : 'answer',
|
||||
sdp : sdpTransform.write(parsedSdp)
|
||||
};
|
||||
|
||||
return modifiedAnswer;
|
||||
}
|
||||
})
|
||||
.then((answer) =>
|
||||
{
|
||||
return this._peerconnection.setLocalDescription(answer);
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
let answer = this._peerconnection.localDescription;
|
||||
let parsedSdp = sdpTransform.parse(answer.sdp);
|
||||
|
||||
logger.debug('sent answer [parsed:%O, sdp:%s]', parsedSdp, answer.sdp);
|
||||
|
||||
accept(
|
||||
{
|
||||
answer :
|
||||
{
|
||||
type : answer.type,
|
||||
sdp : answer.sdp
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('"offer" request failed: %o', error);
|
||||
|
||||
reject(500, error.message);
|
||||
throw error;
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
// If Firefox trigger 'forcestreamsupdate' event due to bug:
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
|
||||
if (browser.firefox || browser.gecko)
|
||||
{
|
||||
// Not sure, but it thinks that the timeout does the trick.
|
||||
setTimeout(() => this.emit('forcestreamsupdate'), 500);
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
{
|
||||
logger.error('unknown method');
|
||||
|
||||
reject(404, 'unknown method');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_updateWebcams()
|
||||
{
|
||||
logger.debug('_updateWebcams()');
|
||||
|
||||
// Reset the list.
|
||||
this._webcams = new Map();
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() =>
|
||||
{
|
||||
return navigator.mediaDevices.enumerateDevices();
|
||||
})
|
||||
.then((devices) =>
|
||||
{
|
||||
for (let device of devices)
|
||||
{
|
||||
if (device.kind !== 'videoinput')
|
||||
continue;
|
||||
|
||||
this._webcams.set(device.deviceId, device);
|
||||
}
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
let array = Array.from(this._webcams.values());
|
||||
let len = array.length;
|
||||
let currentWebcamId = this._webcam ? this._webcam.deviceId : undefined;
|
||||
|
||||
logger.debug('_updateWebcams() [webcams:%o]', array);
|
||||
|
||||
if (len === 0)
|
||||
this._webcam = null;
|
||||
else if (!this._webcams.has(currentWebcamId))
|
||||
this._webcam = array[0];
|
||||
|
||||
this.emit('numwebcams', len);
|
||||
|
||||
this._emitWebcamType();
|
||||
});
|
||||
}
|
||||
|
||||
_getLocalStream(constraints)
|
||||
{
|
||||
logger.debug('_getLocalStream() [constraints:%o, webcam:%o]',
|
||||
constraints, this._webcam);
|
||||
|
||||
if (this._webcam)
|
||||
constraints.video.deviceId = { exact: this._webcam.deviceId };
|
||||
|
||||
return navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
|
||||
_createPeerConnection()
|
||||
{
|
||||
logger.debug('_createPeerConnection()');
|
||||
|
||||
this._peerconnection = new RTCPeerConnection({ iceServers: [] });
|
||||
|
||||
// TODO: TMP
|
||||
global.CLIENT = this;
|
||||
global.PC = this._peerconnection;
|
||||
|
||||
if (this._localStream)
|
||||
this._peerconnection.addStream(this._localStream);
|
||||
|
||||
this._peerconnection.addEventListener('iceconnectionstatechange', () =>
|
||||
{
|
||||
let state = this._peerconnection.iceConnectionState;
|
||||
|
||||
logger.debug('peerconnection "iceconnectionstatechange" event [state:%s]', state);
|
||||
|
||||
this.emit('connectionstate', state);
|
||||
});
|
||||
|
||||
this._peerconnection.addEventListener('addstream', (event) =>
|
||||
{
|
||||
let stream = event.stream;
|
||||
|
||||
logger.debug('peerconnection "addstream" event [stream:%o]', stream);
|
||||
|
||||
this.emit('addstream', stream);
|
||||
|
||||
// NOTE: For testing.
|
||||
let interval = setInterval(() =>
|
||||
{
|
||||
if (!stream.active)
|
||||
{
|
||||
logger.warn('stream inactive [stream:%o]', stream);
|
||||
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
stream.addEventListener('addtrack', (event) =>
|
||||
{
|
||||
let track = event.track;
|
||||
|
||||
logger.debug('stream "addtrack" event [track:%o]', track);
|
||||
|
||||
this.emit('addtrack', track);
|
||||
|
||||
// Firefox does not implement 'stream.onremovetrack' so let's use 'track.ended'.
|
||||
// But... track "ended" is neither fired.
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
|
||||
track.addEventListener('ended', () =>
|
||||
{
|
||||
logger.debug('track "ended" event [track:%o]', track);
|
||||
|
||||
this.emit('removetrack', track);
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: Not implemented in Firefox.
|
||||
stream.addEventListener('removetrack', (event) =>
|
||||
{
|
||||
let track = event.track;
|
||||
|
||||
logger.debug('stream "removetrack" event [track:%o]', track);
|
||||
|
||||
this.emit('removetrack', track);
|
||||
});
|
||||
});
|
||||
|
||||
this._peerconnection.addEventListener('removestream', (event) =>
|
||||
{
|
||||
let stream = event.stream;
|
||||
|
||||
logger.debug('peerconnection "removestream" event [stream:%o]', stream);
|
||||
|
||||
this.emit('removestream', stream);
|
||||
});
|
||||
}
|
||||
|
||||
_requestRenegotiation()
|
||||
{
|
||||
logger.debug('_requestRenegotiation()');
|
||||
|
||||
return this._protooPeer.send('reofferme');
|
||||
}
|
||||
|
||||
_restartIce()
|
||||
{
|
||||
logger.debug('_restartIce()');
|
||||
|
||||
return this._protooPeer.send('restartice')
|
||||
.then(() =>
|
||||
{
|
||||
logger.debug('_restartIce() succeded');
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('_restartIce() failed: %o', error);
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
_emitWebcamType()
|
||||
{
|
||||
let webcam = this._webcam;
|
||||
|
||||
if (!webcam)
|
||||
return;
|
||||
|
||||
if (/(back|rear)/i.test(webcam.label))
|
||||
{
|
||||
logger.debug('_emitWebcamType() | it seems to be a back camera');
|
||||
|
||||
this.emit('webcamtype', 'back');
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.debug('_emitWebcamType() | it seems to be a front camera');
|
||||
|
||||
this.emit('webcamtype', 'front');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use strict';
|
||||
|
||||
import debug from 'debug';
|
||||
|
||||
const APP_NAME = 'mediasoup-demo';
|
||||
|
||||
export default class Logger
|
||||
{
|
||||
constructor(prefix)
|
||||
{
|
||||
if (prefix)
|
||||
{
|
||||
this._debug = debug(APP_NAME + ':' + prefix);
|
||||
this._warn = debug(APP_NAME + ':WARN:' + prefix);
|
||||
this._error = debug(APP_NAME + ':ERROR:' + prefix);
|
||||
}
|
||||
else
|
||||
{
|
||||
this._debug = debug(APP_NAME);
|
||||
this._warn = debug(APP_NAME + ':WARN');
|
||||
this._error = debug(APP_NAME + ':ERROR');
|
||||
}
|
||||
|
||||
this._debug.log = console.info.bind(console);
|
||||
this._warn.log = console.warn.bind(console);
|
||||
this._error.log = console.error.bind(console);
|
||||
}
|
||||
|
||||
get debug()
|
||||
{
|
||||
return this._debug;
|
||||
}
|
||||
|
||||
get warn()
|
||||
{
|
||||
return this._warn;
|
||||
}
|
||||
|
||||
get error()
|
||||
{
|
||||
return this._error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider';
|
||||
import Logger from '../Logger';
|
||||
import muiTheme from './muiTheme';
|
||||
import Notifier from './Notifier';
|
||||
import Room from './Room';
|
||||
|
||||
const logger = new Logger('App'); // eslint-disable-line no-unused-vars
|
||||
|
||||
export default class App extends React.Component
|
||||
{
|
||||
constructor()
|
||||
{
|
||||
super();
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let props = this.props;
|
||||
|
||||
return (
|
||||
<MuiThemeProvider muiTheme={muiTheme}>
|
||||
<div data-component='App'>
|
||||
<Notifier ref='Notifier'/>
|
||||
|
||||
<Room
|
||||
peerId={props.peerId}
|
||||
roomId={props.roomId}
|
||||
onNotify={this.handleNotify.bind(this)}
|
||||
onHideNotification={this.handleHideNotification.bind(this)}
|
||||
/>
|
||||
</div>
|
||||
</MuiThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
handleNotify(data)
|
||||
{
|
||||
this.refs.Notifier.notify(data);
|
||||
}
|
||||
|
||||
handleHideNotification(uid)
|
||||
{
|
||||
this.refs.Notifier.hideNotification(uid);
|
||||
}
|
||||
}
|
||||
|
||||
App.propTypes =
|
||||
{
|
||||
peerId : React.PropTypes.string.isRequired,
|
||||
roomId : React.PropTypes.string.isRequired
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import IconButton from 'material-ui/IconButton/IconButton';
|
||||
import MicOffIcon from 'material-ui/svg-icons/av/mic-off';
|
||||
import VideoCamOffIcon from 'material-ui/svg-icons/av/videocam-off';
|
||||
import ChangeVideoCamIcon from 'material-ui/svg-icons/av/repeat';
|
||||
import Video from './Video';
|
||||
import Logger from '../Logger';
|
||||
|
||||
const logger = new Logger('LocalVideo'); // eslint-disable-line no-unused-vars
|
||||
|
||||
export default class LocalVideo extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
|
||||
this.state =
|
||||
{
|
||||
micMuted : false,
|
||||
webcam : props.stream && !!props.stream.getVideoTracks()[0],
|
||||
togglingWebcam : false
|
||||
};
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let props = this.props;
|
||||
let state = this.state;
|
||||
|
||||
return (
|
||||
<div data-component='LocalVideo' className={`state-${props.connectionState}`}>
|
||||
{props.stream ?
|
||||
<Video
|
||||
stream={props.stream}
|
||||
resolution={props.resolution}
|
||||
muted
|
||||
mirror={props.webcamType === 'front'}
|
||||
onResolutionChange={this.handleResolutionChange.bind(this)}
|
||||
/>
|
||||
:null}
|
||||
|
||||
<div className='controls'>
|
||||
<IconButton
|
||||
className='control'
|
||||
onClick={this.handleClickMuteMic.bind(this)}
|
||||
>
|
||||
<MicOffIcon
|
||||
color={!state.micMuted ? '#fff' : '#ff0000'}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
className='control'
|
||||
disabled={state.togglingWebcam}
|
||||
onClick={this.handleClickWebcam.bind(this)}
|
||||
>
|
||||
<VideoCamOffIcon
|
||||
color={state.webcam ? '#fff' : '#ff8a00'}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
{props.multipleWebcams ?
|
||||
<IconButton
|
||||
className='control'
|
||||
disabled={!state.webcam || state.togglingWebcam}
|
||||
onClick={this.handleClickChangeWebcam.bind(this)}
|
||||
>
|
||||
<ChangeVideoCamIcon
|
||||
color='#fff'
|
||||
/>
|
||||
</IconButton>
|
||||
:null}
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
<div className='peer-id'>{props.peerId}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps)
|
||||
{
|
||||
this.setState({ webcam: nextProps.stream && !!nextProps.stream.getVideoTracks()[0] });
|
||||
}
|
||||
|
||||
handleClickMuteMic()
|
||||
{
|
||||
logger.debug('handleClickMuteMic()');
|
||||
|
||||
let value = !this.state.micMuted;
|
||||
|
||||
this.props.onMicMute(value)
|
||||
.then(() =>
|
||||
{
|
||||
this.setState({ micMuted: value });
|
||||
});
|
||||
}
|
||||
|
||||
handleClickWebcam()
|
||||
{
|
||||
logger.debug('handleClickWebcam()');
|
||||
|
||||
let value = !this.state.webcam;
|
||||
|
||||
this.setState({ togglingWebcam: true });
|
||||
|
||||
this.props.onWebcamToggle(value)
|
||||
.then(() =>
|
||||
{
|
||||
this.setState({ webcam: value, togglingWebcam: false });
|
||||
})
|
||||
.catch(() =>
|
||||
{
|
||||
this.setState({ togglingWebcam: false });
|
||||
});
|
||||
}
|
||||
|
||||
handleClickChangeWebcam()
|
||||
{
|
||||
logger.debug('handleClickChangeWebcam()');
|
||||
|
||||
this.props.onWebcamChange();
|
||||
}
|
||||
|
||||
handleResolutionChange()
|
||||
{
|
||||
logger.debug('handleResolutionChange()');
|
||||
|
||||
this.props.onResolutionChange();
|
||||
}
|
||||
}
|
||||
|
||||
LocalVideo.propTypes =
|
||||
{
|
||||
peerId : React.PropTypes.string.isRequired,
|
||||
stream : React.PropTypes.object,
|
||||
resolution : React.PropTypes.string,
|
||||
multipleWebcams : React.PropTypes.bool.isRequired,
|
||||
webcamType : React.PropTypes.string,
|
||||
connectionState : React.PropTypes.string,
|
||||
onMicMute : React.PropTypes.func.isRequired,
|
||||
onWebcamToggle : React.PropTypes.func.isRequired,
|
||||
onWebcamChange : React.PropTypes.func.isRequired,
|
||||
onResolutionChange : React.PropTypes.func.isRequired
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import NotificationSystem from 'react-notification-system';
|
||||
|
||||
const STYLE =
|
||||
{
|
||||
NotificationItem :
|
||||
{
|
||||
DefaultStyle :
|
||||
{
|
||||
padding : '6px 10px',
|
||||
backgroundColor : 'rgba(255,255,255, 0.9)',
|
||||
fontFamily : 'Roboto',
|
||||
fontWeight : 400,
|
||||
fontSize : '1rem',
|
||||
cursor : 'default',
|
||||
WebkitUserSelect : 'none',
|
||||
MozUserSelect : 'none',
|
||||
userSelect : 'none',
|
||||
transition : '0.15s ease-in-out'
|
||||
},
|
||||
info :
|
||||
{
|
||||
color : '#000',
|
||||
borderTop : '2px solid rgba(255,0,78, 0.75)'
|
||||
},
|
||||
success :
|
||||
{
|
||||
color : '#000',
|
||||
borderTop : '4px solid rgba(73,206,62, 0.75)'
|
||||
},
|
||||
error :
|
||||
{
|
||||
color : '#000',
|
||||
borderTop : '4px solid #ff0014'
|
||||
}
|
||||
},
|
||||
Title :
|
||||
{
|
||||
DefaultStyle :
|
||||
{
|
||||
margin : '0 0 8px 0',
|
||||
fontFamily : 'Roboto',
|
||||
fontWeight : 500,
|
||||
fontSize : '1.1rem',
|
||||
userSelect : 'none',
|
||||
WebkitUserSelect : 'none',
|
||||
MozUserSelect : 'none'
|
||||
},
|
||||
info :
|
||||
{
|
||||
color : 'rgba(255,0,78, 0.85)'
|
||||
},
|
||||
success :
|
||||
{
|
||||
color : 'rgba(73,206,62, 0.9)'
|
||||
},
|
||||
error :
|
||||
{
|
||||
color : '#ff0014'
|
||||
}
|
||||
},
|
||||
Dismiss :
|
||||
{
|
||||
DefaultStyle :
|
||||
{
|
||||
display : 'none'
|
||||
}
|
||||
},
|
||||
Action :
|
||||
{
|
||||
DefaultStyle :
|
||||
{
|
||||
padding : '8px 24px',
|
||||
fontSize : '1.2rem',
|
||||
cursor : 'pointer',
|
||||
userSelect : 'none',
|
||||
WebkitUserSelect : 'none',
|
||||
MozUserSelect : 'none'
|
||||
},
|
||||
info :
|
||||
{
|
||||
backgroundColor : 'rgba(255,0,78, 1)'
|
||||
},
|
||||
success :
|
||||
{
|
||||
backgroundColor : 'rgba(73,206,62, 0.75)'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default class Notifier extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
return (
|
||||
<NotificationSystem ref='NotificationSystem' style={STYLE} allowHTML={false}/>
|
||||
);
|
||||
}
|
||||
|
||||
notify(data)
|
||||
{
|
||||
let data2;
|
||||
|
||||
switch (data.level)
|
||||
{
|
||||
case 'info' :
|
||||
data2 = Object.assign(
|
||||
{
|
||||
position : 'tr',
|
||||
dismissible : true,
|
||||
autoDismiss : 1
|
||||
}, data);
|
||||
break;
|
||||
|
||||
case 'success' :
|
||||
data2 = Object.assign(
|
||||
{
|
||||
position : 'tr',
|
||||
dismissible : true,
|
||||
autoDismiss : 1
|
||||
}, data);
|
||||
break;
|
||||
|
||||
case 'error' :
|
||||
data2 = Object.assign(
|
||||
{
|
||||
position : 'tr',
|
||||
dismissible : true,
|
||||
autoDismiss : 3
|
||||
}, data);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`unknown level "${data.level}"`);
|
||||
}
|
||||
|
||||
this.refs.NotificationSystem.addNotification(data2);
|
||||
}
|
||||
|
||||
hideNotification(uid)
|
||||
{
|
||||
this.refs.NotificationSystem.removeNotification(uid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import IconButton from 'material-ui/IconButton/IconButton';
|
||||
import VolumeOffIcon from 'material-ui/svg-icons/av/volume-off';
|
||||
import VideoOffIcon from 'material-ui/svg-icons/av/videocam-off';
|
||||
import classnames from 'classnames';
|
||||
import Video from './Video';
|
||||
import Logger from '../Logger';
|
||||
|
||||
const logger = new Logger('RemoteVideo'); // eslint-disable-line no-unused-vars
|
||||
|
||||
export default class RemoteVideo extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
|
||||
this.state =
|
||||
{
|
||||
audioMuted : false
|
||||
};
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let props = this.props;
|
||||
let state = this.state;
|
||||
let hasVideo = !!props.stream.getVideoTracks()[0];
|
||||
|
||||
global.SS = props.stream;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-component='RemoteVideo'
|
||||
className={classnames({ fullsize: !!props.fullsize })}
|
||||
>
|
||||
<Video
|
||||
stream={props.stream}
|
||||
muted={state.audioMuted}
|
||||
/>
|
||||
|
||||
<div className='controls'>
|
||||
<IconButton
|
||||
className='control'
|
||||
onClick={this.handleClickMuteAudio.bind(this)}
|
||||
>
|
||||
<VolumeOffIcon
|
||||
color={!state.audioMuted ? '#fff' : '#ff0000'}
|
||||
/>
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
className='control'
|
||||
onClick={this.handleClickDisableVideo.bind(this)}
|
||||
>
|
||||
<VideoOffIcon
|
||||
color={hasVideo ? '#fff' : '#ff8a00'}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<div className='info'>
|
||||
<div className='peer-id'>{props.peer.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleClickMuteAudio()
|
||||
{
|
||||
logger.debug('handleClickMuteAudio()');
|
||||
|
||||
let value = !this.state.audioMuted;
|
||||
|
||||
this.setState({ audioMuted: value });
|
||||
}
|
||||
|
||||
handleClickDisableVideo()
|
||||
{
|
||||
logger.debug('handleClickDisableVideo()');
|
||||
|
||||
let stream = this.props.stream;
|
||||
let msid = stream.id;
|
||||
let hasVideo = !!stream.getVideoTracks()[0];
|
||||
|
||||
if (hasVideo)
|
||||
this.props.onDisableVideo(msid);
|
||||
else
|
||||
this.props.onEnableVideo(msid);
|
||||
}
|
||||
}
|
||||
|
||||
RemoteVideo.propTypes =
|
||||
{
|
||||
peer : React.PropTypes.object.isRequired,
|
||||
stream : React.PropTypes.object.isRequired,
|
||||
fullsize : React.PropTypes.bool,
|
||||
onDisableVideo : React.PropTypes.func.isRequired,
|
||||
onEnableVideo : React.PropTypes.func.isRequired
|
||||
};
|
||||
@@ -0,0 +1,487 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import ClipboardButton from 'react-clipboard.js';
|
||||
import TransitionAppear from './TransitionAppear';
|
||||
import LocalVideo from './LocalVideo';
|
||||
import RemoteVideo from './RemoteVideo';
|
||||
import Stats from './Stats';
|
||||
import Logger from '../Logger';
|
||||
import utils from '../utils';
|
||||
import Client from '../Client';
|
||||
|
||||
const logger = new Logger('Room');
|
||||
const STATS_INTERVAL = 1000;
|
||||
|
||||
export default class Room extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
|
||||
this.state =
|
||||
{
|
||||
peers : {},
|
||||
localStream : null,
|
||||
localVideoResolution : null, // qvga / vga / hd / fullhd.
|
||||
multipleWebcams : false,
|
||||
webcamType : null,
|
||||
connectionState : null,
|
||||
remoteStreams : {},
|
||||
showStats : false,
|
||||
stats : null
|
||||
};
|
||||
|
||||
// Mounted flag
|
||||
this._mounted = false;
|
||||
// Client instance
|
||||
this._client = null;
|
||||
// Timer to retrieve RTC stats.
|
||||
this._statsTimer = null;
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let props = this.props;
|
||||
let state = this.state;
|
||||
let numPeers = Object.keys(state.remoteStreams).length;
|
||||
|
||||
return (
|
||||
<TransitionAppear duration={2000}>
|
||||
<div data-component='Room'>
|
||||
|
||||
<div className='room-link-wrapper'>
|
||||
<div className='room-link'>
|
||||
<ClipboardButton
|
||||
component='a'
|
||||
className='link'
|
||||
button-href={window.location.href}
|
||||
data-clipboard-text={window.location.href}
|
||||
onSuccess={this.handleRoomLinkCopied.bind(this)}
|
||||
onClick={() => {}} // Avoid link action.
|
||||
>
|
||||
invite people to this room
|
||||
</ClipboardButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='remote-videos'>
|
||||
{
|
||||
Object.keys(state.remoteStreams).map((msid) =>
|
||||
{
|
||||
let stream = state.remoteStreams[msid];
|
||||
let peer;
|
||||
|
||||
for (let peerId of Object.keys(state.peers))
|
||||
{
|
||||
peer = state.peers[peerId];
|
||||
|
||||
if (peer.msids.indexOf(msid) !== -1)
|
||||
break;
|
||||
}
|
||||
|
||||
if (!peer)
|
||||
return;
|
||||
|
||||
return (
|
||||
<TransitionAppear key={msid} duration={500}>
|
||||
<RemoteVideo
|
||||
peer={peer}
|
||||
stream={stream}
|
||||
fullsize={numPeers === 1}
|
||||
onDisableVideo={this.handleDisableRemoteVideo.bind(this)}
|
||||
onEnableVideo={this.handleEnableRemoteVideo.bind(this)}
|
||||
/>
|
||||
</TransitionAppear>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<TransitionAppear duration={500}>
|
||||
<div className='local-video'>
|
||||
<LocalVideo
|
||||
peerId={props.peerId}
|
||||
stream={state.localStream}
|
||||
resolution={state.localVideoResolution}
|
||||
multipleWebcams={state.multipleWebcams}
|
||||
webcamType={state.webcamType}
|
||||
connectionState={state.connectionState}
|
||||
onMicMute={this.handleLocalMute.bind(this)}
|
||||
onWebcamToggle={this.handleLocalWebcamToggle.bind(this)}
|
||||
onWebcamChange={this.handleLocalWebcamChange.bind(this)}
|
||||
onResolutionChange={this.handleLocalResolutionChange.bind(this)}
|
||||
/>
|
||||
|
||||
{state.showStats ?
|
||||
<TransitionAppear duration={500}>
|
||||
<Stats
|
||||
stats={state.stats || new Map()}
|
||||
onClose={this.handleStatsClose.bind(this)}
|
||||
/>
|
||||
</TransitionAppear>
|
||||
:
|
||||
<div
|
||||
className='show-stats'
|
||||
onClick={this.handleClickShowStats.bind(this)}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</TransitionAppear>
|
||||
</div>
|
||||
</TransitionAppear>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount()
|
||||
{
|
||||
// Set flag
|
||||
this._mounted = true;
|
||||
|
||||
// Run the client
|
||||
this._runClient();
|
||||
}
|
||||
|
||||
componentWillUnmount()
|
||||
{
|
||||
let state = this.state;
|
||||
|
||||
// Unset flag
|
||||
this._mounted = false;
|
||||
|
||||
// Close client
|
||||
this._client.removeAllListeners();
|
||||
this._client.close();
|
||||
|
||||
// Close local MediaStream
|
||||
if (state.localStream)
|
||||
utils.closeMediaStream(state.localStream);
|
||||
}
|
||||
|
||||
handleRoomLinkCopied()
|
||||
{
|
||||
logger.debug('handleRoomLinkCopied()');
|
||||
|
||||
this.props.onNotify(
|
||||
{
|
||||
level : 'success',
|
||||
position : 'tr',
|
||||
title : 'Room URL copied to the clipboard',
|
||||
message : 'Share it with others to join this room'
|
||||
});
|
||||
}
|
||||
|
||||
handleLocalMute(value)
|
||||
{
|
||||
logger.debug('handleLocalMute() [value:%s]', value);
|
||||
|
||||
let micTrack = this.state.localStream.getAudioTracks()[0];
|
||||
|
||||
if (!micTrack)
|
||||
return Promise.reject(new Error('no audio track'));
|
||||
|
||||
micTrack.enabled = !value;
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
handleLocalWebcamToggle(value)
|
||||
{
|
||||
logger.debug('handleLocalWebcamToggle() [value:%s]', value);
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() =>
|
||||
{
|
||||
if (value)
|
||||
return this._client.addVideo();
|
||||
else
|
||||
return this._client.removeVideo();
|
||||
})
|
||||
.then(() =>
|
||||
{
|
||||
let localStream = this.state.localStream;
|
||||
|
||||
this.setState({ localStream });
|
||||
});
|
||||
}
|
||||
|
||||
handleLocalWebcamChange()
|
||||
{
|
||||
logger.debug('handleLocalWebcamChange()');
|
||||
|
||||
this._client.changeWebcam();
|
||||
}
|
||||
|
||||
handleLocalResolutionChange()
|
||||
{
|
||||
logger.debug('handleLocalResolutionChange()');
|
||||
|
||||
this._client.changeVideoResolution();
|
||||
}
|
||||
|
||||
handleStatsClose()
|
||||
{
|
||||
logger.debug('handleStatsClose()');
|
||||
|
||||
this.setState({ showStats: false });
|
||||
this._stopStats();
|
||||
}
|
||||
|
||||
handleClickShowStats()
|
||||
{
|
||||
logger.debug('handleClickShowStats()');
|
||||
|
||||
this.setState({ showStats: true });
|
||||
this._startStats();
|
||||
}
|
||||
|
||||
handleDisableRemoteVideo(msid)
|
||||
{
|
||||
logger.debug('handleDisableRemoteVideo() [msid:"%s"]', msid);
|
||||
|
||||
this._client.disableRemoteVideo(msid);
|
||||
}
|
||||
|
||||
handleEnableRemoteVideo(msid)
|
||||
{
|
||||
logger.debug('handleEnableRemoteVideo() [msid:"%s"]', msid);
|
||||
|
||||
this._client.enableRemoteVideo(msid);
|
||||
}
|
||||
|
||||
_runClient()
|
||||
{
|
||||
let peerId = this.props.peerId;
|
||||
let roomId = this.props.roomId;
|
||||
|
||||
logger.debug('_runClient() [peerId:"%s", roomId:"%s"]', peerId, roomId);
|
||||
|
||||
this._client = new Client(peerId, roomId);
|
||||
|
||||
this._client.on('localstream', (stream, resolution) =>
|
||||
{
|
||||
this.setState(
|
||||
{
|
||||
localStream : stream,
|
||||
localVideoResolution : resolution
|
||||
});
|
||||
});
|
||||
|
||||
this._client.on('join', () =>
|
||||
{
|
||||
// Clear remote streams (for reconnections).
|
||||
this.setState({ remoteStreams: {} });
|
||||
|
||||
this.props.onNotify(
|
||||
{
|
||||
level : 'success',
|
||||
title : 'Yes!',
|
||||
message : 'You are in the room!',
|
||||
image : '/resources/images/room.svg',
|
||||
imageWidth : 80,
|
||||
imageHeight : 80
|
||||
});
|
||||
|
||||
// Start retrieving WebRTC stats (unless mobile)
|
||||
if (utils.isDesktop())
|
||||
{
|
||||
this.setState({ showStats: true });
|
||||
|
||||
setTimeout(() =>
|
||||
{
|
||||
this._startStats();
|
||||
}, STATS_INTERVAL / 2);
|
||||
}
|
||||
});
|
||||
|
||||
this._client.on('close', (error) =>
|
||||
{
|
||||
// Clear remote streams (for reconnections).
|
||||
this.setState({ remoteStreams: {} });
|
||||
|
||||
if (error)
|
||||
{
|
||||
this.props.onNotify(
|
||||
{
|
||||
level : 'error',
|
||||
title : 'Error',
|
||||
message : error.message
|
||||
});
|
||||
}
|
||||
|
||||
// Stop retrieving WebRTC stats.
|
||||
this._stopStats();
|
||||
});
|
||||
|
||||
this._client.on('disconnected', () =>
|
||||
{
|
||||
// Clear remote streams (for reconnections).
|
||||
this.setState({ remoteStreams: {} });
|
||||
|
||||
this.props.onNotify(
|
||||
{
|
||||
level : 'error',
|
||||
title : 'Warning',
|
||||
message : 'app disconnected'
|
||||
});
|
||||
|
||||
// Stop retrieving WebRTC stats.
|
||||
this._stopStats();
|
||||
});
|
||||
|
||||
this._client.on('numwebcams', (num) =>
|
||||
{
|
||||
this.setState(
|
||||
{
|
||||
multipleWebcams : (num > 1 ? true : false)
|
||||
});
|
||||
});
|
||||
|
||||
this._client.on('webcamtype', (type) =>
|
||||
{
|
||||
this.setState({ webcamType: type });
|
||||
});
|
||||
|
||||
this._client.on('peers', (peers) =>
|
||||
{
|
||||
let peersObject = {};
|
||||
|
||||
for (let peer of peers)
|
||||
{
|
||||
peersObject[peer.id] = peer;
|
||||
}
|
||||
|
||||
this.setState({ peers: peersObject });
|
||||
});
|
||||
|
||||
this._client.on('addpeer', (peer) =>
|
||||
{
|
||||
this.props.onNotify(
|
||||
{
|
||||
level : 'success',
|
||||
message : `${peer.id} joined the room`
|
||||
});
|
||||
|
||||
let peers = this.state.peers;
|
||||
|
||||
peers[peer.id] = peer;
|
||||
this.setState({ peers });
|
||||
});
|
||||
|
||||
this._client.on('updatepeer', (peer) =>
|
||||
{
|
||||
let peers = this.state.peers;
|
||||
|
||||
peers[peer.id] = peer;
|
||||
this.setState({ peers });
|
||||
});
|
||||
|
||||
this._client.on('removepeer', (peer) =>
|
||||
{
|
||||
this.props.onNotify(
|
||||
{
|
||||
level : 'info',
|
||||
message : `${peer.id} left the room`
|
||||
});
|
||||
|
||||
let peers = this.state.peers;
|
||||
|
||||
delete peers[peer.id];
|
||||
this.setState({ peers });
|
||||
});
|
||||
|
||||
this._client.on('connectionstate', (state) =>
|
||||
{
|
||||
this.setState({ connectionState: state });
|
||||
});
|
||||
|
||||
this._client.on('addstream', (stream) =>
|
||||
{
|
||||
let remoteStreams = this.state.remoteStreams;
|
||||
|
||||
remoteStreams[stream.id] = stream;
|
||||
this.setState({ remoteStreams });
|
||||
});
|
||||
|
||||
this._client.on('removestream', (stream) =>
|
||||
{
|
||||
let remoteStreams = this.state.remoteStreams;
|
||||
|
||||
delete remoteStreams[stream.id];
|
||||
this.setState({ remoteStreams });
|
||||
});
|
||||
|
||||
this._client.on('addtrack', () =>
|
||||
{
|
||||
let remoteStreams = this.state.remoteStreams;
|
||||
|
||||
this.setState({ remoteStreams });
|
||||
});
|
||||
|
||||
this._client.on('removetrack', () =>
|
||||
{
|
||||
let remoteStreams = this.state.remoteStreams;
|
||||
|
||||
this.setState({ remoteStreams });
|
||||
});
|
||||
|
||||
this._client.on('forcestreamsupdate', () =>
|
||||
{
|
||||
// Just firef for Firefox due to bug:
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
|
||||
this.forceUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
_startStats()
|
||||
{
|
||||
logger.debug('_startStats()');
|
||||
|
||||
getStats.call(this);
|
||||
|
||||
function getStats()
|
||||
{
|
||||
this._client.getStats()
|
||||
.then((stats) =>
|
||||
{
|
||||
if (!this._mounted)
|
||||
return;
|
||||
|
||||
this.setState({ stats });
|
||||
|
||||
this._statsTimer = setTimeout(() =>
|
||||
{
|
||||
getStats.call(this);
|
||||
}, STATS_INTERVAL);
|
||||
})
|
||||
.catch((error) =>
|
||||
{
|
||||
logger.error('getStats() failed: %o', error);
|
||||
|
||||
this.setState({ stats: null });
|
||||
|
||||
this._statsTimer = setTimeout(() =>
|
||||
{
|
||||
getStats.call(this);
|
||||
}, STATS_INTERVAL);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_stopStats()
|
||||
{
|
||||
logger.debug('_stopStats()');
|
||||
|
||||
this.setState({ stats: null });
|
||||
|
||||
clearTimeout(this._statsTimer);
|
||||
}
|
||||
}
|
||||
|
||||
Room.propTypes =
|
||||
{
|
||||
peerId : React.PropTypes.string.isRequired,
|
||||
roomId : React.PropTypes.string.isRequired,
|
||||
onNotify : React.PropTypes.func.isRequired,
|
||||
onHideNotification : React.PropTypes.func.isRequired
|
||||
};
|
||||
@@ -0,0 +1,497 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import browser from 'bowser';
|
||||
import Logger from '../Logger';
|
||||
|
||||
const logger = new Logger('Stats'); // eslint-disable-line no-unused-vars
|
||||
|
||||
// TODO: TMP
|
||||
global.BROWSER = browser;
|
||||
|
||||
export default class Stats extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
|
||||
this.state =
|
||||
{
|
||||
stats :
|
||||
{
|
||||
transport : null,
|
||||
audio : null,
|
||||
video : null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
componentWillMount()
|
||||
{
|
||||
let stats = this.props.stats;
|
||||
|
||||
this._processStats(stats);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps)
|
||||
{
|
||||
let stats = nextProps.stats;
|
||||
|
||||
this._processStats(stats);
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let state = this.state;
|
||||
|
||||
return (
|
||||
<div data-component='Stats'>
|
||||
<div
|
||||
className='close'
|
||||
onClick={this.handleCloseClick.bind(this)}
|
||||
/>
|
||||
{
|
||||
Object.keys(state.stats).map((blockName) =>
|
||||
{
|
||||
let block = state.stats[blockName];
|
||||
|
||||
if (!block)
|
||||
return;
|
||||
|
||||
let items = Object.keys(block).map((itemName) =>
|
||||
{
|
||||
let value = block[itemName];
|
||||
|
||||
if (value === undefined)
|
||||
return;
|
||||
|
||||
return (
|
||||
<div key={itemName} className='item'>
|
||||
<div className='key'>{itemName}</div>
|
||||
<div className='value'>{value}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
if (!items.length)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div key={blockName} className='block'>
|
||||
<h1>{blockName}</h1>
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
handleCloseClick()
|
||||
{
|
||||
this.props.onClose();
|
||||
}
|
||||
|
||||
_processStats(stats)
|
||||
{
|
||||
if (browser.check({ chrome: '58' }, true))
|
||||
{
|
||||
this._processStatsChrome58(stats);
|
||||
}
|
||||
else if (browser.check({ chrome: '40' }, true))
|
||||
{
|
||||
this._processStatsChromeOld(stats);
|
||||
}
|
||||
else if (browser.check({ firefox: '40' }, true))
|
||||
{
|
||||
this._processStatsFirefox(stats);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.warn('_processStats() | unsupported browser [name:"%s", version:%s]',
|
||||
browser.name, browser.version);
|
||||
}
|
||||
}
|
||||
|
||||
_processStatsChrome58(stats)
|
||||
{
|
||||
let transport = {};
|
||||
let audio = {};
|
||||
let video = {};
|
||||
let selectedCandidatePair = null;
|
||||
let localCandidates = {};
|
||||
let remoteCandidates = {};
|
||||
|
||||
for (let group of stats.values())
|
||||
{
|
||||
switch (group.type)
|
||||
{
|
||||
case 'transport':
|
||||
{
|
||||
transport['bytes sent'] = group.bytesSent;
|
||||
transport['bytes received'] = group.bytesReceived;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'candidate-pair':
|
||||
{
|
||||
if (!group.writable)
|
||||
break;
|
||||
|
||||
selectedCandidatePair = group;
|
||||
|
||||
transport['available bitrate'] =
|
||||
Math.round(group.availableOutgoingBitrate / 1000) + ' kbps';
|
||||
transport['current RTT'] =
|
||||
Math.round(group.currentRoundTripTime * 1000) + ' ms';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'local-candidate':
|
||||
{
|
||||
localCandidates[group.id] = group;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'remote-candidate':
|
||||
{
|
||||
remoteCandidates[group.id] = group;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'codec':
|
||||
{
|
||||
let mimeType = group.mimeType.split('/');
|
||||
let kind = mimeType[0];
|
||||
let codec = mimeType[1];
|
||||
let block;
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case 'audio':
|
||||
block = audio;
|
||||
break;
|
||||
case 'video':
|
||||
block = video;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!block)
|
||||
break;
|
||||
|
||||
block['codec'] = codec;
|
||||
block['payload type'] = group.payloadType;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'track':
|
||||
{
|
||||
if (group.kind !== 'video')
|
||||
break;
|
||||
|
||||
video['frame size'] = group.frameWidth + ' x ' + group.frameHeight;
|
||||
video['frames sent'] = group.framesSent;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'outbound-rtp':
|
||||
{
|
||||
if (group.isRemote)
|
||||
break;
|
||||
|
||||
let block;
|
||||
|
||||
switch (group.mediaType)
|
||||
{
|
||||
case 'audio':
|
||||
block = audio;
|
||||
break;
|
||||
case 'video':
|
||||
block = video;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!block)
|
||||
break;
|
||||
|
||||
block['ssrc'] = group.ssrc;
|
||||
block['bytes sent'] = group.bytesSent;
|
||||
block['packets sent'] = group.packetsSent;
|
||||
|
||||
if (block === video)
|
||||
block['frames encoded'] = group.framesEncoded;
|
||||
|
||||
block['NACK count'] = group.nackCount;
|
||||
block['PLI count'] = group.pliCount;
|
||||
block['FIR count'] = group.firCount;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post checks.
|
||||
|
||||
if (!video.ssrc)
|
||||
video = {};
|
||||
|
||||
if (!audio.ssrc)
|
||||
audio = {};
|
||||
|
||||
if (selectedCandidatePair)
|
||||
{
|
||||
let localCandidate = localCandidates[selectedCandidatePair.localCandidateId];
|
||||
let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId];
|
||||
|
||||
transport['protocol'] = localCandidate.protocol;
|
||||
transport['local IP'] = localCandidate.ip;
|
||||
transport['local port'] = localCandidate.port;
|
||||
transport['remote IP'] = remoteCandidate.ip;
|
||||
transport['remote port'] = remoteCandidate.port;
|
||||
}
|
||||
|
||||
// Set state.
|
||||
this.setState(
|
||||
{
|
||||
stats :
|
||||
{
|
||||
transport,
|
||||
audio,
|
||||
video
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_processStatsChromeOld(stats)
|
||||
{
|
||||
let transport = {};
|
||||
let audio = {};
|
||||
let video = {};
|
||||
|
||||
for (let group of stats.values())
|
||||
{
|
||||
switch (group.type)
|
||||
{
|
||||
case 'googCandidatePair':
|
||||
{
|
||||
if (group.googActiveConnection !== 'true')
|
||||
break;
|
||||
|
||||
let localAddress = group.googLocalAddress.split(':');
|
||||
let remoteAddress = group.googRemoteAddress.split(':');
|
||||
let localIP = localAddress[0];
|
||||
let localPort = localAddress[1];
|
||||
let remoteIP = remoteAddress[0];
|
||||
let remotePort = remoteAddress[1];
|
||||
|
||||
transport['protocol'] = group.googTransportType;
|
||||
transport['local IP'] = localIP;
|
||||
transport['local port'] = localPort;
|
||||
transport['remote IP'] = remoteIP;
|
||||
transport['remote port'] = remotePort;
|
||||
transport['bytes sent'] = group.bytesSent;
|
||||
transport['bytes received'] = group.bytesReceived;
|
||||
transport['RTT'] = Math.round(group.googRtt) + ' ms';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'VideoBwe':
|
||||
{
|
||||
transport['available bitrate'] =
|
||||
Math.round(group.googAvailableSendBandwidth / 1000) + ' kbps';
|
||||
transport['transmit bitrate'] =
|
||||
Math.round(group.googTransmitBitrate / 1000) + ' kbps';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ssrc':
|
||||
{
|
||||
if (group.packetsSent === undefined)
|
||||
break;
|
||||
|
||||
let block;
|
||||
|
||||
switch (group.mediaType)
|
||||
{
|
||||
case 'audio':
|
||||
block = audio;
|
||||
break;
|
||||
case 'video':
|
||||
block = video;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!block)
|
||||
break;
|
||||
|
||||
block['codec'] = group.googCodecName;
|
||||
block['ssrc'] = group.ssrc;
|
||||
block['bytes sent'] = group.bytesSent;
|
||||
block['packets sent'] = group.packetsSent;
|
||||
block['packets lost'] = group.packetsLost;
|
||||
|
||||
if (block === video)
|
||||
{
|
||||
block['frames encoded'] = group.framesEncoded;
|
||||
video['frame size'] =
|
||||
group.googFrameWidthSent + ' x ' + group.googFrameHeightSent;
|
||||
video['frame rate'] = group.googFrameRateSent;
|
||||
}
|
||||
|
||||
block['NACK count'] = group.googNacksReceived;
|
||||
block['PLI count'] = group.googPlisReceived;
|
||||
block['FIR count'] = group.googFirsReceived;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post checks.
|
||||
|
||||
if (!video.ssrc)
|
||||
video = {};
|
||||
|
||||
if (!audio.ssrc)
|
||||
audio = {};
|
||||
|
||||
// Set state.
|
||||
this.setState(
|
||||
{
|
||||
stats :
|
||||
{
|
||||
transport,
|
||||
audio,
|
||||
video
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_processStatsFirefox(stats)
|
||||
{
|
||||
let transport = {};
|
||||
let audio = {};
|
||||
let video = {};
|
||||
let selectedCandidatePair = null;
|
||||
let localCandidates = {};
|
||||
let remoteCandidates = {};
|
||||
|
||||
for (let group of stats.values())
|
||||
{
|
||||
// TODO: REMOVE
|
||||
global.STATS = stats;
|
||||
|
||||
switch (group.type)
|
||||
{
|
||||
case 'candidate-pair':
|
||||
{
|
||||
if (!group.selected)
|
||||
break;
|
||||
|
||||
selectedCandidatePair = group;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'local-candidate':
|
||||
{
|
||||
localCandidates[group.id] = group;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'remote-candidate':
|
||||
{
|
||||
remoteCandidates[group.id] = group;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case 'outbound-rtp':
|
||||
{
|
||||
if (group.isRemote)
|
||||
break;
|
||||
|
||||
let block;
|
||||
|
||||
switch (group.mediaType)
|
||||
{
|
||||
case 'audio':
|
||||
block = audio;
|
||||
break;
|
||||
case 'video':
|
||||
block = video;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!block)
|
||||
break;
|
||||
|
||||
block['ssrc'] = group.ssrc;
|
||||
block['bytes sent'] = group.bytesSent;
|
||||
block['packets sent'] = group.packetsSent;
|
||||
|
||||
if (block === video)
|
||||
{
|
||||
block['bitrate'] =
|
||||
Math.round(group.bitrateMean / 1000) + ' kbps';
|
||||
block['frames encoded'] = group.framesEncoded;
|
||||
video['frame rate'] = Math.round(group.framerateMean);
|
||||
}
|
||||
|
||||
block['NACK count'] = group.nackCount;
|
||||
block['PLI count'] = group.pliCount;
|
||||
block['FIR count'] = group.firCount;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post checks.
|
||||
|
||||
if (!video.ssrc)
|
||||
video = {};
|
||||
|
||||
if (!audio.ssrc)
|
||||
audio = {};
|
||||
|
||||
if (selectedCandidatePair)
|
||||
{
|
||||
let localCandidate = localCandidates[selectedCandidatePair.localCandidateId];
|
||||
let remoteCandidate = remoteCandidates[selectedCandidatePair.remoteCandidateId];
|
||||
|
||||
transport['protocol'] = localCandidate.transport;
|
||||
transport['local IP'] = localCandidate.ipAddress;
|
||||
transport['local port'] = localCandidate.portNumber;
|
||||
transport['remote IP'] = remoteCandidate.ipAddress;
|
||||
transport['remote port'] = remoteCandidate.portNumber;
|
||||
}
|
||||
|
||||
// Set state.
|
||||
this.setState(
|
||||
{
|
||||
stats :
|
||||
{
|
||||
transport,
|
||||
audio,
|
||||
video
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Stats.propTypes =
|
||||
{
|
||||
stats : React.PropTypes.object.isRequired,
|
||||
onClose : React.PropTypes.func.isRequired
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import ReactCSSTransitionGroup from 'react-addons-css-transition-group';
|
||||
|
||||
const DEFAULT_DURATION = 1000;
|
||||
|
||||
export default class TransitionAppear extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let props = this.props;
|
||||
let duration = props.hasOwnProperty('duration') ? props.duration : DEFAULT_DURATION;
|
||||
|
||||
return (
|
||||
<ReactCSSTransitionGroup
|
||||
component={FakeTransitionWrapper}
|
||||
transitionName='transition'
|
||||
transitionAppear={!!duration}
|
||||
transitionAppearTimeout={duration}
|
||||
transitionEnter={false}
|
||||
transitionLeave={false}
|
||||
>
|
||||
{this.props.children}
|
||||
</ReactCSSTransitionGroup>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TransitionAppear.propTypes =
|
||||
{
|
||||
children : React.PropTypes.any,
|
||||
duration : React.PropTypes.number
|
||||
};
|
||||
|
||||
class FakeTransitionWrapper extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let children = React.Children.toArray(this.props.children);
|
||||
|
||||
return children[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
FakeTransitionWrapper.propTypes =
|
||||
{
|
||||
children : React.PropTypes.any
|
||||
};
|
||||
@@ -0,0 +1,233 @@
|
||||
'use strict';
|
||||
|
||||
import React from 'react';
|
||||
import Logger from '../Logger';
|
||||
import classnames from 'classnames';
|
||||
import hark from 'hark';
|
||||
|
||||
const logger = new Logger('Video'); // eslint-disable-line no-unused-vars
|
||||
|
||||
export default class Video extends React.Component
|
||||
{
|
||||
constructor(props)
|
||||
{
|
||||
super(props);
|
||||
|
||||
this.state =
|
||||
{
|
||||
width : 0,
|
||||
height : 0,
|
||||
resolution : null,
|
||||
volume : 0 // Integer from 0 to 10.
|
||||
};
|
||||
|
||||
let stream = props.stream;
|
||||
|
||||
// Clean stream.
|
||||
// Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
|
||||
this._cleanStream(stream);
|
||||
|
||||
// Current MediaStreamTracks info.
|
||||
this._tracksHash = this._getTracksHash(stream);
|
||||
|
||||
// Periodic timer to show video dimensions.
|
||||
this._videoResolutionTimer = null;
|
||||
|
||||
// Hark instance.
|
||||
this._hark = null;
|
||||
}
|
||||
|
||||
render()
|
||||
{
|
||||
let props = this.props;
|
||||
let state = this.state;
|
||||
|
||||
return (
|
||||
<div data-component='Video'>
|
||||
{state.width ?
|
||||
(
|
||||
<div
|
||||
className={classnames('resolution', { clickable: !!props.resolution })}
|
||||
onClick={this.handleResolutionClick.bind(this)}
|
||||
>
|
||||
<p>{state.width}x{state.height}</p>
|
||||
{props.resolution ?
|
||||
<p>{props.resolution}</p>
|
||||
:null}
|
||||
</div>
|
||||
)
|
||||
:null}
|
||||
<div className='volume'>
|
||||
<div className={classnames('bar', `level${state.volume}`)}/>
|
||||
</div>
|
||||
|
||||
<video
|
||||
ref='video'
|
||||
className={classnames({ mirror: props.mirror })}
|
||||
autoPlay
|
||||
muted={props.muted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
componentDidMount()
|
||||
{
|
||||
let stream = this.props.stream;
|
||||
let video = this.refs.video;
|
||||
|
||||
video.srcObject = stream;
|
||||
|
||||
this._showVideoResolution();
|
||||
this._videoResolutionTimer = setInterval(() =>
|
||||
{
|
||||
this._showVideoResolution();
|
||||
}, 500);
|
||||
|
||||
if (stream.getAudioTracks().length > 0)
|
||||
{
|
||||
this._hark = hark(stream);
|
||||
|
||||
this._hark.on('speaking', () =>
|
||||
{
|
||||
logger.debug('hark "speaking" event');
|
||||
});
|
||||
|
||||
this._hark.on('stopped_speaking', () =>
|
||||
{
|
||||
logger.debug('hark "stopped_speaking" event');
|
||||
|
||||
this.setState({ volume: 0 });
|
||||
});
|
||||
|
||||
this._hark.on('volume_change', (volume, threshold) =>
|
||||
{
|
||||
if (volume < threshold)
|
||||
return;
|
||||
|
||||
// logger.debug('hark "volume_change" event [volume:%sdB, threshold:%sdB]', volume, threshold);
|
||||
|
||||
this.setState(
|
||||
{
|
||||
volume : Math.round((volume - threshold) * (-10) / threshold)
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount()
|
||||
{
|
||||
clearInterval(this._videoResolutionTimer);
|
||||
|
||||
if (this._hark)
|
||||
this._hark.stop();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps)
|
||||
{
|
||||
let stream = nextProps.stream;
|
||||
|
||||
// Clean stream.
|
||||
// Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
|
||||
this._cleanStream(stream);
|
||||
|
||||
// If there is something different in the stream, re-render it.
|
||||
|
||||
let previousTracksHash = this._tracksHash;
|
||||
|
||||
this._tracksHash = this._getTracksHash(stream);
|
||||
|
||||
if (this._tracksHash !== previousTracksHash)
|
||||
this.refs.video.srcObject = stream;
|
||||
}
|
||||
|
||||
handleResolutionClick()
|
||||
{
|
||||
if (!this.props.resolution)
|
||||
return;
|
||||
|
||||
logger.debug('handleResolutionClick()');
|
||||
|
||||
this.props.onResolutionChange();
|
||||
}
|
||||
|
||||
_getTracksHash(stream)
|
||||
{
|
||||
return stream.getTracks()
|
||||
.map((track) =>
|
||||
{
|
||||
return track.id;
|
||||
})
|
||||
.join('|');
|
||||
}
|
||||
|
||||
_showVideoResolution()
|
||||
{
|
||||
let video = this.refs.video;
|
||||
|
||||
this.setState(
|
||||
{
|
||||
width : video.videoWidth,
|
||||
height : video.videoHeight
|
||||
});
|
||||
}
|
||||
|
||||
_cleanStream(stream)
|
||||
{
|
||||
// Hack for Firefox bug:
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1347578
|
||||
|
||||
if (!stream)
|
||||
return;
|
||||
|
||||
let tracks = stream.getTracks();
|
||||
let previousNumTracks = tracks.length;
|
||||
|
||||
// Remove ended tracks.
|
||||
for (let track of tracks)
|
||||
{
|
||||
if (track.readyState === 'ended')
|
||||
{
|
||||
logger.warn('_cleanStream() | removing ended track [track:%o]', track);
|
||||
|
||||
stream.removeTrack(track);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are multiple live audio tracks (related to the bug?) just keep
|
||||
// the last one.
|
||||
while (stream.getAudioTracks().length > 1)
|
||||
{
|
||||
let track = stream.getAudioTracks()[0];
|
||||
|
||||
logger.warn('_cleanStream() | removing live audio track due the presence of others [track:%o]', track);
|
||||
|
||||
stream.removeTrack(track);
|
||||
}
|
||||
|
||||
// If there are multiple live video tracks (related to the bug?) just keep
|
||||
// the last one.
|
||||
while (stream.getVideoTracks().length > 1)
|
||||
{
|
||||
let track = stream.getVideoTracks()[0];
|
||||
|
||||
logger.warn('_cleanStream() | removing live video track due the presence of others [track:%o]', track);
|
||||
|
||||
stream.removeTrack(track);
|
||||
}
|
||||
|
||||
let numTracks = stream.getTracks().length;
|
||||
|
||||
if (numTracks !== previousNumTracks)
|
||||
logger.warn('_cleanStream() | num tracks changed from %s to %s', previousNumTracks, numTracks);
|
||||
}
|
||||
}
|
||||
|
||||
Video.propTypes =
|
||||
{
|
||||
stream : React.PropTypes.object.isRequired,
|
||||
resolution : React.PropTypes.string,
|
||||
muted : React.PropTypes.bool,
|
||||
mirror : React.PropTypes.bool,
|
||||
onResolutionChange : React.PropTypes.func
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
'use strict';
|
||||
|
||||
import getMuiTheme from 'material-ui/styles/getMuiTheme';
|
||||
import lightBaseTheme from 'material-ui/styles/baseThemes/lightBaseTheme';
|
||||
import { grey500 } from 'material-ui/styles/colors';
|
||||
|
||||
// NOTE: I should clone it
|
||||
let theme = lightBaseTheme;
|
||||
|
||||
theme.palette.borderColor = grey500;
|
||||
|
||||
let muiTheme = getMuiTheme(lightBaseTheme);
|
||||
|
||||
export default muiTheme;
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
import browser from 'bowser';
|
||||
import webrtc from 'webrtc-adapter'; // eslint-disable-line no-unused-vars
|
||||
import domready from 'domready';
|
||||
import UrlParse from 'url-parse';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import injectTapEventPlugin from 'react-tap-event-plugin';
|
||||
import randomString from 'random-string';
|
||||
import Logger from './Logger';
|
||||
import utils from './utils';
|
||||
import App from './components/App';
|
||||
|
||||
const REGEXP_FRAGMENT_ROOM_ID = new RegExp('^#room-id=([0-9a-zA-Z]+)$');
|
||||
const logger = new Logger();
|
||||
|
||||
logger.debug('detected browser [name:"%s", version:%s]', browser.name, browser.version);
|
||||
|
||||
injectTapEventPlugin();
|
||||
|
||||
domready(() =>
|
||||
{
|
||||
logger.debug('DOM ready');
|
||||
|
||||
// Load stuff and run
|
||||
utils.initialize()
|
||||
.then(run)
|
||||
.catch((error) =>
|
||||
{
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
|
||||
function run()
|
||||
{
|
||||
logger.debug('run() [environment:%s]', process.env.NODE_ENV);
|
||||
|
||||
let container = document.getElementById('mediasoup-demo-app-container');
|
||||
let urlParser = new UrlParse(window.location.href, true);
|
||||
let match = urlParser.hash.match(REGEXP_FRAGMENT_ROOM_ID);
|
||||
let peerId = randomString({ length: 8 }).toLowerCase();
|
||||
let roomId;
|
||||
|
||||
if (match)
|
||||
{
|
||||
roomId = match[1];
|
||||
}
|
||||
else
|
||||
{
|
||||
roomId = randomString({ length: 8 }).toLowerCase();
|
||||
window.location = `#room-id=${roomId}`;
|
||||
}
|
||||
|
||||
ReactDOM.render(<App peerId={peerId} roomId={roomId}/>, container);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
|
||||
const config = require('../config');
|
||||
|
||||
module.exports =
|
||||
{
|
||||
getProtooUrl(peerId, roomId)
|
||||
{
|
||||
let hostname = window.location.hostname;
|
||||
let port = config.protoo.listenPort;
|
||||
let url = `wss://${hostname}:${port}/?peer-id=${peerId}&room-id=${roomId}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
'use strict';
|
||||
|
||||
import browser from 'bowser';
|
||||
import Logger from './Logger';
|
||||
|
||||
const logger = new Logger('utils');
|
||||
|
||||
let mediaQueryDetectorElem;
|
||||
|
||||
module.exports =
|
||||
{
|
||||
initialize()
|
||||
{
|
||||
logger.debug('initialize()');
|
||||
|
||||
// Media query detector stuff
|
||||
mediaQueryDetectorElem = document.getElementById('mediasoup-demo-app-media-query-detector');
|
||||
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
isDesktop()
|
||||
{
|
||||
return !!mediaQueryDetectorElem.offsetParent;
|
||||
},
|
||||
|
||||
isMobile()
|
||||
{
|
||||
return !mediaQueryDetectorElem.offsetParent;
|
||||
},
|
||||
|
||||
isPlanB()
|
||||
{
|
||||
if (browser.chrome || browser.chromium || browser.opera)
|
||||
return true;
|
||||
else
|
||||
return false;
|
||||
},
|
||||
|
||||
closeMediaStream(stream)
|
||||
{
|
||||
if (!stream)
|
||||
return;
|
||||
|
||||
let tracks = stream.getTracks();
|
||||
|
||||
for (let i=0, len=tracks.length; i < len; i++)
|
||||
{
|
||||
tracks[i].stop();
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user