diff --git a/conference.js b/conference.js index dde5d8f1a..dc99ba45c 100644 --- a/conference.js +++ b/conference.js @@ -25,9 +25,11 @@ import { conferenceFailed, conferenceJoined, conferenceLeft, + dataChannelOpened, toggleAudioOnly, EMAIL_COMMAND, - lockStateChanged + lockStateChanged, + p2pStatusChanged } from './react/features/base/conference'; import { updateDeviceList } from './react/features/base/devices'; import { @@ -1704,6 +1706,12 @@ export default { APP.UI.handleLastNEndpoints(leavingIds, enteringIds); }); + room.on( + ConferenceEvents.P2P_STATUS, + (jitsiConference, p2p) => { + APP.store.dispatch(p2pStatusChanged(p2p)); + }); + room.on( ConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED, (id, connectionStatus) => { @@ -1952,6 +1960,12 @@ export default { } ); + room.on( + ConferenceEvents.DATA_CHANNEL_OPENED, () => { + APP.store.dispatch(dataChannelOpened()); + } + ); + // call hangup APP.UI.addListener(UIEvents.HANGUP, () => { this.hangup(true); diff --git a/css/_toolbars.scss b/css/_toolbars.scss index a6f0ef29e..7dcefd3a5 100644 --- a/css/_toolbars.scss +++ b/css/_toolbars.scss @@ -42,6 +42,14 @@ position: relative; z-index: $toolbarZ; + /** + * Ensure nested elements that don't have a button class, maybe because they + * are wrapped in a React Element, still display in a line. + */ + > div { + display: inline-block; + } + /** * Toolbar button styles. */ @@ -94,10 +102,6 @@ &.icon-microphone { @extend .icon-mic-disabled; } - - &.icon-visibility { - @extend .icon-visibility-off; - } } &.unclickable { diff --git a/css/_videolayout_default.scss b/css/_videolayout_default.scss index 4c7218cfc..d64632c3f 100644 --- a/css/_videolayout_default.scss +++ b/css/_videolayout_default.scss @@ -535,111 +535,3 @@ 1px 0px 1px rgba(0,0,0,0.3), 0px 0px 1px rgba(0,0,0,0.3); } - -.filmstrip-only { - #videoResolutionLabel { - display: none; - } -} -.video-state-indicator { - background: $videoStateIndicatorBackground; - color: $videoStateIndicatorColor; - cursor: pointer; - font-size: 13px; - height: 40px; - line-height: 20px; - text-align: center; - min-width: 40px; - padding: 10px 5px; - border-radius: 50%; - position: absolute; - box-sizing: border-box; - - i { - cursor: pointer; - } -} - -#videoResolutionLabel, -.centeredVideoLabel.moveToCorner { - z-index: $tooltipsZ; -} - -.centeredVideoLabel { - bottom: 45%; - border-radius: 2px; - display: none; - -webkit-transition: all 2s 2s linear; - transition: all 2s 2s linear; - z-index: $centeredVideoLabelZ; - - &.moveToCorner { - bottom: auto; - } -} - -.moveToCorner { - position: absolute; - top: 30px; - right: 30px; -} - -.moveToCorner + .moveToCorner { - right: 80px; -} - -.video-state-indicator-menu { - display: none; - padding: 10px; - position: absolute; - right: -10px; - top: 20px; - - .video-state-indicator-menu-options { - background: $popoverBg; - border-radius: 3px; - color: $popoverFontColor; - margin-top: 20px; - padding: 5px 0; - position: relative; - - div { - cursor: pointer; - padding: 10px; - padding-right: 30px; - text-align: left; - white-space: nowrap; - - &.active { - background: $toolbarToggleBackground; - } - &:hover:not(.active) { - background: $popupMenuSelectedItemBackground; - } - - i { - margin-right: 5px; - vertical-align: middle; - } - } - } - - .video-state-indicator-menu-options::after { - content: " "; - border-color: transparent transparent $popoverBg transparent; - border-style: solid; - border-width: 5px; - position: absolute; - right: 15px; - top: -10px; - } -} - -.video-state-indicator:hover, -.video-state-indicator *:hover { - background: $toolbarSelectBackground; - - .video-state-indicator-menu { - display: block; - } -} diff --git a/css/main.scss b/css/main.scss index 6b7c755b4..21cda1343 100644 --- a/css/main.scss +++ b/css/main.scss @@ -43,6 +43,7 @@ @import 'modals/dialog'; @import 'modals/feedback/feedback'; @import 'modals/speaker_stats/speaker_stats'; +@import 'modals/video-quality/video-quality'; @import 'videolayout_default'; @import 'notice'; @import 'popup_menu'; diff --git a/css/modals/video-quality/_video-quality.scss b/css/modals/video-quality/_video-quality.scss new file mode 100644 index 000000000..3ea9a1fc9 --- /dev/null +++ b/css/modals/video-quality/_video-quality.scss @@ -0,0 +1,166 @@ +.video-quality-dialog { + color: $modalTextColor; + + .video-quality-dialog-title { + margin-bottom: 10px; + } + + .video-quality-dialog-contents { + align-items: center; + color: $modalTextColor; + display: flex; + flex-direction: column; + padding: 10px; + min-width: 250px; + + .video-quality-dialog-slider-container { + width: 100%; + text-align: center; + } + + .video-quality-dialog-slider { + width: calc(100% - 5px); + + @mixin sliderTrackStyles() { + height: 15px; + border-radius: 10px; + background: black; + } + + &::-ms-track { + @include sliderTrackStyles(); + } + + &::-moz-range-track { + @include sliderTrackStyles(); + } + + &::-webkit-slider-runnable-track { + @include sliderTrackStyles(); + } + + @mixin sliderThumbStyles() { + top: 50%; + border: none; + position: relative; + opacity: 0; + } + + &::-ms-thumb { + @include sliderThumbStyles(); + } + + &::-moz-range-thumb { + @include sliderThumbStyles(); + + } + + &::-webkit-slider-thumb { + @include sliderThumbStyles(); + } + } + + .video-quality-dialog-labels { + box-sizing: border-box; + display: flex; + margin-top: 5px; + position: relative; + width: 90%; + } + + .video-quality-dialog-label-container { + position: absolute; + text-align: center; + transform: translate(-50%, 0%); + + &::before { + background: rgb(140, 156, 189); + content: ''; + border-radius: 50%; + left: 0; + height: 6px; + margin: 0 auto; + pointer-events: none; + position: absolute; + right: 0; + top: -16px; + width: 6px; + } + } + + .video-quality-dialog-label-container.active { + color: $toolbarToggleBackground; + + &::before { + background: $toolbarToggleBackground; + height: 12px; + top: -19px; + width: 12px; + } + } + + .video-quality-dialog-label-container:first-child { + position: relative; + } + + .video-quality-dialog-label { + display: table-caption; + word-spacing: unset; + } + } +} + +.video-state-indicator { + background: $videoStateIndicatorBackground; + color: $videoStateIndicatorColor; + cursor: default; + font-size: 13px; + height: 40px; + line-height: 20px; + text-align: left; + min-width: 40px; + border-radius: 50%; + position: absolute; + box-sizing: border-box; + + i { + cursor: pointer; + } + + /** + * Give the label padding so it has more volume and can be easily clicked. + */ + .video-quality-label-status { + cursor: pointer; + padding: 10px 5px; + text-align: center; + } +} + +#videoResolutionLabel, +.centeredVideoLabel.moveToCorner { + z-index: $tooltipsZ; +} + +.centeredVideoLabel { + bottom: 45%; + border-radius: 2px; + display: none; + -webkit-transition: all 2s 2s linear; + transition: all 2s 2s linear; + z-index: $centeredVideoLabelZ; + + &.moveToCorner { + bottom: auto; + } +} + +.moveToCorner { + position: absolute; + top: 30px; + right: 30px; +} + +.moveToCorner + .moveToCorner { + right: 80px; +} \ No newline at end of file diff --git a/interface_config.js b/interface_config.js index 36a78e16c..674c5f163 100644 --- a/interface_config.js +++ b/interface_config.js @@ -35,7 +35,7 @@ var interfaceConfig = { // eslint-disable-line no-unused-vars //main toolbar 'microphone', 'camera', 'desktop', 'invite', 'fullscreen', 'fodeviceselection', 'hangup', // jshint ignore:line //extended toolbar - 'profile', 'addtocall', 'contacts', 'chat', 'recording', 'etherpad', 'sharedvideo', 'dialout', 'settings', 'raisehand', 'filmstrip'], // jshint ignore:line + 'profile', 'addtocall', 'contacts', 'chat', 'recording', 'etherpad', 'sharedvideo', 'dialout', 'settings', 'raisehand', 'videoquality', 'filmstrip'], // jshint ignore:line /** * Main Toolbar Buttons * All of them should be in TOOLBAR_BUTTONS diff --git a/lang/main.json b/lang/main.json index 2dcb9682d..55abec919 100644 --- a/lang/main.json +++ b/lang/main.json @@ -449,10 +449,18 @@ "unlocked": "This call is unlocked. Any new caller with the link may join the call." }, "videoStatus": { - "hd": "HD", - "hdVideo": "HD video", - "sd": "SD", - "sdVideo": "SD video" + "callQuality": "Call Quality", + "changeVideoTip": "Change your video quality from the left toolbar.", + "hd": "HD", + "highDefinition": "High definition", + "ld": "LD", + "lowDefinition": "Low definition", + "p2pEnabled": "Peer to Peer Enabled", + "p2pVideoQualityDescription": "In peer to peer mode, received call quality can only be toggled between high and audio only. Other settings will not be honored until peer to peer is exited.", + "recHighDefinitionOnly": "Will prefer high definition.", + "sd": "SD", + "standardDefinition": "Standard definition", + "qualityButtonTip": "Change received video quality" }, "dialOut": { "dial": "Dial", diff --git a/modules/UI/videolayout/LargeVideoManager.js b/modules/UI/videolayout/LargeVideoManager.js index 833eb9d3a..b0a09178c 100644 --- a/modules/UI/videolayout/LargeVideoManager.js +++ b/modules/UI/videolayout/LargeVideoManager.js @@ -1,4 +1,4 @@ -/* global $, APP, config, JitsiMeetJS */ +/* global $, APP, JitsiMeetJS */ /* eslint-disable no-unused-vars */ import React from 'react'; import ReactDOM from 'react-dom'; @@ -9,7 +9,9 @@ import { PresenceLabel } from '../../../react/features/presence-status'; const logger = require("jitsi-meet-logger").getLogger(__filename); -import { setLargeVideoHDStatus } from '../../../react/features/base/conference'; +import { + updateKnownLargeVideoResolution +} from '../../../react/features/large-video'; import Avatar from "../avatar/Avatar"; import {createDeferred} from '../../util/helpers'; @@ -659,10 +661,13 @@ export default class LargeVideoManager { */ _onVideoResolutionUpdate() { const { height, width } = this.videoContainer.getStreamSize(); - const currentAspectRatio = width/ height; - const isCurrentlyHD = Math.min(height, width) >= config.minHDHeight; + const { resolution } = APP.store.getState()['features/large-video']; - APP.store.dispatch(setLargeVideoHDStatus(isCurrentlyHD)); + if (height !== resolution) { + APP.store.dispatch(updateKnownLargeVideoResolution(height)); + } + + const currentAspectRatio = width / height; if (this._videoAspectRatio !== currentAspectRatio) { this._videoAspectRatio = currentAspectRatio; diff --git a/react/features/base/conference/actionTypes.js b/react/features/base/conference/actionTypes.js index 5e95c0090..c571aa2df 100644 --- a/react/features/base/conference/actionTypes.js +++ b/react/features/base/conference/actionTypes.js @@ -52,6 +52,16 @@ export const CONFERENCE_WILL_JOIN = Symbol('CONFERENCE_WILL_JOIN'); */ export const CONFERENCE_WILL_LEAVE = Symbol('CONFERENCE_WILL_LEAVE'); +/** + * The type of (redux) action which signals that the data channel with the + * bridge has been established. + * + * { + * type: DATA_CHANNEL_OPENED + * } + */ +export const DATA_CHANNEL_OPENED = Symbol('DATA_CHANNEL_OPENED'); + /** * The type of (redux) action which signals that the lock state of a specific * {@code JitsiConference} changed. @@ -64,6 +74,17 @@ export const CONFERENCE_WILL_LEAVE = Symbol('CONFERENCE_WILL_LEAVE'); */ export const LOCK_STATE_CHANGED = Symbol('LOCK_STATE_CHANGED'); +/** + * The type of (redux) action which sets the peer2peer flag for the current + * conference. + * + * { + * type: P2P_STATUS_CHANGED, + * p2p: boolean + * } + */ +export const P2P_STATUS_CHANGED = Symbol('P2P_STATUS_CHANGED'); + /** * The type of (redux) action which sets the audio-only flag for the current * conference. @@ -75,17 +96,6 @@ export const LOCK_STATE_CHANGED = Symbol('LOCK_STATE_CHANGED'); */ export const SET_AUDIO_ONLY = Symbol('SET_AUDIO_ONLY'); -/** - * The type of (redux) action to set whether or not the displayed large video is - * in high-definition. - * - * { - * type: SET_LARGE_VIDEO_HD_STATUS, - * isLargeVideoHD: boolean - * } - */ -export const SET_LARGE_VIDEO_HD_STATUS = Symbol('SET_LARGE_VIDEO_HD_STATUS'); - /** * The type of (redux) action which sets the video channel's lastN (value). * @@ -120,6 +130,17 @@ export const SET_PASSWORD = Symbol('SET_PASSWORD'); */ export const SET_PASSWORD_FAILED = Symbol('SET_PASSWORD_FAILED'); +/** + * The type of (redux) action which sets the maximum video size should be + * received from remote participants. + * + * { + * type: SET_RECEIVE_VIDEO_QUALITY, + * receiveVideoQuality: number + * } + */ +export const SET_RECEIVE_VIDEO_QUALITY = Symbol('SET_RECEIVE_VIDEO_QUALITY'); + /** * The type of (redux) action which sets the name of the room of the * conference to be joined. diff --git a/react/features/base/conference/actions.js b/react/features/base/conference/actions.js index 4e87b1fd1..fdd9a006d 100644 --- a/react/features/base/conference/actions.js +++ b/react/features/base/conference/actions.js @@ -17,12 +17,14 @@ import { CONFERENCE_LEFT, CONFERENCE_WILL_JOIN, CONFERENCE_WILL_LEAVE, + DATA_CHANNEL_OPENED, LOCK_STATE_CHANGED, + P2P_STATUS_CHANGED, SET_AUDIO_ONLY, - SET_LARGE_VIDEO_HD_STATUS, SET_LASTN, SET_PASSWORD, SET_PASSWORD_FAILED, + SET_RECEIVE_VIDEO_QUALITY, SET_ROOM } from './actionTypes'; import { @@ -286,6 +288,19 @@ export function createConference() { }; } +/** + * Signals the data channel with the bridge has successfully opened. + * + * @returns {{ + * type: DATA_CHANNEL_OPENED + * }} + */ +export function dataChannelOpened() { + return { + type: DATA_CHANNEL_OPENED + }; +} + /** * Signals that the lock state of a specific JitsiConference changed. * @@ -307,6 +322,22 @@ export function lockStateChanged(conference, locked) { }; } +/** + * Sets whether or not peer2peer is currently enabled. + * + * @param {boolean} p2p - Whether or not peer2peer is currently active. + * @returns {{ + * type: P2P_STATUS_CHANGED, + * p2p: boolean + * }} + */ +export function p2pStatusChanged(p2p) { + return { + type: P2P_STATUS_CHANGED, + p2p + }; +} + /** * Sets the audio-only flag for the current JitsiConference. * @@ -324,23 +355,6 @@ export function setAudioOnly(audioOnly) { }; } -/** - * Action to set whether or not the currently displayed large video is in - * high-definition. - * - * @param {boolean} isLargeVideoHD - True if the large video is high-definition. - * @returns {{ - * type: SET_LARGE_VIDEO_HD_STATUS, - * isLargeVideoHD: boolean - * }} - */ -export function setLargeVideoHDStatus(isLargeVideoHD) { - return { - type: SET_LARGE_VIDEO_HD_STATUS, - isLargeVideoHD - }; -} - /** * Sets the video channel's last N (value) of the current conference. A value of * undefined shall be used to reset it to the default value. @@ -438,6 +452,22 @@ export function setPassword(conference, method, password) { }; } +/** + * Sets the max frame height to receive from remote participant videos. + * + * @param {number} receiveVideoQuality - The max video resolution to receive. + * @returns {{ + * type: SET_RECEIVE_VIDEO_QUALITY, + * receiveVideoQuality: number + * }} + */ +export function setReceiveVideoQuality(receiveVideoQuality) { + return { + type: SET_RECEIVE_VIDEO_QUALITY, + receiveVideoQuality + }; +} + /** * Sets (the name of) the room of the conference to be joined. * diff --git a/react/features/base/conference/constants.js b/react/features/base/conference/constants.js index fcb13302f..7b515efc6 100644 --- a/react/features/base/conference/constants.js +++ b/react/features/base/conference/constants.js @@ -34,3 +34,15 @@ export const EMAIL_COMMAND = 'email'; * from the outside is not cool but it should suffice for now. */ export const JITSI_CONFERENCE_URL_KEY = Symbol('url'); + +/** + * The supported remote video resolutions. The values are currently based on + * available simulcast layers. + * + * @type {object} + */ +export const VIDEO_QUALITY_LEVELS = { + HIGH: 720, + STANDARD: 360, + LOW: 180 +}; diff --git a/react/features/base/conference/middleware.js b/react/features/base/conference/middleware.js index 774394200..3f01d5664 100644 --- a/react/features/base/conference/middleware.js +++ b/react/features/base/conference/middleware.js @@ -14,14 +14,17 @@ import { TRACK_ADDED, TRACK_REMOVED } from '../tracks'; import { createConference, setAudioOnly, - setLastN + setLastN, + toggleAudioOnly } from './actions'; import { CONFERENCE_FAILED, CONFERENCE_JOINED, CONFERENCE_LEFT, + DATA_CHANNEL_OPENED, SET_AUDIO_ONLY, - SET_LASTN + SET_LASTN, + SET_RECEIVE_VIDEO_QUALITY } from './actionTypes'; import { _addLocalTracksToConference, @@ -47,6 +50,9 @@ MiddlewareRegistry.register(store => next => action => { case CONFERENCE_JOINED: return _conferenceJoined(store, next, action); + case DATA_CHANNEL_OPENED: + return _syncReceiveVideoQuality(store, next, action); + case PIN_PARTICIPANT: return _pinParticipant(store, next, action); @@ -56,6 +62,9 @@ MiddlewareRegistry.register(store => next => action => { case SET_LASTN: return _setLastN(store, next, action); + case SET_RECEIVE_VIDEO_QUALITY: + return _setReceiveVideoQuality(store, next, action); + case TRACK_ADDED: case TRACK_REMOVED: return _trackAddedOrRemoved(store, next, action); @@ -253,6 +262,33 @@ function _setLastN(store, next, action) { return next(action); } +/** + * Sets the maximum receive video quality and will turn off audio only mode if + * enabled. + * + * @param {Store} store - The Redux store in which the specified action is being + * dispatched. + * @param {Dispatch} next - The Redux dispatch function to dispatch the + * specified action to the specified store. + * @param {Action} action - The Redux action SET_RECEIVE_VIDEO_QUALITY which is + * being dispatched in the specified store. + * @private + * @returns {Object} The new state that is the result of the reduction of the + * specified action. + */ +function _setReceiveVideoQuality(store, next, action) { + const { audioOnly, conference } + = store.getState()['features/base/conference']; + + conference.setReceiverVideoConstraint(action.receiveVideoQuality); + + if (audioOnly) { + store.dispatch(toggleAudioOnly()); + } + + return next(action); +} + /** * Synchronizes local tracks from state with local tracks in JitsiConference * instance. @@ -282,6 +318,27 @@ function _syncConferenceLocalTracksWithState(store, action) { return promise || Promise.resolve(); } +/** + * Sets the maximum receive video quality. + * + * @param {Store} store - The Redux store in which the specified action is being + * dispatched. + * @param {Dispatch} next - The Redux dispatch function to dispatch the + * specified action to the specified store. + * @param {Action} action - The Redux action DATA_CHANNEL_STATUS_CHANGED which + * is being dispatched in the specified store. + * @private + * @returns {Object} The new state that is the result of the reduction of the + * specified action. + */ +function _syncReceiveVideoQuality(store, next, action) { + const state = store.getState()['features/base/conference']; + + state.conference.setReceiverVideoConstraint(state.receiveVideoQuality); + + return next(action); +} + /** * Notifies the feature base/conference that the action TRACK_ADDED * or TRACK_REMOVED is being dispatched within a specific redux store. diff --git a/react/features/base/conference/reducer.js b/react/features/base/conference/reducer.js index 5773302dc..cda59a88d 100644 --- a/react/features/base/conference/reducer.js +++ b/react/features/base/conference/reducer.js @@ -10,11 +10,15 @@ import { CONFERENCE_WILL_JOIN, CONFERENCE_WILL_LEAVE, LOCK_STATE_CHANGED, + P2P_STATUS_CHANGED, SET_AUDIO_ONLY, - SET_LARGE_VIDEO_HD_STATUS, SET_PASSWORD, + SET_RECEIVE_VIDEO_QUALITY, SET_ROOM } from './actionTypes'; +import { + VIDEO_QUALITY_LEVELS +} from './constants'; import { isRoomValid } from './functions'; /** @@ -41,15 +45,18 @@ ReducerRegistry.register('features/base/conference', (state = {}, action) => { case LOCK_STATE_CHANGED: return _lockStateChanged(state, action); + case P2P_STATUS_CHANGED: + return _p2pStatusChanged(state, action); + case SET_AUDIO_ONLY: return _setAudioOnly(state, action); - case SET_LARGE_VIDEO_HD_STATUS: - return _setLargeVideoHDStatus(state, action); - case SET_PASSWORD: return _setPassword(state, action); + case SET_RECEIVE_VIDEO_QUALITY: + return _setReceiveVideoQuality(state, action); + case SET_ROOM: return _setRoom(state, action); } @@ -135,7 +142,15 @@ function _conferenceJoined(state, { conference }) { * @type {boolean} */ locked, - passwordRequired: undefined + passwordRequired: undefined, + + /** + * The current resolution restraint on receiving remote video. By + * default the conference will send the highest level possible. + * + * @type number + */ + receiveVideoQuality: VIDEO_QUALITY_LEVELS.HIGH }); } @@ -228,6 +243,20 @@ function _lockStateChanged(state, { conference, locked }) { }); } +/** + * Reduces a specific Redux action P2P_STATUS_CHANGED of the feature + * base/conference. + * + * @param {Object} state - The Redux state of the feature base/conference. + * @param {Action} action - The Redux action P2P_STATUS_CHANGED to reduce. + * @private + * @returns {Object} The new state of the feature base/conference after the + * reduction of the specified action. + */ +function _p2pStatusChanged(state, action) { + return set(state, 'p2p', action.p2p); +} + /** * Reduces a specific Redux action SET_AUDIO_ONLY of the feature * base/conference. @@ -242,21 +271,6 @@ function _setAudioOnly(state, action) { return set(state, 'audioOnly', action.audioOnly); } -/** - * Reduces a specific Redux action SET_LARGE_VIDEO_HD_STATUS of the feature - * base/conference. - * - * @param {Object} state - The Redux state of the feature base/conference. - * @param {Action} action - The Redux action SET_LARGE_VIDEO_HD_STATUS to - * reduce. - * @private - * @returns {Object} The new state of the feature base/conference after the - * reduction of the specified action. - */ -function _setLargeVideoHDStatus(state, action) { - return set(state, 'isLargeVideoHD', action.isLargeVideoHD); -} - /** * Reduces a specific Redux action SET_PASSWORD of the feature base/conference. * @@ -294,6 +308,21 @@ function _setPassword(state, { conference, method, password }) { return state; } +/** + * Reduces a specific Redux action SET_RECEIVE_VIDEO_QUALITY of the feature + * base/conference. + * + * @param {Object} state - The Redux state of the feature base/conference. + * @param {Action} action - The Redux action SET_RECEIVE_VIDEO_QUALITY to + * reduce. + * @private + * @returns {Object} The new state of the feature base/conference after the + * reduction of the specified action. + */ +function _setReceiveVideoQuality(state, action) { + return set(state, 'receiveVideoQuality', action.receiveVideoQuality); +} + /** * Reduces a specific Redux action SET_ROOM of the feature base/conference. * diff --git a/react/features/large-video/actionTypes.js b/react/features/large-video/actionTypes.js index 76664df36..90fdcda71 100644 --- a/react/features/large-video/actionTypes.js +++ b/react/features/large-video/actionTypes.js @@ -8,3 +8,14 @@ */ export const SELECT_LARGE_VIDEO_PARTICIPANT = Symbol('SELECT_LARGE_VIDEO_PARTICIPANT'); + +/** + * Action to update the redux store with the current resolution of large video. + * + * @returns {{ + * type: UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION, + * resolution: number + * }} + */ +export const UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION + = Symbol('UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION'); diff --git a/react/features/large-video/actions.js b/react/features/large-video/actions.js index 7301e5e84..e9552739b 100644 --- a/react/features/large-video/actions.js +++ b/react/features/large-video/actions.js @@ -5,7 +5,10 @@ import { getTrackByMediaTypeAndParticipant } from '../base/tracks'; -import { SELECT_LARGE_VIDEO_PARTICIPANT } from './actionTypes'; +import { + SELECT_LARGE_VIDEO_PARTICIPANT, + UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION +} from './actionTypes'; /** * Signals conference to select a participant. @@ -64,6 +67,22 @@ export function selectParticipantInLargeVideo() { }; } +/** + * Updates the currently seen resolution of the video displayed on large video. + * + * @param {number} resolution - The current resolution (height) of the video. + * @returns {{ + * type: UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION, + * resolution: number + * }} + */ +export function updateKnownLargeVideoResolution(resolution) { + return { + type: UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION, + resolution + }; +} + /** * Returns the most recent existing video track. It can be local or remote * video. diff --git a/react/features/large-video/components/LargeVideo.web.js b/react/features/large-video/components/LargeVideo.web.js index ed48a0159..732ea1bf7 100644 --- a/react/features/large-video/components/LargeVideo.web.js +++ b/react/features/large-video/components/LargeVideo.web.js @@ -3,7 +3,9 @@ import React, { Component } from 'react'; import { Watermarks } from '../../base/react'; -import { VideoStatusLabel } from '../../video-status-label'; +import { VideoQualityLabel } from '../../video-quality'; + +declare var interfaceConfig: Object; /** * Implements a React {@link Component} which represents the large video (a.k.a. @@ -66,7 +68,7 @@ export default class LargeVideo extends Component { - + { interfaceConfig.filmStripOnly ? null : } { switch (action.type) { @@ -25,6 +28,12 @@ ReducerRegistry.register('features/large-video', (state = {}, action) => { ...state, participantId: action.participantId }; + + case UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION: + return { + ...state, + resolution: action.resolution + }; } return state; diff --git a/react/features/toolbox/components/index.js b/react/features/toolbox/components/index.js index 70fb4807a..118cf0179 100644 --- a/react/features/toolbox/components/index.js +++ b/react/features/toolbox/components/index.js @@ -1 +1,2 @@ +export { default as ToolbarButton } from './ToolbarButton'; export { default as Toolbox } from './Toolbox'; diff --git a/react/features/toolbox/defaultToolbarButtons.js b/react/features/toolbox/defaultToolbarButtons.js index a934846cd..ae24d1777 100644 --- a/react/features/toolbox/defaultToolbarButtons.js +++ b/react/features/toolbox/defaultToolbarButtons.js @@ -7,6 +7,8 @@ import { openDialOutDialog } from '../dial-out'; import { openAddPeopleDialog, openInviteDialog } from '../invite'; import UIEvents from '../../../service/UI/UIEvents'; +import { VideoQualityButton } from '../video-quality'; + declare var APP: Object; declare var interfaceConfig: Object; declare var JitsiMeetJS: Object; @@ -420,6 +422,10 @@ const buttons: Object = { } ], tooltipKey: 'toolbar.sharedvideo' + }, + + videoquality: { + component: VideoQualityButton } }; diff --git a/react/features/video-quality/components/VideoQualityButton.native.js b/react/features/video-quality/components/VideoQualityButton.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/video-quality/components/VideoQualityButton.web.js b/react/features/video-quality/components/VideoQualityButton.web.js new file mode 100644 index 000000000..db9306b98 --- /dev/null +++ b/react/features/video-quality/components/VideoQualityButton.web.js @@ -0,0 +1,153 @@ +import AKInlineDialog from '@atlaskit/inline-dialog'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { VideoQualityDialog } from './'; + +import { ToolbarButton } from '../../toolbox'; + +const DEFAULT_BUTTON_CONFIGURATION = { + buttonName: 'videoquality', + classNames: [ 'button', 'icon-visibility' ], + enabled: true, + id: 'toolbar_button_videoquality', + tooltipKey: 'videoStatus.qualityButtonTip' +}; + +const TOOLTIP_TO_DIALOG_POSITION = { + bottom: 'bottom center', + left: 'left middle', + right: 'right middle', + top: 'top center' +}; + +/** + * React {@code Component} for displaying an inline dialog for changing receive + * video settings. + * + * @extends Component + */ +class VideoQualityButton extends Component { + /** + * {@code VideoQualityButton}'s property types. + * + * @static + */ + static propTypes = { + /** + * Whether or not the button is visible, based on the visibility of the + * toolbar. Used to automatically hide the inline dialog if not visible. + */ + _visible: React.PropTypes.bool, + + /** + * From which side tooltips should display. Will be re-used for + * displaying the inline dialog for video quality adjustment. + */ + tooltipPosition: React.PropTypes.string + }; + + /** + * Initializes a new {@code VideoQualityButton} instance. + * + * @param {Object} props - The read-only properties with which the new + * instance is to be initialized. + */ + constructor(props) { + super(props); + + this.state = { + /** + * Whether or not the inline dialog for adjusting received video + * quality is displayed. + */ + showVideoQualityDialog: false + }; + + // Bind event handlers so they are only bound once for every instance. + this._onDialogClose = this._onDialogClose.bind(this); + this._onDialogToggle = this._onDialogToggle.bind(this); + } + + /** + * Automatically close the inline dialog if the button will not be visible. + * + * @inheritdoc + * @returns {void} + */ + componentWillReceiveProps(nextProps) { + if (!nextProps._visible) { + this._onDialogClose(); + } + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { _visible, tooltipPosition } = this.props; + const buttonConfiguration = { + ...DEFAULT_BUTTON_CONFIGURATION, + classNames: [ + ...DEFAULT_BUTTON_CONFIGURATION.classNames, + this.state.showVideoQualityDialog ? 'toggled button-active' : '' + ] + }; + + return ( + } + isOpen = { _visible && this.state.showVideoQualityDialog } + onClose = { this._onDialogClose } + position = { TOOLTIP_TO_DIALOG_POSITION[tooltipPosition] }> + + + ); + } + + /** + * Hides the attached inline dialog. + * + * @private + * @returns {void} + */ + _onDialogClose() { + this.setState({ showVideoQualityDialog: false }); + } + + /** + * Toggles the display of the dialog. + * + * @private + * @returns {void} + */ + _onDialogToggle() { + this.setState({ + showVideoQualityDialog: !this.state.showVideoQualityDialog + }); + } +} + +/** + * Maps (parts of) the Redux state to the associated {@code VideoQualityButton} + * component's props. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _visible: boolean + * }} + */ +function _mapStateToProps(state) { + return { + _visible: state['features/toolbox'].visible + }; +} + +export default connect(_mapStateToProps)(VideoQualityButton); diff --git a/react/features/video-quality/components/VideoQualityDialog.native.js b/react/features/video-quality/components/VideoQualityDialog.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/video-quality/components/VideoQualityDialog.web.js b/react/features/video-quality/components/VideoQualityDialog.web.js new file mode 100644 index 000000000..4643447ef --- /dev/null +++ b/react/features/video-quality/components/VideoQualityDialog.web.js @@ -0,0 +1,324 @@ +import InlineMessage from '@atlaskit/inline-message'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { + setAudioOnly, + setReceiveVideoQuality, + VIDEO_QUALITY_LEVELS +} from '../../base/conference'; + +import { translate } from '../../base/i18n'; + +const { + HIGH, + STANDARD, + LOW +} = VIDEO_QUALITY_LEVELS; + +/** + * Implements a React {@link Component} which displays a dialog with a slider + * for selecting a new receive video quality. + * + * @extends Component + */ +class VideoQualityDialog extends Component { + /** + * {@code VideoQualityDialog}'s property types. + * + * @static + */ + static propTypes = { + /** + * Whether or not the conference is in audio only mode. + */ + _audioOnly: React.PropTypes.bool, + + /** + * Whether or not the conference is in peer to peer mode. + */ + _p2p: React.PropTypes.bool, + + /** + * The currently configured maximum quality resolution to be received + * from remote participants. + */ + _receiveVideoQuality: React.PropTypes.number, + + /** + * Invoked to request toggling of audio only mode. + */ + dispatch: React.PropTypes.func, + + /** + * Invoked to obtain translated strings. + */ + t: React.PropTypes.func + }; + + /** + * Initializes a new {@code VideoQualityDialog} instance. + * + * @param {Object} props - The read-only React Component props with which + * the new instance is to be initialized. + */ + constructor(props) { + super(props); + + // Bind event handlers so they are only bound once for every instance. + this._enableAudioOnly = this._enableAudioOnly.bind(this); + this._enableHighDefinition = this._enableHighDefinition.bind(this); + this._enableLowDefinition = this._enableLowDefinition.bind(this); + this._enableStandardDefinition + = this._enableStandardDefinition.bind(this); + this._onSliderChange = this._onSliderChange.bind(this); + + /** + * An array of configuration options for displaying a choice in the + * input. The onSelect callback will be invoked when the option is + * selected and videoQuality helps determine which choice matches with + * the currently active quality level. + * + * @private + * @type {Object[]} + */ + this._sliderOptions = [ + { + audioOnly: true, + onSelect: this._enableAudioOnly, + textKey: 'audioOnly.audioOnly' + }, + { + onSelect: this._enableLowDefinition, + textKey: 'videoStatus.lowDefinition', + videoQuality: LOW + }, + { + onSelect: this._enableStandardDefinition, + textKey: 'videoStatus.standardDefinition', + videoQuality: STANDARD + }, + { + onSelect: this._enableHighDefinition, + textKey: 'videoStatus.highDefinition', + videoQuality: HIGH + } + ]; + } + + /** + * Implements React's {@link Component#render()}. + * + * @inheritdoc + * @returns {ReactElement} + */ + render() { + const { _audioOnly, _p2p, t } = this.props; + const activeSliderOption = this._mapCurrentQualityToSliderValue(); + + return ( +
+

+ { t('videoStatus.callQuality') } +

+ { !_audioOnly && _p2p ? this._renderP2PMessage() : null } +
+
+ { /* FIXME: onChange and onMouseUp are both used for + * compatibility with IE11. This workaround can be + * removed after upgrading to React 16. + */ } + + +
+
+ { this._createLabels(activeSliderOption) } +
+
+
+ ); + } + + /** + * Creates React Elements for notifying that peer to peer is enabled. + * + * @private + * @returns {ReactElement} + */ + _renderP2PMessage() { + const { t } = this.props; + + return ( + + { t('videoStatus.p2pVideoQualityDescription') } + + ); + } + + /** + * Creates React Elements to display mock tick marks with associated labels. + * + * @param {number} activeLabelIndex - Which of the sliderOptions should + * display as currently active. + * @private + * @returns {ReactElement[]} + */ + _createLabels(activeLabelIndex) { + const labelsCount = this._sliderOptions.length; + const maxWidthOfLabel = `${100 / labelsCount}%`; + + return this._sliderOptions.map((sliderOption, index) => { + const style = { + maxWidth: maxWidthOfLabel, + left: `${(index * 100) / (labelsCount - 1)}%` + }; + + const isActiveClass = activeLabelIndex === index ? 'active' : ''; + const className + = `video-quality-dialog-label-container ${isActiveClass}`; + + return ( +
+
+ { this.props.t(sliderOption.textKey) } +
+
+ ); + }); + } + + /** + * Dispatches an action to enable audio only mode. + * + * @private + * @returns {void} + */ + _enableAudioOnly() { + this.props.dispatch(setAudioOnly(true)); + } + + /** + * Dispatches an action to receive high quality video from remote + * participants. + * + * @private + * @returns {void} + */ + _enableHighDefinition() { + this.props.dispatch(setReceiveVideoQuality(HIGH)); + } + + /** + * Dispatches an action to receive low quality video from remote + * participants. + * + * @private + * @returns {void} + */ + _enableLowDefinition() { + this.props.dispatch(setReceiveVideoQuality(LOW)); + } + + /** + * Dispatches an action to receive standard quality video from remote + * participants. + * + * @private + * @returns {void} + */ + _enableStandardDefinition() { + this.props.dispatch(setReceiveVideoQuality(STANDARD)); + } + + /** + * Matches the current video quality state with corresponding index of the + * component's slider options. + * + * @private + * @returns {void} + */ + _mapCurrentQualityToSliderValue() { + const { _audioOnly, _receiveVideoQuality } = this.props; + const { _sliderOptions } = this; + + if (_audioOnly) { + const audioOnlyOption = _sliderOptions.find( + ({ audioOnly }) => audioOnly); + + return _sliderOptions.indexOf(audioOnlyOption); + } + + const matchingOption = _sliderOptions.find( + ({ videoQuality }) => videoQuality === _receiveVideoQuality); + + return _sliderOptions.indexOf(matchingOption); + } + + /** + * Invokes a callback when the selected video quality changes. + * + * @param {Object} event - The slider's change event. + * @private + * @returns {void} + */ + _onSliderChange(event) { + const { _audioOnly, _receiveVideoQuality } = this.props; + const { + audioOnly, + onSelect, + videoQuality + } = this._sliderOptions[event.target.value]; + + // Take no action if the newly chosen option does not change audio only + // or video quality state. + if ((_audioOnly && audioOnly) + || (!_audioOnly && videoQuality === _receiveVideoQuality)) { + return; + } + + onSelect(); + } +} + +/** + * Maps (parts of) the Redux state to the associated props for the + * {@code VideoQualityDialog} component. + * + * @param {Object} state - The Redux state. + * @private + * @returns {{ + * _audioOnly: boolean, + * _p2p: boolean, + * _receiveVideoQuality: boolean + * }} + */ +function _mapStateToProps(state) { + const { + audioOnly, + p2p, + receiveVideoQuality + } = state['features/base/conference']; + + return { + _audioOnly: audioOnly, + _p2p: p2p, + _receiveVideoQuality: receiveVideoQuality + }; +} + +export default translate(connect(_mapStateToProps)(VideoQualityDialog)); + diff --git a/react/features/video-quality/components/VideoQualityLabel.native.js b/react/features/video-quality/components/VideoQualityLabel.native.js new file mode 100644 index 000000000..e69de29bb diff --git a/react/features/video-status-label/components/VideoStatusLabel.js b/react/features/video-quality/components/VideoQualityLabel.web.js similarity index 52% rename from react/features/video-status-label/components/VideoStatusLabel.js rename to react/features/video-quality/components/VideoQualityLabel.web.js index 2bed11bf8..b4e9ef274 100644 --- a/react/features/video-status-label/components/VideoStatusLabel.js +++ b/react/features/video-quality/components/VideoQualityLabel.web.js @@ -1,9 +1,37 @@ +import AKInlineDialog from '@atlaskit/inline-dialog'; import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { toggleAudioOnly } from '../../base/conference'; import { translate } from '../../base/i18n'; +import { VideoQualityDialog } from './'; + +import { + VIDEO_QUALITY_LEVELS +} from '../../base/conference'; + +const { HIGH, STANDARD, LOW } = VIDEO_QUALITY_LEVELS; + +/** + * Expected video resolutions placed into an array, sorted from lowest to + * highest resolution. + * + * @type {number[]} + */ +const RESOLUTIONS + = Object.values(VIDEO_QUALITY_LEVELS).sort((a, b) => a - b); + +/** + * A map of video resolution (number) to translation key. + * + * @type {Object} + */ +const RESOLUTION_TO_TRANSLATION_KEY = { + [HIGH]: 'videoStatus.hd', + [STANDARD]: 'videoStatus.sd', + [LOW]: 'videoStatus.ld' +}; + /** * React {@code Component} responsible for displaying a label that indicates * the displayed video state of the current conference. {@code AudioOnlyLabel} @@ -11,9 +39,9 @@ import { translate } from '../../base/i18n'; * will display if not in audio only mode and a high-definition large video is * being displayed. */ -export class VideoStatusLabel extends Component { +export class VideoQualityLabel extends Component { /** - * {@code VideoStatusLabel}'s property types. + * {@code VideoQualityLabel}'s property types. * * @static */ @@ -34,11 +62,6 @@ export class VideoStatusLabel extends Component { */ _filmstripVisible: React.PropTypes.bool, - /** - * Whether or not a high-definition large video is displayed. - */ - _largeVideoHD: React.PropTypes.bool, - /** * Whether or note remote videos are visible in the filmstrip, * regardless of count. Used to determine display classes to set. @@ -46,9 +69,9 @@ export class VideoStatusLabel extends Component { _remoteVideosVisible: React.PropTypes.bool, /** - * Invoked to request toggling of audio only mode. + * The current video resolution (height) to display a label for. */ - dispatch: React.PropTypes.func, + _resolution: React.PropTypes.number, /** * Invoked to obtain translated strings. @@ -57,7 +80,7 @@ export class VideoStatusLabel extends Component { }; /** - * Initializes a new {@code VideoStatusLabel} instance. + * Initializes a new {@code VideoQualityLabel} instance. * * @param {Object} props - The read-only React Component props with which * the new instance is to be initialized. @@ -66,13 +89,25 @@ export class VideoStatusLabel extends Component { super(props); this.state = { - // Whether or not the filmstrip is transitioning from not visible - // to visible. Used to set a transition class for animation. + /** + * Whether or not the {@code VideoQualityDialog} is displayed. + * + * @type {boolean} + */ + showVideoQualityDialog: false, + + /** + * Whether or not the filmstrip is transitioning from not visible + * to visible. Used to set a transition class for animation. + * + * @type {boolean} + */ togglingToVisible: false }; - // Bind event handler so it is only bound once for every instance. - this._toggleAudioOnly = this._toggleAudioOnly.bind(this); + // Bind event handlers so they are only bound once for every instance. + this._onDialogClose = this._onDialogClose.bind(this); + this._onDialogToggle = this._onDialogToggle.bind(this); } /** @@ -103,8 +138,7 @@ export class VideoStatusLabel extends Component { _conferenceStarted, _filmstripVisible, _remoteVideosVisible, - _largeVideoHD, - t + _resolution } = this.props; // FIXME The _conferenceStarted check is used to be defensive against @@ -114,15 +148,6 @@ export class VideoStatusLabel extends Component { return null; } - let displayedLabel; - - if (_audioOnly) { - displayedLabel = ; - } else { - displayedLabel = _largeVideoHD - ? t('videoStatus.hd') : t('videoStatus.sd'); - } - // Determine which classes should be set on the component. These classes // will used to help with animations and setting position. const baseClasses = 'video-state-indicator moveToCorner'; @@ -138,57 +163,77 @@ export class VideoStatusLabel extends Component { return (
- { displayedLabel } - { this._renderVideonMenu() } + id = 'videoResolutionLabel' + onClick = { this._onDialogToggle }> + } + isOpen = { this.state.showVideoQualityDialog } + onClose = { this._onDialogClose } + position = { 'left top' }> +
+ { _audioOnly + ? + : this._mapResolutionToTranslation(_resolution) } +
+
); } /** - * Renders a dropdown menu for changing video modes. + * Matches the passed in resolution with a translation key for describing + * the resolution. The passed in resolution will be matched with a known + * resolution that it is at least greater than or equal to. * + * @param {number} resolution - The video height to match with a + * translation. * @private - * @returns {ReactElement} + * @returns {string} */ - _renderVideonMenu() { - const { _audioOnly, t } = this.props; - const audioOnlyAttributes = _audioOnly ? { className: 'active' } - : { onClick: this._toggleAudioOnly }; - const videoAttributes = _audioOnly ? { onClick: this._toggleAudioOnly } - : { className: 'active' }; + _mapResolutionToTranslation(resolution) { + // Set the default matching resolution of the lowest just in case a + // match is not found. + let highestMatchingResolution = RESOLUTIONS[0]; - return ( -
-
-
- - { t('audioOnly.audioOnly') } -
-
- - { this.props._largeVideoHD - ? t('videoStatus.hdVideo') - : t('videoStatus.sdVideo') } -
-
-
- ); + for (let i = 0; i < RESOLUTIONS.length; i++) { + const knownResolution = RESOLUTIONS[i]; + + if (resolution >= knownResolution) { + highestMatchingResolution = knownResolution; + } else { + break; + } + } + + return this.props.t( + RESOLUTION_TO_TRANSLATION_KEY[highestMatchingResolution]); } /** - * Dispatches an action to toggle the state of audio only mode. + * Toggles the display of the {@code VideoQualityDialog}. * * @private * @returns {void} */ - _toggleAudioOnly() { - this.props.dispatch(toggleAudioOnly()); + _onDialogToggle() { + this.setState({ + showVideoQualityDialog: !this.state.showVideoQualityDialog + }); + } + + /** + * Hides the attached inline dialog. + * + * @private + * @returns {void} + */ + _onDialogClose() { + this.setState({ showVideoQualityDialog: false }); } } /** - * Maps (parts of) the Redux state to the associated {@code VideoStatusLabel}'s + * Maps (parts of) the Redux state to the associated {@code VideoQualityLabel}'s * props. * * @param {Object} state - The Redux state. @@ -197,28 +242,30 @@ export class VideoStatusLabel extends Component { * _audioOnly: boolean, * _conferenceStarted: boolean, * _filmstripVisible: true, - * _largeVideoHD: (boolean|undefined), - * _remoteVideosVisible: boolean + * _remoteVideosVisible: boolean, + * _resolution: number * }} */ function _mapStateToProps(state) { const { audioOnly, - conference, - isLargeVideoHD + conference } = state['features/base/conference']; const { remoteVideosVisible, visible } = state['features/filmstrip']; + const { + resolution + } = state['features/large-video']; return { _audioOnly: audioOnly, _conferenceStarted: Boolean(conference), _filmstripVisible: visible, - _largeVideoHD: isLargeVideoHD, - _remoteVideosVisible: remoteVideosVisible + _remoteVideosVisible: remoteVideosVisible, + _resolution: resolution }; } -export default translate(connect(_mapStateToProps)(VideoStatusLabel)); +export default translate(connect(_mapStateToProps)(VideoQualityLabel)); diff --git a/react/features/video-quality/components/index.js b/react/features/video-quality/components/index.js new file mode 100644 index 000000000..8a6c90d76 --- /dev/null +++ b/react/features/video-quality/components/index.js @@ -0,0 +1,3 @@ +export { default as VideoQualityButton } from './VideoQualityButton'; +export { default as VideoQualityDialog } from './VideoQualityDialog'; +export { default as VideoQualityLabel } from './VideoQualityLabel'; diff --git a/react/features/video-status-label/index.js b/react/features/video-quality/index.js similarity index 100% rename from react/features/video-status-label/index.js rename to react/features/video-quality/index.js diff --git a/react/features/video-status-label/components/index.js b/react/features/video-status-label/components/index.js deleted file mode 100644 index 88de9e62f..000000000 --- a/react/features/video-status-label/components/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as VideoStatusLabel } from './VideoStatusLabel';