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 => ( + + ))} + +
+ +
+