diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js index 8b2b07528f9cd4affe0b763aa4a79bd3cd04174c..00b959ca451ebaedb10080d93f361cfa3d363bff 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js @@ -664,6 +664,21 @@ export default class SIPBridge extends BaseAudioBridge { return navigator.mediaDevices.getUserMedia(constraints).then(handleMediaSuccess); } + liveChangeInputDevice(deviceId) { + const constraints = { + audio: { + deviceId, + }, + }; + + return navigator.mediaDevices.getUserMedia(constraints).then((stream) => { + const peer = this.getPeerConnection(); + const senders = peer.getSenders()[0]; + const firstTrack = stream.getAudioTracks()[0]; + senders.replaceTrack(firstTrack); + }); + } + async changeOutputDevice(value) { const audioContext = document.querySelector(MEDIA_TAG); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx index 90be874a036a3eaa2f7fb12e644011cdce02e624..f63ff9c4e7ed43ab2293c43d11cda58c58e012b1 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/component.jsx @@ -5,6 +5,7 @@ import { defineMessages, intlShape, injectIntl } from 'react-intl'; import Button from '/imports/ui/components/button/component'; import getFromUserSettings from '/imports/ui/services/users-settings'; import withShortcutHelper from '/imports/ui/components/shortcut-help/service'; +import InputStreamLiveSelectorContainer from './input-stream-live-selector/container'; import { styles } from './styles'; const intlMessages = defineMessages({ @@ -41,6 +42,11 @@ const propTypes = { }; class AudioControls extends PureComponent { + constructor(props) { + super(props); + this.renderLeaveButton = this.renderLeaveButton.bind(this); + } + componentDidMount() { const { processToggleMuteFromOutside } = this.props; if (Meteor.settings.public.allowOutsideCommands.toggleSelfVoice @@ -49,31 +55,46 @@ class AudioControls extends PureComponent { } } + renderLeaveButton() { + const { + listenOnly, + inAudio, + isVoiceUser, + handleLeaveAudio, + shortcuts, + disable, + intl, + } = this.props; + return (inAudio && isVoiceUser && listenOnly) ? ( + <Button + onClick={handleLeaveAudio} + disabled={disable} + hideLabel + aria-label={intl.formatMessage(intlMessages.leaveAudio)} + label={intl.formatMessage(intlMessages.leaveAudio)} + color="primary" + icon="listen" + size="lg" + circle + accessKey={shortcuts.leaveAudio} + /> + ) : (<InputStreamLiveSelectorContainer />); + } + render() { const { handleToggleMuteMicrophone, handleJoinAudio, - handleLeaveAudio, showMute, muted, disable, talking, inAudio, - listenOnly, intl, shortcuts, isVoiceUser, } = this.props; - let joinIcon = 'audio_off'; - if (inAudio) { - if (listenOnly) { - joinIcon = 'listen'; - } else { - joinIcon = 'audio_on'; - } - } - return ( <span className={styles.container}> {showMute && isVoiceUser @@ -92,25 +113,29 @@ class AudioControls extends PureComponent { icon={muted ? 'mute' : 'unmute'} size="lg" circle - accessKey={shortcuts.toggleMute} + // accessKey={shortcuts.toggleMute} /> ) : null} - <Button - className={cx(styles.button, inAudio || styles.btn)} - onClick={inAudio ? handleLeaveAudio : handleJoinAudio} - disabled={disable} - hideLabel - aria-label={inAudio ? intl.formatMessage(intlMessages.leaveAudio) - : intl.formatMessage(intlMessages.joinAudio)} - label={inAudio ? intl.formatMessage(intlMessages.leaveAudio) - : intl.formatMessage(intlMessages.joinAudio)} - color={inAudio ? 'primary' : 'default'} - ghost={!inAudio} - icon={joinIcon} - size="lg" - circle - accessKey={inAudio ? shortcuts.leaveAudio : shortcuts.joinAudio} - /> + { + (inAudio) + ? this.renderLeaveButton() + : ( + <Button + className={cx(styles.button, styles.btn)} + onClick={handleJoinAudio} + disabled={disable} + hideLabel + aria-label={intl.formatMessage(intlMessages.joinAudio)} + label={intl.formatMessage(intlMessages.joinAudio)} + color="default" + ghost + icon={'audio_off'} + size="lg" + circle + accessKey={shortcuts.joinAudio} + /> + ) + } </span>); } } diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx new file mode 100644 index 0000000000000000000000000000000000000000..950d70384cdd5df095c5789d3fe17a5af5dec940 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/component.jsx @@ -0,0 +1,158 @@ +import React, { Component, Fragment } from 'react'; +import { defineMessages, injectIntl } from 'react-intl'; +import PropTypes from 'prop-types'; +import Button from '/imports/ui/components/button/component'; +import Dropdown from '/imports/ui/components/dropdown/component'; +import DropdownTrigger from '/imports/ui/components/dropdown/trigger/component'; +import DropdownContent from '/imports/ui/components/dropdown/content/component'; +import DropdownList from '/imports/ui/components/dropdown/list/component'; +import DropdownListSeparator from '/imports/ui/components/dropdown/list/separator/component'; +import DropdownListItem from '/imports/ui/components/dropdown/list/item/component'; +import DropdownListTitle from '/imports/ui/components/dropdown/list/title/component'; +import withShortcutHelper from '/imports/ui/components/shortcut-help/service'; + +import { styles } from '../styles'; + +const AUDIO_INPUT = 'audioinput'; +const AUDIO_OUTPUT = 'audiooutput'; + +const intlMessages = defineMessages({ + changeLeaveAudio: { + id: 'app.audio.changeLeaveAudio', + description: 'Change/Leave audio button label', + }, + leaveAudio: { + id: 'app.audio.leaveAudio', + description: 'Leave audio dropdown item label', + }, + loading: { + id: 'app.audio.loading', + description: 'Loading audio dropdown item label', + }, + input: { + id: 'app.audio.input', + description: 'Input audio dropdown item label', + }, + output: { + id: 'app.audio.output', + description: 'Output audio dropdown item label', + }, +}); + +const propTypes = { + liveChangeInputDevice: PropTypes.func.isRequired, + exitAudio: PropTypes.func.isRequired, + liveChangeOutputDevice: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + shortcuts: PropTypes.object.isRequired, +}; + +class InputStreamLiveSelector extends Component { + constructor(props) { + super(props); + this.setInputDevices = this.setInputDevices.bind(this); + this.setOutputDevices = this.setOutputDevices.bind(this); + this.renderDeviceList = this.renderDeviceList.bind(this); + this.state = { + audioInputDevices: null, + audioOutputDevices: null, + }; + } + + componentDidMount() { + this.setInputDevices(); + this.setOutputDevices(); + } + + setInputDevices() { + navigator.mediaDevices.enumerateDevices() + .then((devices) => { + this.setState({ + audioInputDevices: devices.filter(i => i.kind === AUDIO_INPUT), + }); + }); + } + + setOutputDevices() { + navigator.mediaDevices.enumerateDevices() + .then((devices) => { + this.setState({ + audioOutputDevices: devices.filter(i => i.kind === AUDIO_OUTPUT), + }); + }); + } + + renderDeviceList(list, callback, title) { + const { intl } = this.props; + return [ + <DropdownListTitle>{title}</DropdownListTitle>, + list ? list.map(device => ( + <DropdownListItem + key={device.deviceId} + label={device.label} + onClick={() => callback(device.deviceId)} + /> + )) + : ( + <DropdownListItem + label={intl.formatMessage(intlMessages.loading)} + /> + ), + <DropdownListSeparator />, + ]; + } + + render() { + const { audioInputDevices, audioOutputDevices } = this.state; + const { + liveChangeInputDevice, + exitAudio, + liveChangeOutputDevice, + intl, + shortcuts, + } = this.props; + return ( + <Dropdown> + <DropdownTrigger> + <Button + aria-label={intl.formatMessage(intlMessages.changeLeaveAudio)} + label={intl.formatMessage(intlMessages.changeLeaveAudio)} + hideLabel + color="primary" + icon="audio_on" + size="lg" + circle + onClick={()=>{ + this.setInputDevices(); + this.setOutputDevices(); + }} + accessKey={shortcuts.leaveAudio} + /> + </DropdownTrigger> + <DropdownContent> + <DropdownList className={styles.dropdownListContainer}> + {this.renderDeviceList( + audioInputDevices, + liveChangeInputDevice, + intl.formatMessage(intlMessages.input), + )} + {this.renderDeviceList( + audioOutputDevices, + liveChangeOutputDevice, + intl.formatMessage(intlMessages.output) + )} + <DropdownListItem + className={styles.stopButton} + label={intl.formatMessage(intlMessages.leaveAudio)} + onClick={() => exitAudio()} + /> + </DropdownList> + </DropdownContent> + </Dropdown> + ); + } +} + +InputStreamLiveSelector.propTypes = propTypes; + +export default withShortcutHelper(injectIntl(InputStreamLiveSelector)); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx new file mode 100644 index 0000000000000000000000000000000000000000..25d30c32ee8c2195bccbd9c90843a089d1124a19 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/input-stream-live-selector/container.jsx @@ -0,0 +1,20 @@ +import React, { PureComponent } from 'react'; +import { withTracker } from 'meteor/react-meteor-data'; +import InputStreamLiveSelector from './component'; +import Service from '../../service'; + +class InputStreamLiveSelectorContainer extends PureComponent { + render() { + return ( + <InputStreamLiveSelector {...this.props} /> + ); + } +} + +export default withTracker(() => { + return { + liveChangeInputDevice: Service.liveChangeInputDevice, + liveChangeOutputDevice: Service.changeOutputDevice, + exitAudio: Service.exitAudio, + }; +})(InputStreamLiveSelectorContainer); diff --git a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/styles.scss b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/styles.scss index 702f765146bfd25adb75f2b88c60bb9a0c21877a..2f710c5aabb080ed89baf4443d3b47fc7d7a96d0 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/audio-controls/styles.scss +++ b/bigbluebutton-html5/imports/ui/components/audio/audio-controls/styles.scss @@ -59,4 +59,14 @@ 100% { box-shadow: 0 0 0 0 transparent; } +} + +.dropdownListContainer { + min-width: 10rem; +} + +.stopButton { + & > span { + color: var(--color-danger); + } } \ No newline at end of file diff --git a/bigbluebutton-html5/imports/ui/components/audio/service.js b/bigbluebutton-html5/imports/ui/components/audio/service.js index 0e7ffaf27c29391c97fb420a61722c70a7fc7c81..e6446e5453affc196848f2a276a0b70147a0867f 100755 --- a/bigbluebutton-html5/imports/ui/components/audio/service.js +++ b/bigbluebutton-html5/imports/ui/components/audio/service.js @@ -70,6 +70,7 @@ export default { joinEchoTest: () => AudioManager.joinEchoTest(), toggleMuteMicrophone: debounce(toggleMuteMicrophone, 500, { leading: true, trailing: false }), changeInputDevice: inputDeviceId => AudioManager.changeInputDevice(inputDeviceId), + liveChangeInputDevice: inputDeviceId => AudioManager.liveChangeInputDevice(inputDeviceId), changeOutputDevice: outputDeviceId => AudioManager.changeOutputDevice(outputDeviceId), isConnected: () => AudioManager.isConnected, isTalking: () => AudioManager.isTalking, diff --git a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js index 4b7f36cad3df23ddc044cd9f22e8dc8962bdf088..092c5c91fa638a28ae43b6fc40278f77355c1482 100755 --- a/bigbluebutton-html5/imports/ui/services/audio-manager/index.js +++ b/bigbluebutton-html5/imports/ui/services/audio-manager/index.js @@ -476,6 +476,14 @@ class AudioManager { .catch(handleChangeInputDeviceError); } + liveChangeInputDevice(deviceId) { + const handleChangeInputDeviceSuccess = (inputDevice) => { + this.inputDevice = inputDevice; + return Promise.resolve(inputDevice); + }; + this.bridge.liveChangeInputDevice(deviceId).then(handleChangeInputDeviceSuccess); + } + async changeOutputDevice(deviceId) { this.outputDeviceId = await this.bridge.changeOutputDevice(deviceId); } diff --git a/bigbluebutton-html5/private/locales/en.json b/bigbluebutton-html5/private/locales/en.json index 5ac23b4386e3f924bdbea7424b61bac80402b97c..5e49653ce15a267ca1a15e18ff52631c0ee931ac 100755 --- a/bigbluebutton-html5/private/locales/en.json +++ b/bigbluebutton-html5/private/locales/en.json @@ -429,9 +429,13 @@ "app.audioManager.mediaError": "Error: There was an issue getting your media devices", "app.audio.joinAudio": "Join audio", "app.audio.leaveAudio": "Leave audio", + "app.audio.changeLeaveAudio": "Change/Leave audio", "app.audio.enterSessionLabel": "Enter session", "app.audio.playSoundLabel": "Play sound", "app.audio.backLabel": "Back", + "app.audio.loading": "Loading", + "app.audio.input": "Input", + "app.audio.output": "Output", "app.audio.audioSettings.titleLabel": "Choose your audio settings", "app.audio.audioSettings.descriptionLabel": "Please note, a dialog will appear in your browser, requiring you to accept sharing your microphone.", "app.audio.audioSettings.microphoneSourceLabel": "Microphone source",