From 1782030936901b7d238ab899185698a812acbd23 Mon Sep 17 00:00:00 2001 From: hristoterezov Date: Fri, 4 Aug 2017 11:15:11 +0300 Subject: [PATCH] feat(alwaysontop): Toolbar. --- conference.js | 49 ++- doc/api.md | 58 +++- modules/API/API.js | 93 ++++++ modules/API/external/external_api.js | 60 +++- react/features/always-on-top/AlwaysOnTop.js | 297 ++++++++++++++++++ react/features/always-on-top/index.js | 10 + react/features/base/tracks/middleware.js | 4 +- .../components/StatelessToolbar.web.js | 5 +- .../toolbox/components/Toolbar.web.js | 23 +- webpack.config.js | 3 + 10 files changed, 562 insertions(+), 40 deletions(-) create mode 100644 react/features/always-on-top/AlwaysOnTop.js create mode 100644 react/features/always-on-top/index.js 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' }