feat(quality-slider): initial implementation (#1817)

* feat(quality-slider): initial implementation

- Add new menu button with an Inline Dialog slider for
  selecting received video quality.
- Place P2P status in redux store for the Inline Dialog
  to display a warning about not respecting video quality
  selection.
- Respond to data channel open events by setting receive
  video quality. This is for lonely call cases where a
  setting is set before the data channel is open.
- Remove dropdown menu from video status label and clean
  up related js and css.

* first pass at addressing feedback

- Move VideoStatusLabel to video-quality directory.
- Rename VideoStatusLabel to VideoQualityLabel.
- Open VideoQualitydialog from VideoQualityLabel.
- New CSS for making VideoQualityLabel display properly.
- Do not render VideoQualityLabel in filmstrip only instead of hiding with css.
- Remove tooltip from VideoQualityLabel.
- Show LD, SD, HD labels in VideoQualityLabel.
- Remove action SET_LARGE_VIDEO_HD_STATUS from conference.
- Create new action UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION in large-video.
- Move VideoQualityButton into video-quality directory.
- General renaming (medium -> standard, menu -> dialog).
- Render P2P message between title and slider.
- Add padding to slider for displacement caused by P2P message's new placement.
- Fix display issue with VideoQualityButton displaying out of line in the
  primary toolbar.

* second pass at addressing feedback

- Fix p2p inline message color
- Force labels to break on words
- Resolve rebase issues, including only dispatching quality
  update on change. Before there was double calling of dispatch
  produced by an IE11 workaround. This breaks now when setting
  audio only mode to true twice.
- Rename some instances of quality to definition

* rename to data channel opened

* do not show p2p in audio only

* stop toggle audio only icon automatically

* remove fixme about toolbar button

* find closest resolution for label

* toggle dialog on button click

* redo last commit for both button and label
This commit is contained in:
virtuacoplenny 2017-08-09 12:40:03 -07:00 committed by yanas
parent c1fb1a7def
commit d8cd3e75b4
28 changed files with 1056 additions and 243 deletions

View File

@ -25,9 +25,11 @@ import {
conferenceFailed, conferenceFailed,
conferenceJoined, conferenceJoined,
conferenceLeft, conferenceLeft,
dataChannelOpened,
toggleAudioOnly, toggleAudioOnly,
EMAIL_COMMAND, EMAIL_COMMAND,
lockStateChanged lockStateChanged,
p2pStatusChanged
} from './react/features/base/conference'; } from './react/features/base/conference';
import { updateDeviceList } from './react/features/base/devices'; import { updateDeviceList } from './react/features/base/devices';
import { import {
@ -1704,6 +1706,12 @@ export default {
APP.UI.handleLastNEndpoints(leavingIds, enteringIds); APP.UI.handleLastNEndpoints(leavingIds, enteringIds);
}); });
room.on(
ConferenceEvents.P2P_STATUS,
(jitsiConference, p2p) => {
APP.store.dispatch(p2pStatusChanged(p2p));
});
room.on( room.on(
ConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED, ConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED,
(id, connectionStatus) => { (id, connectionStatus) => {
@ -1952,6 +1960,12 @@ export default {
} }
); );
room.on(
ConferenceEvents.DATA_CHANNEL_OPENED, () => {
APP.store.dispatch(dataChannelOpened());
}
);
// call hangup // call hangup
APP.UI.addListener(UIEvents.HANGUP, () => { APP.UI.addListener(UIEvents.HANGUP, () => {
this.hangup(true); this.hangup(true);

View File

@ -42,6 +42,14 @@
position: relative; position: relative;
z-index: $toolbarZ; 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. * Toolbar button styles.
*/ */
@ -94,10 +102,6 @@
&.icon-microphone { &.icon-microphone {
@extend .icon-mic-disabled; @extend .icon-mic-disabled;
} }
&.icon-visibility {
@extend .icon-visibility-off;
}
} }
&.unclickable { &.unclickable {

View File

@ -535,111 +535,3 @@
1px 0px 1px rgba(0,0,0,0.3), 1px 0px 1px rgba(0,0,0,0.3),
0px 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;
}
}

View File

@ -43,6 +43,7 @@
@import 'modals/dialog'; @import 'modals/dialog';
@import 'modals/feedback/feedback'; @import 'modals/feedback/feedback';
@import 'modals/speaker_stats/speaker_stats'; @import 'modals/speaker_stats/speaker_stats';
@import 'modals/video-quality/video-quality';
@import 'videolayout_default'; @import 'videolayout_default';
@import 'notice'; @import 'notice';
@import 'popup_menu'; @import 'popup_menu';

View File

@ -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;
}

View File

@ -35,7 +35,7 @@ var interfaceConfig = { // eslint-disable-line no-unused-vars
//main toolbar //main toolbar
'microphone', 'camera', 'desktop', 'invite', 'fullscreen', 'fodeviceselection', 'hangup', // jshint ignore:line 'microphone', 'camera', 'desktop', 'invite', 'fullscreen', 'fodeviceselection', 'hangup', // jshint ignore:line
//extended toolbar //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 * Main Toolbar Buttons
* All of them should be in TOOLBAR_BUTTONS * All of them should be in TOOLBAR_BUTTONS

View File

@ -449,10 +449,18 @@
"unlocked": "This call is unlocked. Any new caller with the link may join the call." "unlocked": "This call is unlocked. Any new caller with the link may join the call."
}, },
"videoStatus": { "videoStatus": {
"hd": "HD", "callQuality": "Call Quality",
"hdVideo": "HD video", "changeVideoTip": "Change your video quality from the left toolbar.",
"sd": "SD", "hd": "HD",
"sdVideo": "SD video" "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": { "dialOut": {
"dial": "Dial", "dial": "Dial",

View File

@ -1,4 +1,4 @@
/* global $, APP, config, JitsiMeetJS */ /* global $, APP, JitsiMeetJS */
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
@ -9,7 +9,9 @@ import { PresenceLabel } from '../../../react/features/presence-status';
const logger = require("jitsi-meet-logger").getLogger(__filename); 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 Avatar from "../avatar/Avatar";
import {createDeferred} from '../../util/helpers'; import {createDeferred} from '../../util/helpers';
@ -659,10 +661,13 @@ export default class LargeVideoManager {
*/ */
_onVideoResolutionUpdate() { _onVideoResolutionUpdate() {
const { height, width } = this.videoContainer.getStreamSize(); const { height, width } = this.videoContainer.getStreamSize();
const currentAspectRatio = width/ height; const { resolution } = APP.store.getState()['features/large-video'];
const isCurrentlyHD = Math.min(height, width) >= config.minHDHeight;
APP.store.dispatch(setLargeVideoHDStatus(isCurrentlyHD)); if (height !== resolution) {
APP.store.dispatch(updateKnownLargeVideoResolution(height));
}
const currentAspectRatio = width / height;
if (this._videoAspectRatio !== currentAspectRatio) { if (this._videoAspectRatio !== currentAspectRatio) {
this._videoAspectRatio = currentAspectRatio; this._videoAspectRatio = currentAspectRatio;

View File

@ -52,6 +52,16 @@ export const CONFERENCE_WILL_JOIN = Symbol('CONFERENCE_WILL_JOIN');
*/ */
export const CONFERENCE_WILL_LEAVE = Symbol('CONFERENCE_WILL_LEAVE'); 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 * The type of (redux) action which signals that the lock state of a specific
* {@code JitsiConference} changed. * {@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'); 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 * The type of (redux) action which sets the audio-only flag for the current
* conference. * conference.
@ -75,17 +96,6 @@ export const LOCK_STATE_CHANGED = Symbol('LOCK_STATE_CHANGED');
*/ */
export const SET_AUDIO_ONLY = Symbol('SET_AUDIO_ONLY'); 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). * 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'); 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 * The type of (redux) action which sets the name of the room of the
* conference to be joined. * conference to be joined.

View File

@ -17,12 +17,14 @@ import {
CONFERENCE_LEFT, CONFERENCE_LEFT,
CONFERENCE_WILL_JOIN, CONFERENCE_WILL_JOIN,
CONFERENCE_WILL_LEAVE, CONFERENCE_WILL_LEAVE,
DATA_CHANNEL_OPENED,
LOCK_STATE_CHANGED, LOCK_STATE_CHANGED,
P2P_STATUS_CHANGED,
SET_AUDIO_ONLY, SET_AUDIO_ONLY,
SET_LARGE_VIDEO_HD_STATUS,
SET_LASTN, SET_LASTN,
SET_PASSWORD, SET_PASSWORD,
SET_PASSWORD_FAILED, SET_PASSWORD_FAILED,
SET_RECEIVE_VIDEO_QUALITY,
SET_ROOM SET_ROOM
} from './actionTypes'; } from './actionTypes';
import { 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. * 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. * 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 * 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. * 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. * Sets (the name of) the room of the conference to be joined.
* *

View File

@ -34,3 +34,15 @@ export const EMAIL_COMMAND = 'email';
* from the outside is not cool but it should suffice for now. * from the outside is not cool but it should suffice for now.
*/ */
export const JITSI_CONFERENCE_URL_KEY = Symbol('url'); 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
};

View File

@ -14,14 +14,17 @@ import { TRACK_ADDED, TRACK_REMOVED } from '../tracks';
import { import {
createConference, createConference,
setAudioOnly, setAudioOnly,
setLastN setLastN,
toggleAudioOnly
} from './actions'; } from './actions';
import { import {
CONFERENCE_FAILED, CONFERENCE_FAILED,
CONFERENCE_JOINED, CONFERENCE_JOINED,
CONFERENCE_LEFT, CONFERENCE_LEFT,
DATA_CHANNEL_OPENED,
SET_AUDIO_ONLY, SET_AUDIO_ONLY,
SET_LASTN SET_LASTN,
SET_RECEIVE_VIDEO_QUALITY
} from './actionTypes'; } from './actionTypes';
import { import {
_addLocalTracksToConference, _addLocalTracksToConference,
@ -47,6 +50,9 @@ MiddlewareRegistry.register(store => next => action => {
case CONFERENCE_JOINED: case CONFERENCE_JOINED:
return _conferenceJoined(store, next, action); return _conferenceJoined(store, next, action);
case DATA_CHANNEL_OPENED:
return _syncReceiveVideoQuality(store, next, action);
case PIN_PARTICIPANT: case PIN_PARTICIPANT:
return _pinParticipant(store, next, action); return _pinParticipant(store, next, action);
@ -56,6 +62,9 @@ MiddlewareRegistry.register(store => next => action => {
case SET_LASTN: case SET_LASTN:
return _setLastN(store, next, action); return _setLastN(store, next, action);
case SET_RECEIVE_VIDEO_QUALITY:
return _setReceiveVideoQuality(store, next, action);
case TRACK_ADDED: case TRACK_ADDED:
case TRACK_REMOVED: case TRACK_REMOVED:
return _trackAddedOrRemoved(store, next, action); return _trackAddedOrRemoved(store, next, action);
@ -253,6 +262,33 @@ function _setLastN(store, next, action) {
return 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 * Synchronizes local tracks from state with local tracks in JitsiConference
* instance. * instance.
@ -282,6 +318,27 @@ function _syncConferenceLocalTracksWithState(store, action) {
return promise || Promise.resolve(); 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 * Notifies the feature base/conference that the action TRACK_ADDED
* or TRACK_REMOVED is being dispatched within a specific redux store. * or TRACK_REMOVED is being dispatched within a specific redux store.

View File

@ -10,11 +10,15 @@ import {
CONFERENCE_WILL_JOIN, CONFERENCE_WILL_JOIN,
CONFERENCE_WILL_LEAVE, CONFERENCE_WILL_LEAVE,
LOCK_STATE_CHANGED, LOCK_STATE_CHANGED,
P2P_STATUS_CHANGED,
SET_AUDIO_ONLY, SET_AUDIO_ONLY,
SET_LARGE_VIDEO_HD_STATUS,
SET_PASSWORD, SET_PASSWORD,
SET_RECEIVE_VIDEO_QUALITY,
SET_ROOM SET_ROOM
} from './actionTypes'; } from './actionTypes';
import {
VIDEO_QUALITY_LEVELS
} from './constants';
import { isRoomValid } from './functions'; import { isRoomValid } from './functions';
/** /**
@ -41,15 +45,18 @@ ReducerRegistry.register('features/base/conference', (state = {}, action) => {
case LOCK_STATE_CHANGED: case LOCK_STATE_CHANGED:
return _lockStateChanged(state, action); return _lockStateChanged(state, action);
case P2P_STATUS_CHANGED:
return _p2pStatusChanged(state, action);
case SET_AUDIO_ONLY: case SET_AUDIO_ONLY:
return _setAudioOnly(state, action); return _setAudioOnly(state, action);
case SET_LARGE_VIDEO_HD_STATUS:
return _setLargeVideoHDStatus(state, action);
case SET_PASSWORD: case SET_PASSWORD:
return _setPassword(state, action); return _setPassword(state, action);
case SET_RECEIVE_VIDEO_QUALITY:
return _setReceiveVideoQuality(state, action);
case SET_ROOM: case SET_ROOM:
return _setRoom(state, action); return _setRoom(state, action);
} }
@ -135,7 +142,15 @@ function _conferenceJoined(state, { conference }) {
* @type {boolean} * @type {boolean}
*/ */
locked, 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 * Reduces a specific Redux action SET_AUDIO_ONLY of the feature
* base/conference. * base/conference.
@ -242,21 +271,6 @@ function _setAudioOnly(state, action) {
return set(state, 'audioOnly', action.audioOnly); 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. * Reduces a specific Redux action SET_PASSWORD of the feature base/conference.
* *
@ -294,6 +308,21 @@ function _setPassword(state, { conference, method, password }) {
return state; 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. * Reduces a specific Redux action SET_ROOM of the feature base/conference.
* *

View File

@ -8,3 +8,14 @@
*/ */
export const SELECT_LARGE_VIDEO_PARTICIPANT export const SELECT_LARGE_VIDEO_PARTICIPANT
= Symbol('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');

View File

@ -5,7 +5,10 @@ import {
getTrackByMediaTypeAndParticipant getTrackByMediaTypeAndParticipant
} from '../base/tracks'; } 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. * 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 * Returns the most recent existing video track. It can be local or remote
* video. * video.

View File

@ -3,7 +3,9 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Watermarks } from '../../base/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. * Implements a React {@link Component} which represents the large video (a.k.a.
@ -66,7 +68,7 @@ export default class LargeVideo extends Component {
</div> </div>
<span id = 'localConnectionMessage' /> <span id = 'localConnectionMessage' />
<VideoStatusLabel /> { interfaceConfig.filmStripOnly ? null : <VideoQualityLabel /> }
<span <span
className = 'video-state-indicator centeredVideoLabel' className = 'video-state-indicator centeredVideoLabel'

View File

@ -1,7 +1,10 @@
import { PARTICIPANT_ID_CHANGED } from '../base/participants'; import { PARTICIPANT_ID_CHANGED } from '../base/participants';
import { ReducerRegistry } from '../base/redux'; import { ReducerRegistry } from '../base/redux';
import { SELECT_LARGE_VIDEO_PARTICIPANT } from './actionTypes'; import {
SELECT_LARGE_VIDEO_PARTICIPANT,
UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION
} from './actionTypes';
ReducerRegistry.register('features/large-video', (state = {}, action) => { ReducerRegistry.register('features/large-video', (state = {}, action) => {
switch (action.type) { switch (action.type) {
@ -25,6 +28,12 @@ ReducerRegistry.register('features/large-video', (state = {}, action) => {
...state, ...state,
participantId: action.participantId participantId: action.participantId
}; };
case UPDATE_KNOWN_LARGE_VIDEO_RESOLUTION:
return {
...state,
resolution: action.resolution
};
} }
return state; return state;

View File

@ -1 +1,2 @@
export { default as ToolbarButton } from './ToolbarButton';
export { default as Toolbox } from './Toolbox'; export { default as Toolbox } from './Toolbox';

View File

@ -7,6 +7,8 @@ import { openDialOutDialog } from '../dial-out';
import { openAddPeopleDialog, openInviteDialog } from '../invite'; import { openAddPeopleDialog, openInviteDialog } from '../invite';
import UIEvents from '../../../service/UI/UIEvents'; import UIEvents from '../../../service/UI/UIEvents';
import { VideoQualityButton } from '../video-quality';
declare var APP: Object; declare var APP: Object;
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
declare var JitsiMeetJS: Object; declare var JitsiMeetJS: Object;
@ -420,6 +422,10 @@ const buttons: Object = {
} }
], ],
tooltipKey: 'toolbar.sharedvideo' tooltipKey: 'toolbar.sharedvideo'
},
videoquality: {
component: VideoQualityButton
} }
}; };

View File

@ -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 (
<AKInlineDialog
content = { <VideoQualityDialog /> }
isOpen = { _visible && this.state.showVideoQualityDialog }
onClose = { this._onDialogClose }
position = { TOOLTIP_TO_DIALOG_POSITION[tooltipPosition] }>
<ToolbarButton
button = { buttonConfiguration }
onClick = { this._onDialogToggle }
tooltipPosition = { tooltipPosition } />
</AKInlineDialog>
);
}
/**
* 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);

View File

@ -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 (
<div className = 'video-quality-dialog'>
<h3 className = 'video-quality-dialog-title'>
{ t('videoStatus.callQuality') }
</h3>
{ !_audioOnly && _p2p ? this._renderP2PMessage() : null }
<div className = 'video-quality-dialog-contents'>
<div className = 'video-quality-dialog-slider-container'>
{ /* FIXME: onChange and onMouseUp are both used for
* compatibility with IE11. This workaround can be
* removed after upgrading to React 16.
*/ }
<input
className = 'video-quality-dialog-slider'
max = { this._sliderOptions.length - 1 }
min = '0'
onChange = { this._onSliderChange }
onMouseUp = { this._onSliderChange }
step = '1'
type = 'range'
value
= { activeSliderOption } />
</div>
<div className = 'video-quality-dialog-labels'>
{ this._createLabels(activeSliderOption) }
</div>
</div>
</div>
);
}
/**
* Creates React Elements for notifying that peer to peer is enabled.
*
* @private
* @returns {ReactElement}
*/
_renderP2PMessage() {
const { t } = this.props;
return (
<InlineMessage
secondaryText = { t('videoStatus.recHighDefinitionOnly') }
title = { t('videoStatus.p2pEnabled') }>
{ t('videoStatus.p2pVideoQualityDescription') }
</InlineMessage>
);
}
/**
* 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 (
<div
className = { className }
key = { index }
style = { style }>
<div className = 'video-quality-dialog-label'>
{ this.props.t(sliderOption.textKey) }
</div>
</div>
);
});
}
/**
* 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));

View File

@ -1,9 +1,37 @@
import AKInlineDialog from '@atlaskit/inline-dialog';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { toggleAudioOnly } from '../../base/conference';
import { translate } from '../../base/i18n'; 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 * React {@code Component} responsible for displaying a label that indicates
* the displayed video state of the current conference. {@code AudioOnlyLabel} * 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 * will display if not in audio only mode and a high-definition large video is
* being displayed. * being displayed.
*/ */
export class VideoStatusLabel extends Component { export class VideoQualityLabel extends Component {
/** /**
* {@code VideoStatusLabel}'s property types. * {@code VideoQualityLabel}'s property types.
* *
* @static * @static
*/ */
@ -34,11 +62,6 @@ export class VideoStatusLabel extends Component {
*/ */
_filmstripVisible: React.PropTypes.bool, _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, * Whether or note remote videos are visible in the filmstrip,
* regardless of count. Used to determine display classes to set. * regardless of count. Used to determine display classes to set.
@ -46,9 +69,9 @@ export class VideoStatusLabel extends Component {
_remoteVideosVisible: React.PropTypes.bool, _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. * 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 * @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized. * the new instance is to be initialized.
@ -66,13 +89,25 @@ export class VideoStatusLabel extends Component {
super(props); super(props);
this.state = { 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 togglingToVisible: false
}; };
// Bind event handler so it is only bound once for every instance. // Bind event handlers so they are only bound once for every instance.
this._toggleAudioOnly = this._toggleAudioOnly.bind(this); this._onDialogClose = this._onDialogClose.bind(this);
this._onDialogToggle = this._onDialogToggle.bind(this);
} }
/** /**
@ -103,8 +138,7 @@ export class VideoStatusLabel extends Component {
_conferenceStarted, _conferenceStarted,
_filmstripVisible, _filmstripVisible,
_remoteVideosVisible, _remoteVideosVisible,
_largeVideoHD, _resolution
t
} = this.props; } = this.props;
// FIXME The _conferenceStarted check is used to be defensive against // FIXME The _conferenceStarted check is used to be defensive against
@ -114,15 +148,6 @@ export class VideoStatusLabel extends Component {
return null; return null;
} }
let displayedLabel;
if (_audioOnly) {
displayedLabel = <i className = 'icon-visibility-off' />;
} else {
displayedLabel = _largeVideoHD
? t('videoStatus.hd') : t('videoStatus.sd');
}
// Determine which classes should be set on the component. These classes // Determine which classes should be set on the component. These classes
// will used to help with animations and setting position. // will used to help with animations and setting position.
const baseClasses = 'video-state-indicator moveToCorner'; const baseClasses = 'video-state-indicator moveToCorner';
@ -138,57 +163,77 @@ export class VideoStatusLabel extends Component {
return ( return (
<div <div
className = { classNames } className = { classNames }
id = 'videoResolutionLabel' > id = 'videoResolutionLabel'
{ displayedLabel } onClick = { this._onDialogToggle }>
{ this._renderVideonMenu() } <AKInlineDialog
content = { <VideoQualityDialog /> }
isOpen = { this.state.showVideoQualityDialog }
onClose = { this._onDialogClose }
position = { 'left top' }>
<div className = 'video-quality-label-status'>
{ _audioOnly
? <i className = 'icon-visibility-off' />
: this._mapResolutionToTranslation(_resolution) }
</div>
</AKInlineDialog>
</div> </div>
); );
} }
/** /**
* 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 * @private
* @returns {ReactElement} * @returns {string}
*/ */
_renderVideonMenu() { _mapResolutionToTranslation(resolution) {
const { _audioOnly, t } = this.props; // Set the default matching resolution of the lowest just in case a
const audioOnlyAttributes = _audioOnly ? { className: 'active' } // match is not found.
: { onClick: this._toggleAudioOnly }; let highestMatchingResolution = RESOLUTIONS[0];
const videoAttributes = _audioOnly ? { onClick: this._toggleAudioOnly }
: { className: 'active' };
return ( for (let i = 0; i < RESOLUTIONS.length; i++) {
<div className = 'video-state-indicator-menu'> const knownResolution = RESOLUTIONS[i];
<div className = 'video-state-indicator-menu-options'>
<div { ...audioOnlyAttributes }> if (resolution >= knownResolution) {
<i className = 'icon-visibility' /> highestMatchingResolution = knownResolution;
{ t('audioOnly.audioOnly') } } else {
</div> break;
<div { ...videoAttributes }> }
<i className = 'icon-camera' /> }
{ this.props._largeVideoHD
? t('videoStatus.hdVideo') return this.props.t(
: t('videoStatus.sdVideo') } RESOLUTION_TO_TRANSLATION_KEY[highestMatchingResolution]);
</div>
</div>
</div>
);
} }
/** /**
* Dispatches an action to toggle the state of audio only mode. * Toggles the display of the {@code VideoQualityDialog}.
* *
* @private * @private
* @returns {void} * @returns {void}
*/ */
_toggleAudioOnly() { _onDialogToggle() {
this.props.dispatch(toggleAudioOnly()); 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. * props.
* *
* @param {Object} state - The Redux state. * @param {Object} state - The Redux state.
@ -197,28 +242,30 @@ export class VideoStatusLabel extends Component {
* _audioOnly: boolean, * _audioOnly: boolean,
* _conferenceStarted: boolean, * _conferenceStarted: boolean,
* _filmstripVisible: true, * _filmstripVisible: true,
* _largeVideoHD: (boolean|undefined), * _remoteVideosVisible: boolean,
* _remoteVideosVisible: boolean * _resolution: number
* }} * }}
*/ */
function _mapStateToProps(state) { function _mapStateToProps(state) {
const { const {
audioOnly, audioOnly,
conference, conference
isLargeVideoHD
} = state['features/base/conference']; } = state['features/base/conference'];
const { const {
remoteVideosVisible, remoteVideosVisible,
visible visible
} = state['features/filmstrip']; } = state['features/filmstrip'];
const {
resolution
} = state['features/large-video'];
return { return {
_audioOnly: audioOnly, _audioOnly: audioOnly,
_conferenceStarted: Boolean(conference), _conferenceStarted: Boolean(conference),
_filmstripVisible: visible, _filmstripVisible: visible,
_largeVideoHD: isLargeVideoHD, _remoteVideosVisible: remoteVideosVisible,
_remoteVideosVisible: remoteVideosVisible _resolution: resolution
}; };
} }
export default translate(connect(_mapStateToProps)(VideoStatusLabel)); export default translate(connect(_mapStateToProps)(VideoQualityLabel));

View File

@ -0,0 +1,3 @@
export { default as VideoQualityButton } from './VideoQualityButton';
export { default as VideoQualityDialog } from './VideoQualityDialog';
export { default as VideoQualityLabel } from './VideoQualityLabel';

View File

@ -1 +0,0 @@
export { default as VideoStatusLabel } from './VideoStatusLabel';