diff --git a/conference.js b/conference.js
index 2c29778f9..533be995d 100644
--- a/conference.js
+++ b/conference.js
@@ -727,12 +727,12 @@ export default {
// so that the user can try unmute later on and add audio/video
// to the conference
if (!tracks.find((t) => t.isAudioTrack())) {
- this.audioMuted = true;
+ this.setAudioMuteStatus(true);
APP.UI.setAudioMuted(this.getMyUserId(), this.audioMuted);
}
if (!tracks.find((t) => t.isVideoTrack())) {
- this.videoMuted = true;
+ this.setVideoMuteStatus(true);
APP.UI.setVideoMuted(this.getMyUserId(), this.videoMuted);
}
@@ -765,7 +765,7 @@ export default {
muteAudio(mute, showUI = true) {
// Not ready to modify track's state yet
if (!this._localTracksInitialized) {
- this.audioMuted = mute;
+ this.setAudioMuteStatus(mute);
return;
} else if (localAudio && localAudio.isMuted() === mute) {
// NO-OP
@@ -794,7 +794,7 @@ export default {
muteLocalAudio(mute)
.catch(error => {
maybeShowErrorDialog(error);
- this.audioMuted = oldMutedStatus;
+ this.setAudioMuteStatus(oldMutedStatus);
APP.UI.setAudioMuted(this.getMyUserId(), this.audioMuted);
});
}
@@ -824,7 +824,7 @@ export default {
muteVideo(mute, showUI = true) {
// Not ready to modify track's state yet
if (!this._localTracksInitialized) {
- this.videoMuted = mute;
+ this.setVideoMuteStatus(mute);
return;
} else if (localVideo && localVideo.isMuted() === mute) {
@@ -863,7 +863,7 @@ export default {
muteLocalVideo(mute)
.catch(error => {
maybeShowErrorDialog(error);
- this.videoMuted = oldMutedStatus;
+ this.setVideoMuteStatus(oldMutedStatus);
APP.UI.setVideoMuted(this.getMyUserId(), this.videoMuted);
});
}
@@ -1220,13 +1220,13 @@ export default {
.then(() => {
localVideo = newStream;
if (newStream) {
- this.videoMuted = newStream.isMuted();
+ this.setVideoMuteStatus(newStream.isMuted());
this.isSharingScreen = newStream.videoType === 'desktop';
APP.UI.addLocalStream(newStream);
} else {
// No video is treated the same way as being video muted
- this.videoMuted = true;
+ this.setVideoMuteStatus(true);
this.isSharingScreen = false;
}
APP.UI.setVideoMuted(this.getMyUserId(), this.videoMuted);
@@ -1245,12 +1245,13 @@ export default {
replaceLocalTrack(localAudio, newStream, room))
.then(() => {
localAudio = newStream;
+
if (newStream) {
- this.audioMuted = newStream.isMuted();
+ this.setAudioMuteStatus(newStream.isMuted());
APP.UI.addLocalStream(newStream);
} else {
// No audio is treated the same way as being audio muted
- this.audioMuted = true;
+ this.setAudioMuteStatus(true);
}
APP.UI.setAudioMuted(this.getMyUserId(), this.audioMuted);
});
@@ -2310,6 +2311,7 @@ export default {
'device count: ' + audioDeviceCount);
APP.store.dispatch(setAudioAvailable(available));
+ APP.API.notifyAudioAvailabilityChanged(available);
},
/**
@@ -2334,6 +2336,7 @@ export default {
'device count: ' + videoDeviceCount);
APP.store.dispatch(setVideoAvailable(available));
+ APP.API.notifyVideoAvailabilityChanged(available);
},
/**
@@ -2541,5 +2544,29 @@ export default {
*/
getDesktopSharingSourceType() {
return localVideo.sourceType;
- }
+ },
+
+ /**
+ * Sets the video muted status.
+ *
+ * @param {boolean} muted - New muted status.
+ */
+ setVideoMuteStatus(muted) {
+ if (this.videoMuted !== muted) {
+ this.videoMuted = muted;
+ APP.API.notifyVideoMutedStatusChanged(muted);
+ }
+ },
+
+ /**
+ * Sets the audio muted status.
+ *
+ * @param {boolean} muted - New muted status.
+ */
+ setAudioMuteStatus(muted) {
+ if (this.audioMuted !== muted) {
+ this.audioMuted = muted;
+ APP.API.notifyAudioMutedStatusChanged(muted);
+ }
+ },
};
diff --git a/doc/api.md b/doc/api.md
index d047d5c45..ce1f34f70 100644
--- a/doc/api.md
+++ b/doc/api.md
@@ -25,7 +25,7 @@ Its constructor gets a number of options:
* **parentNode**: (optional) HTML DOM Element where the iframe will be added as a child.
* **configOverwrite**: (optional) JS object with overrides for options defined in [config.js].
* **interfaceConfigOverwrite**: (optional) JS object with overrides for options defined in [interface_config.js].
- * **noSsl**: (optional, defaults to true) Boolean indicating if the server should be contacted using HTTP or HTTPS.
+ * **noSSL**: (optional, defaults to true) Boolean indicating if the server should be contacted using HTTP or HTTPS.
* **jwt**: (optional) [JWT](https://jwt.io/) token.
Example:
@@ -141,6 +141,20 @@ The `listener` parameter is a Function object with one argument that will be not
The following events are currently supported:
+* **audioAvailabilityChanged** - event notifications about audio availability status changes. The listener will receive an object with the following structure:
+```javascript
+{
+"available": available // new available status - boolean
+}
+```
+
+* **audioMuteStatusChanged** - event notifications about audio mute status changes. The listener will receive an object with the following structure:
+```javascript
+{
+"muted": muted // new muted status - boolean
+}
+```
+
* **incomingMessage** - Event notifications about incoming
messages. The listener will receive an object with the following structure:
```javascript
@@ -196,6 +210,20 @@ changes. The listener will receive an object with the following structure:
}
```
+* **videoAvailabilityChanged** - event notifications about video availability status changes. The listener will receive an object with the following structure:
+```javascript
+{
+"available": available // new available status - boolean
+}
+```
+
+* **videoMuteStatusChanged** - event notifications about video mute status changes. The listener will receive an object with the following structure:
+```javascript
+{
+"muted": muted // new muted status - boolean
+}
+```
+
* **readyToClose** - event notification fired when Jitsi Meet is ready to be closed (hangup operations are completed).
You can also add multiple event listeners by using `addEventListeners`.
@@ -241,6 +269,34 @@ You can get the iframe HTML element where Jitsi Meet is loaded with the followin
var iframe = api.getIFrame();
```
+You can check whether the audio is muted with the following API function:
+```javascript
+isAudioMuted().then(function(muted) {
+ ...
+});
+```
+
+You can check whether the video is muted with the following API function:
+```javascript
+isVideoMuted().then(function(muted) {
+ ...
+});
+```
+
+You can check whether the audio is available with the following API function:
+```javascript
+isAudioAvailable().then(function(available) {
+ ...
+});
+```
+
+You can check whether the video is available with the following API function:
+```javascript
+isVideoAvailable().then(function(available) {
+ ...
+});
+```
+
You can remove the embedded Jitsi Meet Conference with the following API function:
```javascript
api.dispose()
diff --git a/modules/API/API.js b/modules/API/API.js
index 0cf7d1fff..f6bab6c59 100644
--- a/modules/API/API.js
+++ b/modules/API/API.js
@@ -26,6 +26,20 @@ let initialScreenSharingState = false;
*/
const transport = getJitsiMeetTransport();
+/**
+ * The current audio availability.
+ *
+ * @type {boolean}
+ */
+let audioAvailable = true;
+
+/**
+ * The current video availability.
+ *
+ * @type {boolean}
+ */
+let videoAvailable = true;
+
/**
* Initializes supported commands.
*
@@ -58,6 +72,26 @@ function initCommands() {
return false;
});
+ transport.on('request', ({ data, name }, callback) => {
+ switch (name) {
+ case 'is-audio-muted':
+ callback(APP.conference.audioMuted);
+ break;
+ case 'is-video-muted':
+ callback(APP.conference.videoMuted);
+ break;
+ case 'is-audio-available':
+ callback(audioAvailable);
+ break;
+ case 'is-video-available':
+ callback(videoAvailable);
+ break;
+ default:
+ return false;
+ }
+
+ return true;
+ });
}
/**
@@ -265,6 +299,65 @@ class API {
this._sendEvent({ name: 'video-ready-to-close' });
}
+ /**
+ * Notify external application (if API is enabled) for audio muted status
+ * changed.
+ *
+ * @param {boolean} muted - The new muted status.
+ * @returns {void}
+ */
+ notifyAudioMutedStatusChanged(muted) {
+ this._sendEvent({
+ name: 'audio-mute-status-changed',
+ muted
+ });
+ }
+
+ /**
+ * Notify external application (if API is enabled) for video muted status
+ * changed.
+ *
+ * @param {boolean} muted - The new muted status.
+ * @returns {void}
+ */
+ notifyVideoMutedStatusChanged(muted) {
+ this._sendEvent({
+ name: 'video-mute-status-changed',
+ muted
+ });
+ }
+
+ /**
+ * Notify external application (if API is enabled) for audio availability
+ * changed.
+ *
+ * @param {boolean} available - True if available and false otherwise.
+ * @returns {void}
+ */
+ notifyAudioAvailabilityChanged(available) {
+ audioAvailable = available;
+ this._sendEvent({
+ name: 'audio-availability-changed',
+ available
+ });
+ }
+
+ /**
+ * Notify external application (if API is enabled) for video available
+ * status changed.
+ *
+ * @param {boolean} available - True if available and false otherwise.
+ * @returns {void}
+ */
+ notifyVideoAvailabilityChanged(available) {
+ videoAvailable = available;
+ this._sendEvent({
+ name: 'video-availability-changed',
+ available
+ });
+ }
+
+
/**
* Disposes the allocated resources.
*
diff --git a/modules/API/external/external_api.js b/modules/API/external/external_api.js
index 8da4efd48..8b0970101 100644
--- a/modules/API/external/external_api.js
+++ b/modules/API/external/external_api.js
@@ -9,7 +9,7 @@ import {
const logger = require('jitsi-meet-logger').getLogger(__filename);
const ALWAYS_ON_TOP_FILENAMES = [
- 'css/alwaysontop.css', 'libs/alwaysontop.bundle.min.js'
+ 'css/all.css', 'libs/alwaysontop.min.js'
];
/**
@@ -34,6 +34,8 @@ const commands = {
* events expected by jitsi-meet
*/
const events = {
+ 'audio-availability-changed': 'audioAvailabilityChanged',
+ 'audio-mute-status-changed': 'audioMuteStatusChanged',
'display-name-change': 'displayNameChange',
'incoming-message': 'incomingMessage',
'outgoing-message': 'outgoingMessage',
@@ -41,7 +43,9 @@ const events = {
'participant-left': 'participantLeft',
'video-ready-to-close': 'readyToClose',
'video-conference-joined': 'videoConferenceJoined',
- 'video-conference-left': 'videoConferenceLeft'
+ 'video-conference-left': 'videoConferenceLeft',
+ 'video-availability-changed': 'videoAvailabilityChanged',
+ 'video-mute-status-changed': 'videoMuteStatusChanged'
};
/**
@@ -211,9 +215,7 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
noSSL,
roomName
});
- this._baseUrl = generateURL(domain, {
- noSSL
- });
+ this._baseUrl = `${noSSL ? 'http' : 'https'}://${domain}/`;
this._createIFrame(height, width);
this._transport = new Transport({
backend: new PostMessageTransportBackend({
@@ -448,6 +450,30 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
}
}
+ /**
+ * Check if the audio is available.
+ *
+ * @returns {Promise} - Resolves with true if the audio available, with
+ * false if not and rejects on failure.
+ */
+ isAudioAvailable() {
+ return this._transport.sendRequest({
+ name: 'is-audio-available'
+ });
+ }
+
+ /**
+ * Returns the audio mute status.
+ *
+ * @returns {Promise} - Resolves with the audio mute status and rejects on
+ * failure.
+ */
+ isAudioMuted() {
+ return this._transport.sendRequest({
+ name: 'is-audio-muted'
+ });
+ }
+
/**
* Returns the iframe that loads Jitsi Meet.
*
@@ -467,6 +493,30 @@ export default class JitsiMeetExternalAPI extends EventEmitter {
return this._numberOfParticipants;
}
+ /**
+ * Check if the video is available.
+ *
+ * @returns {Promise} - Resolves with true if the video available, with
+ * false if not and rejects on failure.
+ */
+ isVideoAvailable() {
+ return this._transport.sendRequest({
+ name: 'is-video-available'
+ });
+ }
+
+ /**
+ * Returns the audio mute status.
+ *
+ * @returns {Promise} - Resolves with the audio mute status and rejects on
+ * failure.
+ */
+ isVideoMuted() {
+ return this._transport.sendRequest({
+ name: 'is-video-muted'
+ });
+ }
+
/**
* Removes event listener.
*
diff --git a/react/features/always-on-top/AlwaysOnTop.js b/react/features/always-on-top/AlwaysOnTop.js
new file mode 100644
index 000000000..405bdc1a1
--- /dev/null
+++ b/react/features/always-on-top/AlwaysOnTop.js
@@ -0,0 +1,297 @@
+import React, { Component } from 'react';
+
+import StatelessToolbar from '../toolbox/components/StatelessToolbar';
+import StatelessToolbarButton
+ from '../toolbox/components/StatelessToolbarButton';
+
+const { api } = window.alwaysOnTop;
+
+/**
+ * The timeout in ms for hidding the toolbar.
+ */
+const TOOLBAR_TIMEOUT = 4000;
+
+/**
+ * Map with toolbar button descriptors.
+ */
+const toolbarButtons = {
+ /**
+ * The descriptor of the camera toolbar button.
+ */
+ camera: {
+ classNames: [ 'button', 'icon-camera' ],
+ enabled: true,
+ id: 'toolbar_button_camera',
+ onClick() {
+ api.executeCommand('toggleVideo');
+ }
+ },
+
+ /**
+ * The descriptor of the toolbar button which hangs up the call/conference.
+ */
+ hangup: {
+ classNames: [ 'button', 'icon-hangup', 'button_hangup' ],
+ enabled: true,
+ id: 'toolbar_button_hangup',
+ onClick() {
+ api.executeCommand('hangup');
+ window.close();
+ }
+ },
+
+ /**
+ * The descriptor of the microphone toolbar button.
+ */
+ microphone: {
+ classNames: [ 'button', 'icon-microphone' ],
+ enabled: true,
+ id: 'toolbar_button_mute',
+ onClick() {
+ api.executeCommand('toggleAudio');
+ }
+ }
+};
+
+/**
+ * Represents the always on top page.
+ *
+ * @class AlwaysOnTop
+ * @extends Component
+ */
+export default class AlwaysOnTop extends Component {
+ /**
+ * Initializes new AlwaysOnTop instance.
+ *
+ * @param {Object} props - The read-only properties with which the new
+ * instance is to be initialized.
+ */
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ visible: true,
+ audioMuted: false,
+ videoMuted: false,
+ audioAvailable: false,
+ videoAvailable: false
+ };
+
+ this._hovered = false;
+
+ this._audioAvailabilityListener
+ = this._audioAvailabilityListener.bind(this);
+ this._audioMutedListener = this._audioMutedListener.bind(this);
+ this._mouseMove = this._mouseMove.bind(this);
+ this._onMouseOver = this._onMouseOver.bind(this);
+ this._onMouseOut = this._onMouseOut.bind(this);
+ this._videoAvailabilityListener
+ = this._videoAvailabilityListener.bind(this);
+ this._videoMutedListener = this._videoMutedListener.bind(this);
+ }
+
+ /**
+ * Handles audio available api events.
+ *
+ * @param {{ available: boolean }} status - The new available status.
+ * @returns {void}
+ */
+ _audioAvailabilityListener({ available }) {
+ this.setState({ audioAvailable: available });
+ }
+
+ /**
+ * Handles audio muted api events.
+ *
+ * @param {{ muted: boolean }} status - The new muted status.
+ * @returns {void}
+ */
+ _audioMutedListener({ muted }) {
+ this.setState({ audioMuted: muted });
+ }
+
+ /**
+ * Hides the toolbar after a timeout.
+ *
+ * @returns {void}
+ */
+ _hideToolbarAfterTimeout() {
+ setTimeout(() => {
+ if (this._hovered) {
+ this._hideToolbarAfterTimeout();
+
+ return;
+ }
+ this.setState({ visible: false });
+ }, TOOLBAR_TIMEOUT);
+ }
+
+ /**
+ * Handles mouse move events.
+ *
+ * @returns {void}
+ */
+ _mouseMove() {
+ if (!this.state.visible) {
+ this.setState({ visible: true });
+ }
+ }
+
+ /**
+ * Toolbar mouse over handler.
+ *
+ * @returns {void}
+ */
+ _onMouseOver() {
+ this._hovered = true;
+ }
+
+ /**
+ * Toolbar mouse out handler.
+ *
+ * @returns {void}
+ */
+ _onMouseOut() {
+ this._hovered = false;
+ }
+
+ /**
+ * Handles audio available api events.
+ *
+ * @param {{ available: boolean }} status - The new available status.
+ * @returns {void}
+ */
+ _videoAvailabilityListener({ available }) {
+ this.setState({ videoAvailable: available });
+ }
+
+ /**
+ * Handles video muted api events.
+ *
+ * @param {{ muted: boolean }} status - The new muted status.
+ * @returns {void}
+ */
+ _videoMutedListener({ muted }) {
+ this.setState({ videoMuted: muted });
+ }
+
+ /**
+ * Sets mouse move listener and initial toolbar timeout.
+ *
+ * @inheritdoc
+ * @returns {void}
+ */
+ componentDidMount() {
+ api.on('audioMuteStatusChanged', this._audioMutedListener);
+ api.on('videoMuteStatusChanged', this._videoMutedListener);
+ api.on('audioAvailabilityChanged', this._audioAvailabilityListener);
+ api.on('videoAvailabilityChanged', this._videoAvailabilityListener);
+
+ Promise.all([
+ api.isAudioMuted(),
+ api.isVideoMuted(),
+ api.isAudioAvailable(),
+ api.isVideoAvailable()
+ ])
+ .then(([
+ audioMuted = false,
+ videoMuted = false,
+ audioAvailable = false,
+ videoAvailable = false
+ ]) =>
+ this.setState({
+ audioMuted,
+ videoMuted,
+ audioAvailable,
+ videoAvailable
+ })
+ )
+ .catch(console.error);
+
+ window.addEventListener('mousemove', this._mouseMove);
+
+ this._hideToolbarAfterTimeout();
+ }
+
+ /**
+ * Removes all listeners.
+ *
+ * @inheritdoc
+ * @returns {void}
+ */
+ componentWillUnmount() {
+ api.removeListener('audioMuteStatusChanged',
+ this._audioMutedListener);
+ api.removeListener('videoMuteStatusChanged',
+ this._videoMutedListener);
+ api.removeListener('audioAvailabilityChanged',
+ this._audioAvailabilityListener);
+ api.removeListener('videoAvailabilityChanged',
+ this._videoAvailabilityListener);
+ window.removeEventListener('mousemove', this._mouseMove);
+ }
+
+ /**
+ * Sets a timeout to hide the toolbar when the toolbar is shown.
+ *
+ * @inheritdoc
+ * @returns {void}
+ */
+ componentWillUpdate(nextProps, nextState) {
+ if (!this.state.visible && nextState.visible) {
+ this._hideToolbarAfterTimeout();
+ }
+ }
+
+ /**
+ * Implements React's {@link Component#render()}.
+ *
+ * @inheritdoc
+ * @returns {ReactElement}
+ */
+ render() {
+ const className
+ = `toolbar_primary ${this.state.visible ? 'fadeIn' : 'fadeOut'}`;
+
+ return (
+
+ {
+ Object.entries(toolbarButtons).map(([ key, button ]) => {
+ const { onClick } = button;
+ let enabled = false, toggled = false;
+
+ switch (key) {
+ case 'microphone':
+ enabled = this.state.audioAvailable;
+ toggled = enabled ? this.state.audioMuted : true;
+ break;
+ case 'camera':
+ enabled = this.state.videoAvailable;
+ toggled = enabled ? this.state.videoMuted : true;
+ break;
+ default: // hangup button
+ toggled = false;
+ enabled = true;
+ }
+
+ const updatedButton = {
+ ...button,
+ enabled,
+ toggled
+ };
+
+ return (
+
+ );
+ })
+ }
+
+ );
+ }
+}
diff --git a/react/features/always-on-top/index.js b/react/features/always-on-top/index.js
new file mode 100644
index 000000000..1b4a92a40
--- /dev/null
+++ b/react/features/always-on-top/index.js
@@ -0,0 +1,10 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import AlwaysOnTop from './AlwaysOnTop';
+
+// Render the main/root Component.
+ReactDOM.render(
+ ,
+ document.getElementById('react')
+);
diff --git a/react/features/base/tracks/middleware.js b/react/features/base/tracks/middleware.js
index 504cd15fc..7952f90ef 100644
--- a/react/features/base/tracks/middleware.js
+++ b/react/features/base/tracks/middleware.js
@@ -110,9 +110,9 @@ MiddlewareRegistry.register(store => next => action => {
if (jitsiTrack.isLocal()) {
if (isVideoTrack) {
- APP.conference.videoMuted = muted;
+ APP.conference.setVideoMuteStatus(muted);
} else {
- APP.conference.audioMuted = muted;
+ APP.conference.setAudioMuteStatus(muted);
}
}
diff --git a/react/features/toolbox/components/StatelessToolbar.web.js b/react/features/toolbox/components/StatelessToolbar.web.js
index c5fa79166..aca957fdc 100644
--- a/react/features/toolbox/components/StatelessToolbar.web.js
+++ b/react/features/toolbox/components/StatelessToolbar.web.js
@@ -4,10 +4,9 @@ import React, { Component } from 'react';
/**
* Implements a toolbar in React/Web. It is a strip that contains a set of
- * toolbar items such as buttons. Toolbar is commonly placed inside of a
- * Toolbox.
+ * toolbar items such as buttons.
*
- * @class Toolbar
+ * @class StatelessToolbar
* @extends Component
*/
export default class StatelessToolbar extends Component {
diff --git a/react/features/toolbox/components/Toolbar.web.js b/react/features/toolbox/components/Toolbar.web.js
index fa1336c81..3437319da 100644
--- a/react/features/toolbox/components/Toolbar.web.js
+++ b/react/features/toolbox/components/Toolbar.web.js
@@ -76,24 +76,10 @@ class Toolbar extends Component {
* @returns {ReactElement}
*/
render(): ReactElement<*> {
- const toolbarButtons = new Map();
-
- this.props.toolbarButtons
- .forEach((button, key) => {
- const { onClick } = button;
-
- toolbarButtons.set(key, {
- ...button,
- onClick: (...args) =>
- onClick && onClick(this.props.dispatch, ...args)
- });
- });
-
const props = {
- ...this.props,
+ className: this.props.className,
onMouseOut: this._onMouseOut,
- onMouseOver: this._onMouseOver,
- toolbarButtons
+ onMouseOver: this._onMouseOver
};
return (
@@ -150,14 +136,15 @@ class Toolbar extends Component {
}
const { tooltipPosition } = this.props;
-
const { onClick, onMount, onUnmount } = button;
+ const onClickWithDispatch = (...args) =>
+ onClick && onClick(this.props.dispatch, ...args);
return (
diff --git a/webpack.config.js b/webpack.config.js
index f6b239e16..b0ce8641d 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -176,6 +176,9 @@ module.exports = [
'device_selection_popup_bundle':
'./react/features/device-selection/popup.js',
+ 'alwaysontop':
+ './react/features/always-on-top/index.js',
+
'do_external_connect':
'./connection_optimization/do_external_connect.js'
}