diff --git a/lib/audioPlayer.js b/lib/audioPlayer.js
index eadb35f..0d234a1 100644
--- a/lib/audioPlayer.js
+++ b/lib/audioPlayer.js
@@ -38,12 +38,13 @@ module.exports =
initialized = true;
},
- /**
- * Play a sound
- * @param {String} name - Sound name
- * @param {[Float]} relativeVolume - Relative volume (0.0 - 1.0)
- */
- play(name, relativeVolume)
+ /**
+ * Play a sound
+ * @param {String} name - Sound name
+ * @param {String} deviceId - DeviceId to use for audio output
+ * @param {[Float]} relativeVolume - Relative volume (0.0 - 1.0)
+ */
+ play(name, deviceId, relativeVolume)
{
this.initialize();
@@ -62,6 +63,11 @@ module.exports =
sound.audio.pause();
sound.audio.currentTime = 0.0;
sound.audio.volume = (sound.volume || 1.0) * relativeVolume;
+
+ if (deviceId && sound.audio.setSinkId) {
+ sound.audio.setSinkId(deviceId);
+ }
+
sound.audio.play();
}
catch (error)
diff --git a/lib/components/App.jsx b/lib/components/App.jsx
index c23c46e..bf508d0 100644
--- a/lib/components/App.jsx
+++ b/lib/components/App.jsx
@@ -9,6 +9,8 @@ import Snackbar from 'material-ui/Snackbar';
import Notifier from './Notifier';
import Login from './Login';
import Phone from './Phone';
+import DevicesWatcher from './DevicesWatcher';
+import clone from 'clone';
const logger = new Logger('App');
@@ -71,6 +73,12 @@ export default class App extends React.Component
+
+
{component}
{
+ const devicesOld = localStorage.getItem('devices')
+ ? JSON.parse(localStorage.getItem('devices'))
+ : devices;
+
+ devicesOld.map((item) => {
+ if (!devices.find(x => x.deviceId === item.deviceId)) {
+ this.deviceRemoved(item, devices);
+ }
+ });
+
+ devices.map((item) => {
+ if (!devicesOld.find(x => x.deviceId === item.deviceId)) {
+ this.deviceAdded(item, devices);
+ }
+ });
+
+ localStorage.setItem('devices', JSON.stringify(devices));
+ });
+ }
+
+ deviceAdded(device) {
+ const {
+ mediaDevices: {
+ audioInput,
+ audioOutput,
+ videoInput
+ },
+ onMediaSettingsChange,
+ onNotify
+ } = this.props;
+
+ logger.debug('deviceAdded() [device:%o]', device);
+
+ onNotify({
+ level : 'success',
+ title : 'Device added',
+ message : device.label
+ });
+
+ if (device.kind === 'audioinput') {
+ localStorage.setItem('audioInputOld', audioInput);
+ onMediaSettingsChange('audioInput', device.deviceId);
+ } else if (device.kind === 'audiooutput') {
+ localStorage.setItem('audioOutputOld', audioOutput);
+ onMediaSettingsChange('audioOutput', device.deviceId);
+ } else if (device.kind === 'videoinput') {
+ localStorage.setItem('videoInputOld', videoInput);
+ onMediaSettingsChange('videoInput', device.deviceId);
+ }
+ }
+
+ deviceRemoved(device, devices) {
+ const {
+ mediaDevices: {
+ audioInput,
+ audioOutput,
+ videoInput
+ },
+ onMediaSettingsChange,
+ onNotify
+ } = this.props;
+
+ logger.debug('deviceRemoved() [device:%o]', device);
+
+ onNotify({
+ level : 'error',
+ title : 'Device removed',
+ message : device.label
+ });
+
+ // if (device.deviceId === audioInput) {
+ // if (audioInput && devices.find(x => x.deviceId === audioInput)) {
+ // onMediaSettingsChange('audioInput', audioInput)
+ // } else {
+ // onMediaSettingsChange('audioInput', 'default')
+ // }
+ // }
+ //
+ // if (device.deviceId === audioOutput) {
+ // if (audioOutput && devices.find(x => x.deviceId === audioOutput)) {
+ // onMediaSettingsChange('audioOutput', audioOutput)
+ // } else {
+ // onMediaSettingsChange('audioOutput', 'default')
+ // }
+ // }
+ //
+ // if (device.deviceId === videoInput) {
+ // const videoInput = localStorage.getItem('videoInput')
+ // if (videoInput && devices.find(x => x.deviceId === videoInput)) {
+ // onMediaSettingsChange('videoInput', videoInput)
+ // } else {
+ // onMediaSettingsChange('videoInput', null)
+ // }
+ // }
+
+ if (device.deviceId === audioInput) {
+ const audioInputOld = localStorage.getItem('audioInputOld');
+ if (audioInputOld && devices.find(x => x.deviceId === audioInputOld)) {
+ onMediaSettingsChange('audioInput', audioInputOld);
+ localStorage.removeItem('audioInputOld');
+ } else {
+ onMediaSettingsChange('audioInput', 'default');
+ }
+ }
+
+ if (device.deviceId === audioOutput) {
+ const audioOutputOld = localStorage.getItem('audioOutputOld');
+ if (audioOutputOld && devices.find(x => x.deviceId === audioOutputOld)) {
+ onMediaSettingsChange('audioOutput', audioOutputOld);
+ localStorage.removeItem('audioOutputOld');
+ } else {
+ onMediaSettingsChange('audioOutput', 'default');
+ }
+ }
+
+ if (device.deviceId === videoInput) {
+ const videoInputOld = localStorage.getItem('videoInputOld');
+ if (videoInputOld && devices.find(x => x.deviceId === videoInputOld)) {
+ onMediaSettingsChange('videoInput', videoInputOld);
+ localStorage.removeItem('videoInputOld');
+ } else {
+ onMediaSettingsChange('videoInput', null);
+ }
+ }
+ }
+
+ componentWillMount() {
+ this.reloadDevices();
+ navigator.mediaDevices.ondevicechange = () => this.reloadDevices();
+ }
+
+ render()
+ {
+ return null;
+ }
+}
+
+Dialer.propTypes =
+ {
+ settings: PropTypes.object.isRequired,
+ onNotify: PropTypes.func.isRequired,
+ onMediaSettingsChange: PropTypes.func.isRequired,
+ mediaDevices: PropTypes.object.isRequired
+ };
diff --git a/lib/components/Phone.jsx b/lib/components/Phone.jsx
index c99258b..adbb43b 100644
--- a/lib/components/Phone.jsx
+++ b/lib/components/Phone.jsx
@@ -103,6 +103,7 @@ export default class Phone extends React.Component
session={state.session}
onNotify={props.onNotify}
onHideNotification={props.onHideNotification}
+ mediaDevices={props.settings.media}
/>
:
null
@@ -266,7 +267,7 @@ export default class Phone extends React.Component
return;
}
- audioPlayer.play('ringing');
+ audioPlayer.play('ringing', settings.media.audioRinging);
this.setState({ incomingSession: session });
session.on('failed', () =>
@@ -349,15 +350,27 @@ export default class Phone extends React.Component
handleOutgoingCall(uri)
{
+ const {
+ settings: {
+ media: {
+ audioInput,
+ audioOutput,
+ videoInput
+ },
+ pcConfig
+ },
+ onNotify
+ } = this.props;
+
logger.debug('handleOutgoingCall() [uri:"%s"]', uri);
let session = this._ua.call(uri,
{
- pcConfig : this.props.settings.pcConfig || { iceServers: [] },
+ pcConfig : pcConfig || { iceServers: [] },
mediaConstraints :
{
- audio : true,
- video : true
+ audio: audioInput ? {deviceId: {exact: audioInput}} : true,
+ video : videoInput ? {deviceId: {exact: videoInput}} : true
},
rtcOfferConstraints :
{
@@ -373,21 +386,20 @@ export default class Phone extends React.Component
session.on('progress', () =>
{
- audioPlayer.play('ringback');
+ audioPlayer.play('ringback', audioOutput);
});
session.on('failed', (data) =>
{
audioPlayer.stop('ringback');
- audioPlayer.play('rejected');
+ audioPlayer.play('rejected', audioOutput);
this.setState({ session: null });
- this.props.onNotify(
- {
- level : 'error',
- title : 'Call failed',
- message : data.cause
- });
+ onNotify({
+ level : 'error',
+ title : 'Call failed',
+ message : data.cause
+ });
});
session.on('ended', () =>
@@ -399,7 +411,7 @@ export default class Phone extends React.Component
session.on('accepted', () =>
{
audioPlayer.stop('ringback');
- audioPlayer.play('answered');
+ audioPlayer.play('answered', audioOutput);
});
}
diff --git a/lib/components/Session.jsx b/lib/components/Session.jsx
index 76315a0..2c92d95 100644
--- a/lib/components/Session.jsx
+++ b/lib/components/Session.jsx
@@ -32,6 +32,7 @@ export default class Session extends React.Component
this._mounted = false;
// Local cloned stream
this._localClonedStream = null;
+ this._remoteStream = null;
}
render()
@@ -104,14 +105,30 @@ export default class Session extends React.Component
);
}
+ componentDidUpdate(prevProps) {
+ if (prevProps.mediaDevices.audioOutput !== this.props.mediaDevices.audioOutput) {
+ logger.debug('componentDidUpdate() audioOutput [old:%s] [device:%s]', prevProps.mediaDevices.audioOutput, this.props.mediaDevices.audioOutput);
+ this._attachStreamToElement(this.refs.remoteVideo, this.props.mediaDevices.audioOutput, this._remoteStream);
+ }
+
+ if (prevProps.mediaDevices.audioInput !== this.props.mediaDevices.audioInput) {
+ logger.debug('componentDidUpdate() audioInput [old:%s] [device:%s]', prevProps.mediaDevices.audioInput, this.props.mediaDevices.audioInput);
+ this._replaceLocalMedia();
+ }
+ }
+
componentDidMount()
{
- logger.debug('componentDidMount()');
-
this._mounted = true;
let localVideo = this.refs.localVideo;
- let session = this.props.session;
+ const {
+ session,
+ mediaDevices: {
+ audioOutput
+ }
+ } = this.props;
+
let peerconnection = session.connection;
let localStream = peerconnection.getLocalStreams()[0];
let remoteStream = peerconnection.getRemoteStreams()[0];
@@ -123,7 +140,7 @@ export default class Session extends React.Component
this._localClonedStream = localStream.clone();
// Display local stream
- localVideo.srcObject = this._localClonedStream;
+ this._attachStreamToElement(localVideo, audioOutput, this._localClonedStream);
setTimeout(() =>
{
@@ -140,7 +157,7 @@ export default class Session extends React.Component
{
logger.debug('already have a remote stream');
- this._handleRemoteStream(remoteStream);
+ this._handleRemoteStream(remoteStream);
}
if (session.isEstablished())
@@ -308,10 +325,14 @@ export default class Session extends React.Component
{
logger.debug('_handleRemoteStream() [stream:%o]', stream);
+ this._remoteStream = stream;
+
let remoteVideo = this.refs.remoteVideo;
+ const audioOutput = this.props.mediaDevices.audioOutput;
+
// Display remote stream
- remoteVideo.srcObject = stream;
+ this._attachStreamToElement(remoteVideo, audioOutput, stream);
this._checkRemoteVideo(stream);
@@ -325,7 +346,7 @@ export default class Session extends React.Component
logger.debug('remote stream "addtrack" event [track:%o]', track);
// Refresh remote video
- remoteVideo.srcObject = stream;
+ this._attachStreamToElement(remoteVideo, audioOutput, stream);
this._checkRemoteVideo(stream);
@@ -343,7 +364,7 @@ export default class Session extends React.Component
logger.debug('remote stream "removetrack" event');
// Refresh remote video
- remoteVideo.srcObject = stream;
+ this._attachStreamToElement(remoteVideo, audioOutput, stream);
this._checkRemoteVideo(stream);
});
@@ -362,11 +383,70 @@ export default class Session extends React.Component
this.setState({ remoteHasVideo: !!videoTrack });
}
+
+ /**
+ * Re/Attach stream to element with deviceId
+ * @param {HTMLMediaElement} element - Target audio/video element
+ * @param {Stream} stream - Stream to attach
+ * @param {String} deviceId - DeviceId to use for audio output
+ */
+ _attachStreamToElement(element, deviceId, stream) {
+ /*
+ 1. Pause stream before change
+ 2. Redirect to device
+ 3. Replace stream source if needed
+ 4. Play stream
+ */
+
+ element.pause();
+ element.setSinkId(deviceId)
+ .then(() => logger.debug('Stream directed to device [device:%s]', deviceId))
+ .catch(err => logger.error(err, deviceId));
+
+ if (element.srcObject !== stream) {
+ element.srcObject = stream;
+ }
+
+ element.play();
+ }
+
+ /**
+ * Update local input media
+ */
+ _replaceLocalMedia() {
+ const {
+ session,
+ mediaDevices
+ } = this.props;
+
+ /*
+ 1. Get new stream with proper constrains
+ 2. Remove current local streams
+ 3. Add new stream to connection
+ 4. Send update
+ */
+
+ navigator.mediaDevices.getUserMedia({
+ audio: mediaDevices.audioInput ? {deviceId: {exact: mediaDevices.audioInput}} : true,
+ video: mediaDevices.videoInput ? {deviceId: {exact: mediaDevices.videoInput}} : true
+ })
+ .then(streamNew => {
+ session.connection.getLocalStreams().forEach(stream => {
+ stream.getTracks().forEach(track => track.stop());
+ session.connection.removeStream(stream);
+ });
+
+ return session.connection.addStream(streamNew);
+ })
+ .then(() => session.renegotiate({ useUpdate: true }));
+ }
}
Session.propTypes =
{
- session : PropTypes.object.isRequired,
- onNotify : PropTypes.func.isRequired,
onHideNotification : PropTypes.func.isRequired,
+ onNotify : PropTypes.func.isRequired,
+ audioOutput : PropTypes.string,
+ session : PropTypes.object.isRequired,
+ mediaDevices : PropTypes.object.isRequired
};
diff --git a/lib/components/Settings.jsx b/lib/components/Settings.jsx
index da82646..9ba065c 100644
--- a/lib/components/Settings.jsx
+++ b/lib/components/Settings.jsx
@@ -22,15 +22,28 @@ export default class Settings extends React.Component
let settings = props.settings;
- this.state =
- {
- settings : clone(settings, false)
+ this.state = {
+ settings : clone(settings, false),
+ devices : JSON.parse(localStorage.devices)
};
}
+ componentDidMount()
+ {
+ const self = this;
+ window.addEventListener('storage', function(e) {
+ logger.debug('StorageEvent [event:%o]', e);
+
+ if (e.key === 'devices') {
+ self.setState({devices: JSON.parse(e.newValue)});
+ }
+ });
+ }
+
render()
{
- let settings = this.state.settings;
+ const settings = this.state.settings;
+ const devices = JSON.parse(localStorage.devices);
return (
@@ -195,6 +208,60 @@ export default class Settings extends React.Component
+
+
+ {devices.filter(x => x.kind === 'audioinput').map(x => (
+
+ ))}
+
+
+
+
+
+ {devices.filter(x => x.kind === 'audiooutput').map(x => (
+
+ ))}
+
+
+
+
+
+ {devices.filter(x => x.kind === 'audiooutput').map(x => (
+
+ ))}
+
+
+
+
+
+ {devices.filter(x => x.kind === 'videoinput').map(x => (
+
+ ))}
+
+
+
+
+