diff --git a/app/public/config/config.example.js b/app/public/config/config.example.js index 9aaff4b..9e16360 100644 --- a/app/public/config/config.example.js +++ b/app/public/config/config.example.js @@ -25,6 +25,16 @@ var config = { scaleResolutionDownBy: 2 }, { scaleResolutionDownBy: 1 } ], + /** + * White listing browsers that support audio output device selection. + * It is not yet fully implemented in Firefox. + * See: https://bugzilla.mozilla.org/show_bug.cgi?id=1498512 + */ + audioOutputSupportedBrowsers : + [ + 'chrome', + 'opera' + ], // Socket.io request timeout requestTimeout : 10000, transportOptions : diff --git a/app/src/RoomClient.js b/app/src/RoomClient.js index 1a603fb..630851a 100644 --- a/app/src/RoomClient.js +++ b/app/src/RoomClient.js @@ -244,6 +244,8 @@ export default class RoomClient this._audioDevices = {}; + this._audioOutputDevices = {}; + // mediasoup Consumers. // @type {Map} this._consumers = new Map(); @@ -456,6 +458,7 @@ export default class RoomClient await this._updateAudioDevices(); await this._updateWebcams(); + await this._updateAudioOutputDevices(); store.dispatch(requestActions.notify( { @@ -1107,6 +1110,37 @@ export default class RoomClient meActions.setAudioInProgress(false)); } + async changeAudioOutputDevice(deviceId) + { + logger.debug('changeAudioOutputDevice() [deviceId: %s]', deviceId); + + store.dispatch( + meActions.setAudioOutputInProgress(true)); + + try + { + const device = this._audioOutputDevices[deviceId]; + + if (!device) + throw new Error('Selected audio output device no longer avaibale'); + + logger.debug( + 'changeAudioOutputDevice() | new selected [audio output device:%o]', + device); + + store.dispatch(settingsActions.setSelectedAudioOutputDevice(deviceId)); + + await this._updateAudioOutputDevices(); + } + catch (error) + { + logger.error('changeAudioOutputDevice() failed: %o', error); + } + + store.dispatch( + meActions.setAudioOutputInProgress(false)); + } + async changeVideoResolution(resolution) { logger.debug('changeVideoResolution() [resolution: %s]', resolution); @@ -2716,7 +2750,20 @@ export default class RoomClient if (joinVideo && this._mediasoupDevice.canProduce('video')) this.enableWebcam(); } + + await this._updateAudioOutputDevices(); + const { selectedAudioOutputDevice } = store.getState().settings; + + if (!selectedAudioOutputDevice && this._audioOutputDevices !== {}) + { + store.dispatch( + settingsActions.setSelectedAudioOutputDevice( + Object.keys(this._audioOutputDevices)[0] + ) + ); + } + store.dispatch(roomActions.setRoomState('connected')); // Clean all the existing notifications. @@ -3515,4 +3562,35 @@ export default class RoomClient logger.error('_getWebcamDeviceId() failed:%o', error); } } + + async _updateAudioOutputDevices() + { + logger.debug('_updateAudioOutputDevices()'); + + // Reset the list. + this._audioOutputDevices = {}; + + try + { + logger.debug('_updateAudioOutputDevices() | calling enumerateDevices()'); + + const devices = await navigator.mediaDevices.enumerateDevices(); + + for (const device of devices) + { + if (device.kind !== 'audiooutput') + continue; + + this._audioOutputDevices[device.deviceId] = device; + } + + store.dispatch( + meActions.setAudioOutputDevices(this._audioOutputDevices)); + } + catch (error) + { + logger.error('_updateAudioOutputDevices() failed:%o', error); + } + } + } diff --git a/app/src/__tests__/Room.spec.js b/app/src/__tests__/Room.spec.js index a866e06..3c802d3 100644 --- a/app/src/__tests__/Room.spec.js +++ b/app/src/__tests__/Room.spec.js @@ -31,6 +31,8 @@ beforeEach(() => me : { audioDevices : null, audioInProgress : false, + audioOutputDevices : null, + audioOutputInProgress : false, canSendMic : false, canSendWebcam : false, canShareFiles : false, @@ -72,11 +74,12 @@ beforeEach(() => windowConsumer : null }, settings : { - advancedMode : true, - displayName : 'Jest Tester', - resolution : 'ultra', - selectedAudioDevice : 'default', - selectedWebcam : 'soifjsiajosjfoi' + advancedMode : true, + displayName : 'Jest Tester', + resolution : 'ultra', + selectedAudioDevice : 'default', + selectedAudioOutputDevice : 'default', + selectedWebcam : 'soifjsiajosjfoi' }, toolarea : { currentToolTab : 'chat', diff --git a/app/src/actions/meActions.js b/app/src/actions/meActions.js index ec9f00d..9572dcb 100644 --- a/app/src/actions/meActions.js +++ b/app/src/actions/meActions.js @@ -51,6 +51,12 @@ export const setAudioDevices = (devices) => payload : { devices } }); +export const setAudioOutputDevices = (devices) => + ({ + type : 'SET_AUDIO_OUTPUT_DEVICES', + payload : { devices } + }); + export const setWebcamDevices = (devices) => ({ type : 'SET_WEBCAM_DEVICES', @@ -68,6 +74,12 @@ export const setAudioInProgress = (flag) => type : 'SET_AUDIO_IN_PROGRESS', payload : { flag } }); + +export const setAudioOutputInProgress = (flag) => + ({ + type : 'SET_AUDIO_OUTPUT_IN_PROGRESS', + payload : { flag } + }); export const setWebcamInProgress = (flag) => ({ diff --git a/app/src/actions/settingsActions.js b/app/src/actions/settingsActions.js index 79b5ef2..9603eaf 100644 --- a/app/src/actions/settingsActions.js +++ b/app/src/actions/settingsActions.js @@ -4,6 +4,12 @@ export const setSelectedAudioDevice = (deviceId) => payload : { deviceId } }); +export const setSelectedAudioOutputDevice = (deviceId) => + ({ + type : 'CHANGE_AUDIO_OUTPUT_DEVICE', + payload : { deviceId } + }); + export const setSelectedWebcamDevice = (deviceId) => ({ type : 'CHANGE_WEBCAM', diff --git a/app/src/components/PeerAudio/AudioPeers.js b/app/src/components/PeerAudio/AudioPeers.js index 671f4a2..d02f26d 100644 --- a/app/src/components/PeerAudio/AudioPeers.js +++ b/app/src/components/PeerAudio/AudioPeers.js @@ -7,7 +7,8 @@ import PeerAudio from './PeerAudio'; const AudioPeers = (props) => { const { - micConsumers + micConsumers, + audioOutputDevice } = props; return ( @@ -19,6 +20,7 @@ const AudioPeers = (props) => ); }) @@ -29,12 +31,14 @@ const AudioPeers = (props) => AudioPeers.propTypes = { - micConsumers : PropTypes.array + micConsumers : PropTypes.array, + audioOutputDevice : PropTypes.string }; const mapStateToProps = (state) => ({ - micConsumers : micConsumerSelector(state) + micConsumers : micConsumerSelector(state), + audioOutputDevice : state.settings.selectedAudioOutputDevice }); const AudioPeersContainer = connect( @@ -45,7 +49,8 @@ const AudioPeersContainer = connect( areStatesEqual : (next, prev) => { return ( - prev.consumers === next.consumers + prev.consumers === next.consumers && + prev.settings.selectedAudioOutputDevice === next.settings.selectedAudioOutputDevice ); } } diff --git a/app/src/components/PeerAudio/PeerAudio.js b/app/src/components/PeerAudio/PeerAudio.js index 38d7faf..c5a4396 100644 --- a/app/src/components/PeerAudio/PeerAudio.js +++ b/app/src/components/PeerAudio/PeerAudio.js @@ -10,6 +10,7 @@ export default class PeerAudio extends React.PureComponent // Latest received audio track. // @type {MediaStreamTrack} this._audioTrack = null; + this._audioOutputDevice = null; } render() @@ -24,17 +25,19 @@ export default class PeerAudio extends React.PureComponent componentDidMount() { - const { audioTrack } = this.props; + const { audioTrack, audioOutputDevice } = this.props; this._setTrack(audioTrack); + this._setOutputDevice(audioOutputDevice); } // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(nextProps) { - const { audioTrack } = nextProps; - + const { audioTrack, audioOutputDevice } = nextProps; + this._setTrack(audioTrack); + this._setOutputDevice(audioOutputDevice); } _setTrack(audioTrack) @@ -60,9 +63,23 @@ export default class PeerAudio extends React.PureComponent audio.srcObject = null; } } + + _setOutputDevice(audioOutputDevice) + { + if (this._audioOutputDevice === audioOutputDevice) + return; + + this._audioOutputDevice = audioOutputDevice; + + const { audio } = this.refs; + + if (audioOutputDevice && typeof audio.setSinkId === 'function') + audio.setSinkId(audioOutputDevice); + } } PeerAudio.propTypes = { - audioTrack : PropTypes.any + audioTrack : PropTypes.any, + audioOutputDevice : PropTypes.string }; diff --git a/app/src/components/Settings/Settings.js b/app/src/components/Settings/Settings.js index 91ba0db..5a4a09a 100644 --- a/app/src/components/Settings/Settings.js +++ b/app/src/components/Settings/Settings.js @@ -130,6 +130,13 @@ const Settings = ({ audioDevices = Object.values(me.audioDevices); else audioDevices = []; + + let audioOutputDevices; + + if (me.audioOutputDevices) + audioOutputDevices = Object.values(me.audioOutputDevices); + else + audioOutputDevices = []; return ( + { + 'audioOutputSupportedBrowsers' in window.config && + window.config.audioOutputSupportedBrowsers.includes(me.browser.name) && +
+ + + + { audioOutputDevices.length > 0 ? + intl.formatMessage({ + id : 'settings.selectAudioOutput', + defaultMessage : 'Select audio output device' + }) + : + intl.formatMessage({ + id : 'settings.cantSelectAudioOutput', + defaultMessage : 'Unable to select audio output device' + }) + } + + +
+ }