ref(Filmstrip): Use Thumbnail component.

This commit is contained in:
Hristo Terezov 2021-01-21 14:46:47 -06:00
parent e937e99284
commit f50872285d
96 changed files with 1311 additions and 1859 deletions

1
app.js
View File

@ -1,7 +1,6 @@
/* application specific logic */ /* application specific logic */
import 'jquery'; import 'jquery';
import 'jquery-contextmenu';
import 'jQuery-Impromptu'; import 'jQuery-Impromptu';
import 'olm'; import 'olm';

View File

@ -1399,9 +1399,6 @@ export default {
.then(() => { .then(() => {
this.localVideo = newTrack; this.localVideo = newTrack;
this._setSharingScreen(newTrack); this._setSharingScreen(newTrack);
if (newTrack) {
APP.UI.addLocalVideoStream(newTrack);
}
this.setVideoMuteStatus(this.isLocalVideoMuted()); this.setVideoMuteStatus(this.isLocalVideoMuted());
}) })
.then(resolve) .then(resolve)
@ -2408,7 +2405,11 @@ export default {
// There is no guarantee another event will trigger the update // There is no guarantee another event will trigger the update
// immediately and in all situations, for example because a remote // immediately and in all situations, for example because a remote
// participant is having connection trouble so no status changes. // participant is having connection trouble so no status changes.
APP.UI.updateAllVideos(); const displayedUserId = APP.UI.getLargeVideoID();
if (displayedUserId) {
APP.UI.updateLargeVideo(displayedUserId, true);
}
}); });
APP.UI.addListener( APP.UI.addListener(

View File

@ -3,7 +3,7 @@
**/ **/
.popupmenu { .popupmenu {
min-width: 75px; min-width: 150px;
text-align: left; text-align: left;
padding: 0px; padding: 0px;
white-space: nowrap; white-space: nowrap;
@ -109,6 +109,6 @@ ul.popupmenu {
margin: -16px -24px; margin: -16px -24px;
} }
span.remotevideomenu:hover ul.popupmenu, ul.popupmenu:hover { span.localvideomenu:hover ul.popupmenu, span.remotevideomenu:hover ul.popupmenu, ul.popupmenu:hover {
display:block !important; display:block !important;
} }

View File

@ -400,7 +400,9 @@
} }
} }
.local-video-menu-trigger,
.remote-video-menu-trigger, .remote-video-menu-trigger,
.localvideomenu,
.remotevideomenu .remotevideomenu
{ {
display: inline-block; display: inline-block;
@ -418,6 +420,7 @@
cursor: hand; cursor: hand;
} }
} }
.local-video-menu-trigger,
.remote-video-menu-trigger { .remote-video-menu-trigger {
margin-top: 7px; margin-top: 7px;
} }

View File

@ -19,7 +19,7 @@
0 0 3px $videoThumbnailSelected; 0 0 3px $videoThumbnailSelected;
} }
.remotevideomenu > .icon-menu { .remotevideomenu > .icon-menu, .localvideomenu > .icon-menu {
display: none; display: none;
} }
@ -32,7 +32,7 @@
box-shadow: inset 0 0 3px $videoThumbnailHovered, box-shadow: inset 0 0 3px $videoThumbnailHovered,
0 0 3px $videoThumbnailHovered; 0 0 3px $videoThumbnailHovered;
.remotevideomenu > .icon-menu { .remotevideomenu > .icon-menu, .localvideomenu > .icon-menu {
display: inline-block; display: inline-block;
} }
} }

View File

@ -43,6 +43,7 @@
* specifically the various status icons. * specifically the various status icons.
*/ */
.remotevideomenu, .remotevideomenu,
.localvideomenu,
.videocontainer__toptoolbar { .videocontainer__toptoolbar {
z-index: auto; z-index: auto;
} }

View File

@ -51,6 +51,7 @@
* and tooltips from getting a new location context due to translate3d. * and tooltips from getting a new location context due to translate3d.
*/ */
.connection-indicator, .connection-indicator,
.local-video-menu-trigger,
.remote-video-menu-trigger, .remote-video-menu-trigger,
.indicator-icon-container { .indicator-icon-container {
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
@ -68,7 +69,9 @@
* Move the remote video menu trigger to the bottom left of the video * Move the remote video menu trigger to the bottom left of the video
* thumbnail. * thumbnail.
*/ */
.localvideomenu,
.remotevideomenu, .remotevideomenu,
.local-video-menu-trigger,
.remote-video-menu-trigger { .remote-video-menu-trigger {
bottom: 0; bottom: 0;
left: 0; left: 0;
@ -76,6 +79,7 @@
right: auto; right: auto;
} }
.local-video-menu-trigger,
.remote-video-menu-trigger { .remote-video-menu-trigger {
margin-bottom: 7px; margin-bottom: 7px;
margin-left: $remoteVideoMenuIconMargin; margin-left: $remoteVideoMenuIconMargin;

View File

@ -33,8 +33,8 @@ import {
import { toggleLobbyMode } from '../../react/features/lobby/actions.web'; import { toggleLobbyMode } from '../../react/features/lobby/actions.web';
import { RECORDING_TYPES } from '../../react/features/recording/constants'; import { RECORDING_TYPES } from '../../react/features/recording/constants';
import { getActiveSession } from '../../react/features/recording/functions'; import { getActiveSession } from '../../react/features/recording/functions';
import { muteAllParticipants } from '../../react/features/remote-video-menu/actions';
import { toggleTileView } from '../../react/features/video-layout'; import { toggleTileView } from '../../react/features/video-layout';
import { muteAllParticipants } from '../../react/features/video-menu/actions';
import { setVideoQuality } from '../../react/features/video-quality'; import { setVideoQuality } from '../../react/features/video-quality';
import { getJitsiMeetTransport } from '../transport'; import { getJitsiMeetTransport } from '../transport';

View File

@ -115,7 +115,6 @@ UI.start = function() {
// Set the defaults for prompt dialogs. // Set the defaults for prompt dialogs.
$.prompt.setDefaults({ persistent: false }); $.prompt.setDefaults({ persistent: false });
VideoLayout.init(eventEmitter);
VideoLayout.initLargeVideo(); VideoLayout.initLargeVideo();
// Do not animate the video area on UI start (second argument passed into // Do not animate the video area on UI start (second argument passed into
@ -135,7 +134,6 @@ UI.start = function() {
if (config.iAmRecorder) { if (config.iAmRecorder) {
// in case of iAmSipGateway keep local video visible // in case of iAmSipGateway keep local video visible
if (!config.iAmSipGateway) { if (!config.iAmSipGateway) {
VideoLayout.setLocalVideoVisible(false);
APP.store.dispatch(setNotificationsEnabled(false)); APP.store.dispatch(setNotificationsEnabled(false));
} }
@ -179,14 +177,6 @@ UI.unbindEvents = () => {
$(window).off('resize'); $(window).off('resize');
}; };
/**
* Show local video stream on UI.
* @param {JitsiTrack} track stream to show
*/
UI.addLocalVideoStream = track => {
VideoLayout.changeLocalVideo(track);
};
/** /**
* Setup and show Etherpad. * Setup and show Etherpad.
* @param {string} name etherpad id * @param {string} name etherpad id
@ -227,14 +217,6 @@ UI.addUser = function(user) {
} }
}; };
/**
* Update videotype for specified user.
* @param {string} id user id
* @param {string} newVideoType new videotype
*/
UI.onPeerVideoTypeChanged
= (id, newVideoType) => VideoLayout.onVideoTypeChanged(id, newVideoType);
/** /**
* Updates the user status. * Updates the user status.
* *
@ -289,19 +271,14 @@ UI.setAudioMuted = function(id) {
* Sets muted video state for participant * Sets muted video state for participant
*/ */
UI.setVideoMuted = function(id) { UI.setVideoMuted = function(id) {
VideoLayout.onVideoMute(id); VideoLayout._updateLargeVideoIfDisplayed(id, true);
if (APP.conference.isLocalId(id)) { if (APP.conference.isLocalId(id)) {
APP.conference.updateVideoIconEnabled(); APP.conference.updateVideoIconEnabled();
} }
}; };
/** UI.updateLargeVideo = (id, forceUpdate) => VideoLayout.updateLargeVideo(id, forceUpdate);
* Triggers an update of remote video and large video displays so they may pick
* up any state changes that have occurred elsewhere.
*
* @returns {void}
*/
UI.updateAllVideos = () => VideoLayout.updateAllVideos();
/** /**
* Adds a listener that would be notified on the given type of event. * Adds a listener that would be notified on the given type of event.
@ -340,8 +317,6 @@ UI.removeListener = function(type, listener) {
*/ */
UI.emitEvent = (type, ...options) => eventEmitter.emit(type, ...options); UI.emitEvent = (type, ...options) => eventEmitter.emit(type, ...options);
UI.clickOnVideo = videoNumber => VideoLayout.togglePin(videoNumber);
// Used by torture. // Used by torture.
UI.showToolbar = timeout => APP.store.dispatch(showToolbox(timeout)); UI.showToolbar = timeout => APP.store.dispatch(showToolbox(timeout));

View File

@ -1,67 +0,0 @@
/* global $, APP */
/* eslint-disable no-unused-vars */
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { i18next } from '../../../react/features/base/i18n';
import { Thumbnail } from '../../../react/features/filmstrip';
import SmallVideo from '../videolayout/SmallVideo';
/* eslint-enable no-unused-vars */
/**
*
*/
export default class SharedVideoThumb extends SmallVideo {
/**
*
* @param {*} participant
*/
constructor(participant) {
super();
this.id = participant.id;
this.isLocal = false;
this.url = participant.id;
this.videoSpanId = 'sharedVideoContainer';
this.container = this.createContainer(this.videoSpanId);
this.$container = $(this.container);
this.renderThumbnail();
this._setThumbnailSize();
this.bindHoverHandler();
this.container.onclick = this._onContainerClick;
}
/**
*
* @param {*} spanId
*/
createContainer(spanId) {
const container = document.createElement('span');
container.id = spanId;
container.className = 'videocontainer';
const remoteVideosContainer
= document.getElementById('filmstripRemoteVideosContainer');
const localVideoContainer
= document.getElementById('localVideoTileViewContainer');
remoteVideosContainer.insertBefore(container, localVideoContainer);
return container;
}
/**
* Renders the thumbnail.
*/
renderThumbnail(isHovered = false) {
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<Thumbnail participantID = { this.id } isHovered = { isHovered } />
</I18nextProvider>
</Provider>, this.container);
}
}

View File

@ -25,129 +25,6 @@ const Filmstrip = {
*/ */
getVerticalFilmstripWidth() { getVerticalFilmstripWidth() {
return isFilmstripVisible(APP.store) ? getVerticalFilmstripVisibleAreaWidth() : 0; return isFilmstripVisible(APP.store) ? getVerticalFilmstripVisibleAreaWidth() : 0;
},
/**
* Resizes thumbnails for tile view.
*
* @param {number} width - The new width of the thumbnails.
* @param {number} height - The new height of the thumbnails.
* @param {boolean} forceUpdate
* @returns {void}
*/
resizeThumbnailsForTileView(width, height, forceUpdate = false) {
const thumbs = this._getThumbs(!forceUpdate);
if (thumbs.localThumb) {
thumbs.localThumb.css({
'padding-top': '',
height: `${height}px`,
'min-height': `${height}px`,
'min-width': `${width}px`,
width: `${width}px`
});
}
if (thumbs.remoteThumbs) {
thumbs.remoteThumbs.css({
'padding-top': '',
height: `${height}px`,
'min-height': `${height}px`,
'min-width': `${width}px`,
width: `${width}px`
});
}
},
/**
* Resizes thumbnails for horizontal view.
*
* @param {Object} dimensions - The new dimensions of the thumbnails.
* @param {boolean} forceUpdate
* @returns {void}
*/
resizeThumbnailsForHorizontalView({ local = {}, remote = {} }, forceUpdate = false) {
const thumbs = this._getThumbs(!forceUpdate);
if (thumbs.localThumb) {
const { height, width } = local;
thumbs.localThumb.css({
height: `${height}px`,
'min-height': `${height}px`,
'min-width': `${width}px`,
width: `${width}px`
});
}
if (thumbs.remoteThumbs) {
const { height, width } = remote;
thumbs.remoteThumbs.css({
height: `${height}px`,
'min-height': `${height}px`,
'min-width': `${width}px`,
width: `${width}px`
});
}
},
/**
* Resizes thumbnails for vertical view.
*
* @returns {void}
*/
resizeThumbnailsForVerticalView() {
const thumbs = this._getThumbs(true);
if (thumbs.localThumb) {
const heightToWidthPercent = 100 / interfaceConfig.LOCAL_THUMBNAIL_RATIO;
thumbs.localThumb.css({
'padding-top': `${heightToWidthPercent}%`,
width: '',
height: '',
'min-width': '',
'min-height': ''
});
}
if (thumbs.remoteThumbs) {
const heightToWidthPercent = 100 / interfaceConfig.REMOTE_THUMBNAIL_RATIO;
thumbs.remoteThumbs.css({
'padding-top': `${heightToWidthPercent}%`,
width: '',
height: '',
'min-width': '',
'min-height': ''
});
}
},
/**
* Returns thumbnails of the filmstrip
* @param onlyVisible
* @returns {object} thumbnails
*/
_getThumbs(onlyVisible = false) {
let selector = 'span';
if (onlyVisible) {
selector += ':visible';
}
const localThumb = $('#localVideoContainer');
const remoteThumbs = $('#filmstripRemoteVideosContainer').children(selector);
// Exclude the local video container if it has been hidden.
if (localThumb.hasClass('hidden')) {
return { remoteThumbs };
}
return { remoteThumbs,
localThumb };
} }
}; };

View File

@ -22,7 +22,6 @@ import {
import { PresenceLabel } from '../../../react/features/presence-status'; import { PresenceLabel } from '../../../react/features/presence-status';
import { shouldDisplayTileView } from '../../../react/features/video-layout'; import { shouldDisplayTileView } from '../../../react/features/video-layout';
/* eslint-enable no-unused-vars */ /* eslint-enable no-unused-vars */
import UIEvents from '../../../service/UI/UIEvents';
import { createDeferred } from '../../util/helpers'; import { createDeferred } from '../../util/helpers';
import AudioLevels from '../audio_levels/AudioLevels'; import AudioLevels from '../audio_levels/AudioLevels';
@ -51,21 +50,19 @@ export default class LargeVideoManager {
/** /**
* *
*/ */
constructor(emitter) { constructor() {
/** /**
* The map of <tt>LargeContainer</tt>s where the key is the video * The map of <tt>LargeContainer</tt>s where the key is the video
* container type. * container type.
* @type {Object.<string, LargeContainer>} * @type {Object.<string, LargeContainer>}
*/ */
this.containers = {}; this.containers = {};
this.eventEmitter = emitter;
this.state = VIDEO_CONTAINER_TYPE; this.state = VIDEO_CONTAINER_TYPE;
// FIXME: We are passing resizeContainer as parameter which is calling // FIXME: We are passing resizeContainer as parameter which is calling
// Container.resize. Probably there's better way to implement this. // Container.resize. Probably there's better way to implement this.
this.videoContainer = new VideoContainer( this.videoContainer = new VideoContainer(() => this.resizeContainer(VIDEO_CONTAINER_TYPE));
() => this.resizeContainer(VIDEO_CONTAINER_TYPE), emitter);
this.addContainer(VIDEO_CONTAINER_TYPE, this.videoContainer); this.addContainer(VIDEO_CONTAINER_TYPE, this.videoContainer);
// use the same video container to handle desktop tracks // use the same video container to handle desktop tracks
@ -300,7 +297,6 @@ export default class LargeVideoManager {
// after everything is done check again if there are any pending // after everything is done check again if there are any pending
// new streams. // new streams.
this.updateInProcess = false; this.updateInProcess = false;
this.eventEmitter.emit(UIEvents.LARGE_VIDEO_ID_CHANGED, this.id);
this.scheduleLargeVideoUpdate(); this.scheduleLargeVideoUpdate();
}); });
} }

View File

@ -1,213 +0,0 @@
/* global $, config, APP */
/* eslint-disable no-unused-vars */
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { i18next } from '../../../react/features/base/i18n';
import { JitsiTrackEvents } from '../../../react/features/base/lib-jitsi-meet';
import { VideoTrack } from '../../../react/features/base/media';
import { updateSettings } from '../../../react/features/base/settings';
import { getLocalVideoTrack } from '../../../react/features/base/tracks';
import Thumbnail from '../../../react/features/filmstrip/components/web/Thumbnail';
import { shouldDisplayTileView } from '../../../react/features/video-layout';
/* eslint-enable no-unused-vars */
import UIEvents from '../../../service/UI/UIEvents';
import SmallVideo from './SmallVideo';
/**
*
*/
export default class LocalVideo extends SmallVideo {
/**
*
* @param {*} emitter
* @param {*} streamEndedCallback
*/
constructor(emitter, streamEndedCallback) {
super();
this.videoSpanId = 'localVideoContainer';
this.streamEndedCallback = streamEndedCallback;
this.container = this.createContainer();
this.$container = $(this.container);
this.isLocal = true;
this._setThumbnailSize();
this.updateDOMLocation();
this.renderThumbnail();
this.localVideoId = null;
this.bindHoverHandler();
if (!config.disableLocalVideoFlip) {
this._buildContextMenu();
}
this.emitter = emitter;
Object.defineProperty(this, 'id', {
get() {
return APP.conference.getMyUserId();
}
});
this.initBrowserSpecificProperties();
this.container.onclick = this._onContainerClick;
}
/**
*
*/
createContainer() {
const containerSpan = document.createElement('span');
containerSpan.classList.add('videocontainer');
containerSpan.id = this.videoSpanId;
return containerSpan;
}
/**
* Renders the thumbnail.
*/
renderThumbnail(isHovered = false) {
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<Thumbnail participantID = { this.id } isHovered = { isHovered } />
</I18nextProvider>
</Provider>, this.container);
}
/**
*
* @param {*} stream
*/
changeVideo(stream) {
this.localVideoId = `localVideo_${stream.getId()}`;
// eslint-disable-next-line eqeqeq
const isVideo = stream.videoType != 'desktop';
const settings = APP.store.getState()['features/base/settings'];
this._enableDisableContextMenu(isVideo);
this.setFlipX(isVideo ? settings.localFlipX : false);
const endedHandler = () => {
this._notifyOfStreamEnded();
stream.off(JitsiTrackEvents.LOCAL_TRACK_STOPPED, endedHandler);
};
stream.on(JitsiTrackEvents.LOCAL_TRACK_STOPPED, endedHandler);
}
/**
* Notify any subscribers of the local video stream ending.
*
* @private
* @returns {void}
*/
_notifyOfStreamEnded() {
if (this.streamEndedCallback) {
this.streamEndedCallback(this.id);
}
}
/**
* Shows or hides the local video container.
* @param {boolean} true to make the local video container visible, false
* otherwise
*/
setVisible(visible) {
// We toggle the hidden class as an indication to other interested parties
// that this container has been hidden on purpose.
this.$container.toggleClass('hidden');
// We still show/hide it as we need to overwrite the style property if we
// want our action to take effect. Toggling the display property through
// the above css class didn't succeed in overwriting the style.
if (visible) {
this.$container.show();
} else {
this.$container.hide();
}
}
/**
* Sets the flipX state of the video.
* @param val {boolean} true for flipped otherwise false;
*/
setFlipX(val) {
this.emitter.emit(UIEvents.LOCAL_FLIPX_CHANGED, val);
if (!this.localVideoId) {
return;
}
if (val) {
this.selectVideoElement().addClass('flipVideoX');
} else {
this.selectVideoElement().removeClass('flipVideoX');
}
}
/**
* Builds the context menu for the local video.
*/
_buildContextMenu() {
$.contextMenu({
selector: `#${this.videoSpanId}`,
zIndex: 10000,
items: {
flip: {
name: 'Flip',
callback: () => {
const { store } = APP;
const val = !store.getState()['features/base/settings']
.localFlipX;
this.setFlipX(val);
store.dispatch(updateSettings({
localFlipX: val
}));
}
}
},
events: {
show(options) {
options.items.flip.name
= APP.translation.generateTranslationHTML(
'videothumbnail.flip');
}
}
});
}
/**
* Enables or disables the context menu for the local video.
* @param enable {boolean} true for enable, false for disable
*/
_enableDisableContextMenu(enable) {
if (this.$container.contextMenu) {
this.$container.contextMenu(enable);
}
}
/**
* Places the {@code LocalVideo} in the DOM based on the current video layout.
*
* @returns {void}
*/
updateDOMLocation() {
if (!this.container) {
return;
}
if (this.container.parentElement) {
this.container.parentElement.removeChild(this.container);
}
const appendTarget = shouldDisplayTileView(APP.store.getState())
? document.getElementById('localVideoTileViewContainer')
: document.getElementById('filmstripLocalVideoThumbnail');
appendTarget && appendTarget.appendChild(this.container);
}
}

View File

@ -1,242 +0,0 @@
/* global $, APP, config */
/* eslint-disable no-unused-vars */
import { AtlasKitThemeProvider } from '@atlaskit/theme';
import Logger from 'jitsi-meet-logger';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { i18next } from '../../../react/features/base/i18n';
import {
JitsiParticipantConnectionStatus
} from '../../../react/features/base/lib-jitsi-meet';
import { getParticipantById } from '../../../react/features/base/participants';
import { isTestModeEnabled } from '../../../react/features/base/testing';
import { updateLastTrackVideoMediaEvent } from '../../../react/features/base/tracks';
import { Thumbnail, isVideoPlayable } from '../../../react/features/filmstrip';
import { PresenceLabel } from '../../../react/features/presence-status';
import { stopController, requestRemoteControl } from '../../../react/features/remote-control';
import { RemoteVideoMenuTriggerButton } from '../../../react/features/remote-video-menu';
/* eslint-enable no-unused-vars */
import UIUtils from '../util/UIUtil';
import SmallVideo from './SmallVideo';
const logger = Logger.getLogger(__filename);
/**
* List of container events that we are going to process, will be added as listener to the
* container for every event in the list. The latest event will be stored in redux.
*/
const containerEvents = [
'abort', 'canplay', 'canplaythrough', 'emptied', 'ended', 'error', 'loadeddata', 'loadedmetadata', 'loadstart',
'pause', 'play', 'playing', 'ratechange', 'stalled', 'suspend', 'waiting'
];
/**
*
* @param {*} spanId
*/
function createContainer(spanId) {
const container = document.createElement('span');
container.id = spanId;
container.className = 'videocontainer';
const remoteVideosContainer
= document.getElementById('filmstripRemoteVideosContainer');
const localVideoContainer
= document.getElementById('localVideoTileViewContainer');
remoteVideosContainer.insertBefore(container, localVideoContainer);
return container;
}
/**
*
*/
export default class RemoteVideo extends SmallVideo {
/**
* Creates new instance of the <tt>RemoteVideo</tt>.
* @param user {JitsiParticipant} the user for whom remote video instance will
* be created.
* @constructor
*/
constructor(user) {
super();
this.user = user;
this.id = user.getId();
this.videoSpanId = `participant_${this.id}`;
this.addRemoteVideoContainer();
this.bindHoverHandler();
this.flipX = false;
this.isLocal = false;
/**
* The flag is set to <tt>true</tt> after the 'canplay' event has been
* triggered on the current video element. It goes back to <tt>false</tt>
* when the stream is removed. It is used to determine whether the video
* playback has ever started.
* @type {boolean}
*/
this._canPlayEventReceived = false;
this.container.onclick = this._onContainerClick;
}
/**
*
*/
addRemoteVideoContainer() {
this.container = createContainer(this.videoSpanId);
this.$container = $(this.container);
this.renderThumbnail();
this._setThumbnailSize();
this.initBrowserSpecificProperties();
return this.container;
}
/**
* Renders the thumbnail.
*/
renderThumbnail(isHovered = false) {
ReactDOM.render(
<Provider store = { APP.store }>
<I18nextProvider i18n = { i18next }>
<Thumbnail participantID = { this.id } isHovered = { isHovered } />
</I18nextProvider>
</Provider>, this.container);
}
/**
* Removes the remote stream element corresponding to the given stream and
* parent container.
*
* @param stream the MediaStream
* @param isVideo <tt>true</tt> if given <tt>stream</tt> is a video one.
*/
removeRemoteStreamElement(stream) {
if (!this.container) {
return false;
}
const isVideo = stream.isVideoTrack();
const elementID = `remoteVideo_${stream.getId()}`;
const select = $(`#${elementID}`);
select.remove();
if (isVideo) {
this._canPlayEventReceived = false;
}
logger.info(`Video removed ${this.id}`, select);
this.updateView();
}
/**
* The remote video is considered "playable" once the can play event has been received.
*
* @inheritdoc
* @override
*/
isVideoPlayable() {
return isVideoPlayable(APP.store.getState(), this.id) && this._canPlayEventReceived;
}
/**
* @inheritDoc
*/
updateView() {
this.$container.toggleClass('audio-only', APP.conference.isAudioOnly());
super.updateView();
}
/**
* Removes RemoteVideo from the page.
*/
remove() {
ReactDOM.unmountComponentAtNode(this.container);
super.remove();
}
/**
*
* @param {*} streamElement
* @param {*} stream
*/
waitForPlayback(streamElement, stream) {
$(streamElement).hide();
const webRtcStream = stream.getOriginalStream();
const isVideo = stream.isVideoTrack();
if (!isVideo || webRtcStream.id === 'mixedmslabel') {
return;
}
const listener = () => {
this._canPlayEventReceived = true;
logger.info(`${this.id} video is now active`, streamElement);
if (streamElement) {
$(streamElement).show();
}
streamElement.removeEventListener('canplay', listener);
// Refresh to show the video
this.updateView();
};
streamElement.addEventListener('canplay', listener);
}
/**
*
* @param {*} stream
*/
addRemoteStreamElement(stream) {
if (!this.container) {
logger.debug('Not attaching remote stream due to no container');
return;
}
const isVideo = stream.isVideoTrack();
if (!stream.getOriginalStream()) {
logger.debug('Remote video stream has no original stream');
return;
}
let streamElement = document.createElement('video');
streamElement.autoplay = !config.testing?.noAutoPlayVideo;
streamElement.id = `remoteVideo_${stream.getId()}`;
streamElement.mute = true;
streamElement.playsInline = true;
// Put new stream element always in front
streamElement = UIUtils.prependChild(this.container, streamElement);
this.waitForPlayback(streamElement, stream);
stream.attach(streamElement);
if (isVideo && isTestModeEnabled(APP.store.getState())) {
const cb = name => APP.store.dispatch(updateLastTrackVideoMediaEvent(stream, name));
containerEvents.forEach(event => {
streamElement.addEventListener(event, cb.bind(this, event));
});
}
}
}

View File

@ -1,509 +0,0 @@
/* global $, APP, interfaceConfig */
/* eslint-disable no-unused-vars */
import { AtlasKitThemeProvider } from '@atlaskit/theme';
import Logger from 'jitsi-meet-logger';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { createScreenSharingIssueEvent, sendAnalytics } from '../../../react/features/analytics';
import { AudioLevelIndicator } from '../../../react/features/audio-level-indicator';
import { Avatar as AvatarDisplay } from '../../../react/features/base/avatar';
import { i18next } from '../../../react/features/base/i18n';
import { MEDIA_TYPE } from '../../../react/features/base/media';
import {
getLocalParticipant,
getParticipantById,
getParticipantCount,
getPinnedParticipant,
pinParticipant
} from '../../../react/features/base/participants';
import {
getLocalVideoTrack,
getTrackByMediaTypeAndParticipant,
isLocalTrackMuted,
isRemoteTrackMuted
} from '../../../react/features/base/tracks';
import { ConnectionIndicator } from '../../../react/features/connection-indicator';
import { DisplayName } from '../../../react/features/display-name';
import {
DominantSpeakerIndicator,
RaisedHandIndicator,
StatusIndicators,
isVideoPlayable
} from '../../../react/features/filmstrip';
import {
LAYOUTS,
getCurrentLayout,
setTileView,
shouldDisplayTileView
} from '../../../react/features/video-layout';
/* eslint-enable no-unused-vars */
const logger = Logger.getLogger(__filename);
/**
* Display mode constant used when video is being displayed on the small video.
* @type {number}
* @constant
*/
const DISPLAY_VIDEO = 0;
/**
* Display mode constant used when the user's avatar is being displayed on
* the small video.
* @type {number}
* @constant
*/
const DISPLAY_AVATAR = 1;
/**
* Display mode constant used when neither video nor avatar is being displayed
* on the small video. And we just show the display name.
* @type {number}
* @constant
*/
const DISPLAY_BLACKNESS_WITH_NAME = 2;
/**
* Display mode constant used when video is displayed and display name
* at the same time.
* @type {number}
* @constant
*/
const DISPLAY_VIDEO_WITH_NAME = 3;
/**
* Display mode constant used when neither video nor avatar is being displayed
* on the small video. And we just show the display name.
* @type {number}
* @constant
*/
const DISPLAY_AVATAR_WITH_NAME = 4;
/**
*
*/
export default class SmallVideo {
/**
* Constructor.
*/
constructor() {
this.videoIsHovered = false;
this.videoType = undefined;
// Bind event handlers so they are only bound once for every instance.
this.updateView = this.updateView.bind(this);
this._onContainerClick = this._onContainerClick.bind(this);
}
/**
* Returns the identifier of this small video.
*
* @returns the identifier of this small video
*/
getId() {
return this.id;
}
/**
* Indicates if this small video is currently visible.
*
* @return <tt>true</tt> if this small video isn't currently visible and
* <tt>false</tt> - otherwise.
*/
isVisible() {
return this.$container.is(':visible');
}
/**
* Configures hoverIn/hoverOut handlers. Depends on connection indicator.
*/
bindHoverHandler() {
// Add hover handler
this.$container.hover(
() => {
this.videoIsHovered = true;
this.renderThumbnail(true);
this.updateView();
},
() => {
this.videoIsHovered = false;
this.renderThumbnail(false);
this.updateView();
}
);
}
/**
* Renders the thumbnail.
*/
renderThumbnail() {
// Should be implemented by in subclasses.
}
/**
* This is an especially interesting function. A naive reader might think that
* it returns this SmallVideo's "video" element. But it is much more exciting.
* It first finds this video's parent element using jquery, then uses a utility
* from lib-jitsi-meet to extract the video element from it (with two more
* jquery calls), and finally uses jquery again to encapsulate the video element
* in an array. This last step allows (some might prefer "forces") users of
* this function to access the video element via the 0th element of the returned
* array (after checking its length of course!).
*/
selectVideoElement() {
return $($(this.container).find('video')[0]);
}
/**
* Enables / disables the css responsible for focusing/pinning a video
* thumbnail.
*
* @param isFocused indicates if the thumbnail should be focused/pinned or not
*/
focus(isFocused) {
const focusedCssClass = 'videoContainerFocused';
const isFocusClassEnabled = this.$container.hasClass(focusedCssClass);
if (!isFocused && isFocusClassEnabled) {
this.$container.removeClass(focusedCssClass);
} else if (isFocused && !isFocusClassEnabled) {
this.$container.addClass(focusedCssClass);
}
}
/**
*
*/
hasVideo() {
return this.selectVideoElement().length !== 0;
}
/**
* Checks whether the user associated with this <tt>SmallVideo</tt> is currently
* being displayed on the "large video".
*
* @return {boolean} <tt>true</tt> if the user is displayed on the large video
* or <tt>false</tt> otherwise.
*/
isCurrentlyOnLargeVideo() {
return APP.store.getState()['features/large-video']?.participantId === this.id;
}
/**
* Checks whether there is a playable video stream available for the user
* associated with this <tt>SmallVideo</tt>.
*
* @return {boolean} <tt>true</tt> if there is a playable video stream available
* or <tt>false</tt> otherwise.
*/
isVideoPlayable() {
return isVideoPlayable(APP.store.getState(), this.id);
}
/**
* Determines what should be display on the thumbnail.
*
* @return {number} one of <tt>DISPLAY_VIDEO</tt>,<tt>DISPLAY_AVATAR</tt>
* or <tt>DISPLAY_BLACKNESS_WITH_NAME</tt>.
*/
selectDisplayMode(input) {
if (!input.tileViewActive && input.isScreenSharing) {
return input.isHovered ? DISPLAY_AVATAR_WITH_NAME : DISPLAY_AVATAR;
} else if (input.isCurrentlyOnLargeVideo && !input.tileViewActive) {
// Display name is always and only displayed when user is on the stage
return input.isVideoPlayable && !input.isAudioOnly ? DISPLAY_BLACKNESS_WITH_NAME : DISPLAY_AVATAR_WITH_NAME;
} else if (input.isVideoPlayable && input.hasVideo && !input.isAudioOnly) {
// check hovering and change state to video with name
return input.isHovered ? DISPLAY_VIDEO_WITH_NAME : DISPLAY_VIDEO;
}
// check hovering and change state to avatar with name
return input.isHovered ? DISPLAY_AVATAR_WITH_NAME : DISPLAY_AVATAR;
}
/**
* Computes information that determine the display mode.
*
* @returns {Object}
*/
computeDisplayModeInput() {
let isScreenSharing = false;
let connectionStatus;
const state = APP.store.getState();
const id = this.id;
const participant = getParticipantById(state, id);
const isLocal = participant?.local ?? true;
const tracks = state['features/base/tracks'];
const videoTrack
= isLocal ? getLocalVideoTrack(tracks) : getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, id);
if (typeof participant !== 'undefined' && !participant.isFakeParticipant && !participant.local) {
isScreenSharing = videoTrack?.videoType === 'desktop';
connectionStatus = participant.connectionStatus;
}
return {
isCurrentlyOnLargeVideo: this.isCurrentlyOnLargeVideo(),
isHovered: this._isHovered(),
isAudioOnly: APP.conference.isAudioOnly(),
tileViewActive: shouldDisplayTileView(state),
isVideoPlayable: this.isVideoPlayable(),
hasVideo: Boolean(this.selectVideoElement().length),
connectionStatus,
canPlayEventReceived: this._canPlayEventReceived,
videoStream: Boolean(videoTrack),
isScreenSharing,
videoStreamMuted: videoTrack ? videoTrack.muted : 'no stream'
};
}
/**
* Checks whether current video is considered hovered. Currently it is hovered
* if the mouse is over the video, or if the connection
* indicator is shown(hovered).
* @private
*/
_isHovered() {
return this.videoIsHovered;
}
/**
* Updates the css classes of the thumbnail based on the current state.
*/
updateView() {
this.$container.removeClass((index, classNames) =>
classNames.split(' ').filter(name => name.startsWith('display-')));
const oldDisplayMode = this.displayMode;
let displayModeString = '';
const displayModeInput = this.computeDisplayModeInput();
// Determine whether video, avatar or blackness should be displayed
this.displayMode = this.selectDisplayMode(displayModeInput);
switch (this.displayMode) {
case DISPLAY_AVATAR_WITH_NAME:
displayModeString = 'avatar-with-name';
this.$container.addClass('display-avatar-with-name');
break;
case DISPLAY_BLACKNESS_WITH_NAME:
displayModeString = 'blackness-with-name';
this.$container.addClass('display-name-on-black');
break;
case DISPLAY_VIDEO:
displayModeString = 'video';
this.$container.addClass('display-video');
break;
case DISPLAY_VIDEO_WITH_NAME:
displayModeString = 'video-with-name';
this.$container.addClass('display-name-on-video');
break;
case DISPLAY_AVATAR:
default:
displayModeString = 'avatar';
this.$container.addClass('display-avatar-only');
break;
}
if (this.displayMode !== oldDisplayMode) {
logger.debug(`Displaying ${displayModeString} for ${this.id}, data: [${JSON.stringify(displayModeInput)}]`);
}
if (this.displayMode !== DISPLAY_VIDEO
&& this.displayMode !== DISPLAY_VIDEO_WITH_NAME
&& displayModeInput.tileViewActive
&& displayModeInput.isScreenSharing
&& !displayModeInput.isAudioOnly) {
// send the event
sendAnalytics(createScreenSharingIssueEvent({
source: 'thumbnail',
...displayModeInput
}));
}
}
/**
* Shows or hides the dominant speaker indicator.
* @param show whether to show or hide.
*/
showDominantSpeakerIndicator(show) {
// Don't create and show dominant speaker indicator if
// DISABLE_DOMINANT_SPEAKER_INDICATOR is true
if (interfaceConfig.DISABLE_DOMINANT_SPEAKER_INDICATOR) {
return;
}
if (!this.container) {
logger.warn(`Unable to set dominant speaker indicator - ${this.videoSpanId} does not exist`);
return;
}
this.$container.toggleClass('active-speaker', show);
}
/**
* Initializes any browser specific properties. Currently sets the overflow
* property for Qt browsers on Windows to hidden, thus fixing the following
* problem:
* Some browsers don't have full support of the object-fit property for the
* video element and when we set video object-fit to "cover" the video
* actually overflows the boundaries of its container, so it's important
* to indicate that the "overflow" should be hidden.
*
* Setting this property for all browsers will result in broken audio levels,
* which makes this a temporary solution, before reworking audio levels.
*/
initBrowserSpecificProperties() {
const userAgent = window.navigator.userAgent;
if (userAgent.indexOf('QtWebEngine') > -1
&& (userAgent.indexOf('Windows') > -1 || userAgent.indexOf('Linux') > -1)) {
this.$container.css('overflow', 'hidden');
}
}
/**
* Cleans up components on {@code SmallVideo} and removes itself from the DOM.
*
* @returns {void}
*/
remove() {
logger.log('Remove thumbnail', this.id);
this._unmountThumbnail();
// Remove whole container
if (this.container.parentNode) {
this.container.parentNode.removeChild(this.container);
}
}
/**
* Helper function for re-rendering multiple react components of the small
* video.
*
* @returns {void}
*/
rerender() {
this.updateView();
}
/**
* Callback invoked when the thumbnail is clicked and potentially trigger
* pinning of the participant.
*
* @param {MouseEvent} event - The click event to intercept.
* @private
* @returns {void}
*/
_onContainerClick(event) {
const triggerPin = this._shouldTriggerPin(event);
if (event.stopPropagation && triggerPin) {
event.stopPropagation();
event.preventDefault();
}
if (triggerPin) {
this.togglePin();
}
return false;
}
/**
* Returns whether or not a click event is targeted at certain elements which
* should not trigger a pin.
*
* @param {MouseEvent} event - The click event to intercept.
* @private
* @returns {boolean}
*/
_shouldTriggerPin(event) {
// TODO Checking the classes is a workround to allow events to bubble into
// the DisplayName component if it was clicked. React's synthetic events
// will fire after jQuery handlers execute, so stop propagation at this
// point will prevent DisplayName from getting click events. This workaround
// should be removable once LocalVideo is a React Component because then
// the components share the same eventing system.
const $source = $(event.target || event.srcElement);
return $source.parents('.displayNameContainer').length === 0
&& $source.parents('.popover').length === 0
&& !event.target.classList.contains('popover');
}
/**
* Pins the participant displayed by this thumbnail or unpins if already pinned.
*
* @returns {void}
*/
togglePin() {
const pinnedParticipant = getPinnedParticipant(APP.store.getState()) || {};
const participantIdToPin = pinnedParticipant && pinnedParticipant.id === this.id ? null : this.id;
APP.store.dispatch(pinParticipant(participantIdToPin));
}
/**
* Unmounts the thumbnail.
*/
_unmountThumbnail() {
ReactDOM.unmountComponentAtNode(this.container);
}
/**
* Sets the size of the thumbnail.
*/
_setThumbnailSize() {
const layout = getCurrentLayout(APP.store.getState());
const heightToWidthPercent = 100
/ (this.isLocal ? interfaceConfig.LOCAL_THUMBNAIL_RATIO : interfaceConfig.REMOTE_THUMBNAIL_RATIO);
switch (layout) {
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: {
this.$container.css('padding-top', `${heightToWidthPercent}%`);
break;
}
case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: {
const state = APP.store.getState();
const { local, remote } = state['features/filmstrip'].horizontalViewDimensions;
const size = this.isLocal ? local : remote;
if (typeof size !== 'undefined') {
const { height, width } = size;
this.$container.css({
height: `${height}px`,
'min-height': `${height}px`,
'min-width': `${width}px`,
width: `${width}px`
});
}
break;
}
case LAYOUTS.TILE_VIEW: {
const state = APP.store.getState();
const { thumbnailSize } = state['features/filmstrip'].tileViewDimensions;
if (typeof thumbnailSize !== 'undefined') {
const { height, width } = thumbnailSize;
this.$container.css({
height: `${height}px`,
'min-height': `${height}px`,
'min-width': `${width}px`,
width: `${width}px`
});
}
break;
}
}
}
}

View File

@ -9,7 +9,6 @@ import { isTestModeEnabled } from '../../../react/features/base/testing';
import { ORIENTATION, LargeVideoBackground, updateLastLargeVideoMediaEvent } from '../../../react/features/large-video'; import { ORIENTATION, LargeVideoBackground, updateLastLargeVideoMediaEvent } from '../../../react/features/large-video';
import { LAYOUTS, getCurrentLayout } from '../../../react/features/video-layout'; import { LAYOUTS, getCurrentLayout } from '../../../react/features/video-layout';
/* eslint-enable no-unused-vars */ /* eslint-enable no-unused-vars */
import UIEvents from '../../../service/UI/UIEvents';
import UIUtil from '../util/UIUtil'; import UIUtil from '../util/UIUtil';
import Filmstrip from './Filmstrip'; import Filmstrip from './Filmstrip';
@ -187,16 +186,13 @@ export class VideoContainer extends LargeContainer {
* Creates new VideoContainer instance. * Creates new VideoContainer instance.
* @param resizeContainer {Function} function that takes care of the size * @param resizeContainer {Function} function that takes care of the size
* of the video container. * of the video container.
* @param emitter {EventEmitter} the event emitter that will be used by
* this instance.
*/ */
constructor(resizeContainer, emitter) { constructor(resizeContainer) {
super(); super();
this.stream = null; this.stream = null;
this.userId = null; this.userId = null;
this.videoType = null; this.videoType = null;
this.localFlipX = true; this.localFlipX = true;
this.emitter = emitter;
this.resizeContainer = resizeContainer; this.resizeContainer = resizeContainer;
/** /**
@ -492,7 +488,7 @@ export class VideoContainer extends LargeContainer {
stream.attach(this.$video[0]); stream.attach(this.$video[0]);
const flipX = stream.isLocal() && this.localFlipX; const flipX = stream.isLocal() && this.localFlipX && !this.isScreenSharing();
this.$video.css({ this.$video.css({
transform: flipX ? 'scaleX(-1)' : 'none' transform: flipX ? 'scaleX(-1)' : 'none'
@ -534,7 +530,6 @@ export class VideoContainer extends LargeContainer {
this.$avatar.css('visibility', show ? 'visible' : 'hidden'); this.$avatar.css('visibility', show ? 'visible' : 'hidden');
this.avatarDisplayed = show; this.avatarDisplayed = show;
this.emitter.emit(UIEvents.LARGE_VIDEO_AVATAR_VISIBLE, show);
APP.API.notifyLargeVideoVisibilityChanged(show); APP.API.notifyLargeVideoVisibilityChanged(show);
} }

View File

@ -4,88 +4,29 @@ import Logger from 'jitsi-meet-logger';
import { MEDIA_TYPE, VIDEO_TYPE } from '../../../react/features/base/media'; import { MEDIA_TYPE, VIDEO_TYPE } from '../../../react/features/base/media';
import { import {
getLocalParticipant as getLocalParticipantFromStore,
getPinnedParticipant, getPinnedParticipant,
getParticipantById, getParticipantById
pinParticipant
} from '../../../react/features/base/participants'; } from '../../../react/features/base/participants';
import { getTrackByMediaTypeAndParticipant } from '../../../react/features/base/tracks'; import { getTrackByMediaTypeAndParticipant } from '../../../react/features/base/tracks';
import UIEvents from '../../../service/UI/UIEvents';
import { SHARED_VIDEO_CONTAINER_TYPE } from '../shared_video/SharedVideo'; import { SHARED_VIDEO_CONTAINER_TYPE } from '../shared_video/SharedVideo';
import SharedVideoThumb from '../shared_video/SharedVideoThumb';
import LargeVideoManager from './LargeVideoManager'; import LargeVideoManager from './LargeVideoManager';
import LocalVideo from './LocalVideo';
import RemoteVideo from './RemoteVideo';
import { VIDEO_CONTAINER_TYPE } from './VideoContainer'; import { VIDEO_CONTAINER_TYPE } from './VideoContainer';
const logger = Logger.getLogger(__filename); const logger = Logger.getLogger(__filename);
const remoteVideos = {};
let localVideoThumbnail = null;
let eventEmitter = null;
let largeVideo; let largeVideo;
/**
* flipX state of the localVideo
*/
let localFlipX = null;
/**
* Handler for local flip X changed event.
* @param {Object} val
*/
function onLocalFlipXChanged(val) {
localFlipX = val;
if (largeVideo) {
largeVideo.onLocalFlipXChange(val);
}
}
/**
* Returns an array of all thumbnails in the filmstrip.
*
* @private
* @returns {Array}
*/
function getAllThumbnails() {
return [
...localVideoThumbnail ? [ localVideoThumbnail ] : [],
...Object.values(remoteVideos)
];
}
/**
* Private helper to get the redux representation of the local participant.
*
* @private
* @returns {Object}
*/
function getLocalParticipant() {
return getLocalParticipantFromStore(APP.store.getState());
}
const VideoLayout = { const VideoLayout = {
init(emitter) {
eventEmitter = emitter;
localVideoThumbnail = new LocalVideo(
emitter,
this._updateLargeVideoIfDisplayed.bind(this));
this.registerListeners();
},
/** /**
* Registering listeners for UI events in Video layout component. * Handler for local flip X changed event.
*
* @returns {void}
*/ */
registerListeners() { onLocalFlipXChanged() {
eventEmitter.addListener(UIEvents.LOCAL_FLIPX_CHANGED, if (largeVideo) {
onLocalFlipXChanged); const { store } = APP;
const { localFlipX } = store.getState()['features/base/settings'];
largeVideo.onLocalFlipXChange(localFlipX);
}
}, },
/** /**
@ -95,14 +36,17 @@ const VideoLayout = {
*/ */
reset() { reset() {
this._resetLargeVideo(); this._resetLargeVideo();
this._resetFilmstrip();
}, },
initLargeVideo() { initLargeVideo() {
this._resetLargeVideo(); this._resetLargeVideo();
largeVideo = new LargeVideoManager(eventEmitter); largeVideo = new LargeVideoManager();
if (localFlipX) {
const { store } = APP;
const { localFlipX } = store.getState()['features/base/settings'];
if (typeof localFlipX === 'boolean') {
largeVideo.onLocalFlipXChange(localFlipX); largeVideo.onLocalFlipXChange(localFlipX);
} }
largeVideo.updateContainerSize(); largeVideo.updateContainerSize();
@ -120,55 +64,6 @@ const VideoLayout = {
} }
}, },
changeLocalVideo(stream) {
const localId = getLocalParticipant().id;
this.onVideoTypeChanged(localId, stream.videoType);
localVideoThumbnail.changeVideo(stream);
this._updateLargeVideoIfDisplayed(localId);
},
/**
* Shows/hides local video.
* @param {boolean} true to make the local video visible, false - otherwise
*/
setLocalVideoVisible(visible) {
localVideoThumbnail.setVisible(visible);
},
onRemoteStreamAdded(stream) {
const id = stream.getParticipantId();
const remoteVideo = remoteVideos[id];
logger.debug(`Received a new ${stream.getType()} stream for ${id}`);
if (!remoteVideo) {
logger.debug('No remote video element to add stream');
return;
}
remoteVideo.addRemoteStreamElement(stream);
this.onVideoMute(id);
remoteVideo.updateView();
},
onRemoteStreamRemoved(stream) {
const id = stream.getParticipantId();
const remoteVideo = remoteVideos[id];
// Remote stream may be removed after participant left the conference.
if (remoteVideo) {
remoteVideo.removeRemoteStreamElement(stream);
remoteVideo.updateView();
}
this.updateVideoMutedForNoTracks(id);
},
/** /**
* FIXME get rid of this method once muted indicator are reactified (by * FIXME get rid of this method once muted indicator are reactified (by
* making sure that user with no tracks is displayed as muted ) * making sure that user with no tracks is displayed as muted )
@ -180,7 +75,7 @@ const VideoLayout = {
const participant = APP.conference.getParticipantById(participantId); const participant = APP.conference.getParticipantById(participantId);
if (participant && !participant.getTracksByMediaType('video').length) { if (participant && !participant.getTracksByMediaType('video').length) {
APP.UI.setVideoMuted(participantId); VideoLayout._updateLargeVideoIfDisplayed(participantId, true);
} }
}, },
@ -202,110 +97,12 @@ const VideoLayout = {
return videoTrack?.videoType; return videoTrack?.videoType;
}, },
isPinned(id) {
return id === this.getPinnedId();
},
getPinnedId() { getPinnedId() {
const { id } = getPinnedParticipant(APP.store.getState()) || {}; const { id } = getPinnedParticipant(APP.store.getState()) || {};
return id || null; return id || null;
}, },
/**
* Triggers a thumbnail to pin or unpin itself.
*
* @param {number} videoNumber - The index of the video to toggle pin on.
* @private
*/
togglePin(videoNumber) {
const videos = getAllThumbnails();
const videoView = videos[videoNumber];
videoView && videoView.togglePin();
},
/**
* Callback invoked to update display when the pin participant has changed.
*
* @paramn {string|null} pinnedParticipantID - The participant ID of the
* participant that is pinned or null if no one is pinned.
* @returns {void}
*/
onPinChange(pinnedParticipantID) {
getAllThumbnails().forEach(thumbnail =>
thumbnail.focus(pinnedParticipantID === thumbnail.getId()));
},
/**
* Creates a participant container for the given id.
*
* @param {Object} participant - The redux representation of a remote
* participant.
* @returns {void}
*/
addRemoteParticipantContainer(participant) {
if (!participant || participant.local) {
return;
} else if (participant.isFakeParticipant) {
const sharedVideoThumb = new SharedVideoThumb(participant);
this.addRemoteVideoContainer(participant.id, sharedVideoThumb);
return;
}
const id = participant.id;
const jitsiParticipant = APP.conference.getParticipantById(id);
const remoteVideo = new RemoteVideo(jitsiParticipant);
this.addRemoteVideoContainer(id, remoteVideo);
this.updateVideoMutedForNoTracks(id);
},
/**
* Adds remote video container for the given id and <tt>SmallVideo</tt>.
*
* @param {string} the id of the video to add
* @param {SmallVideo} smallVideo the small video instance to add as a
* remote video
*/
addRemoteVideoContainer(id, remoteVideo) {
remoteVideos[id] = remoteVideo;
// Initialize the view
remoteVideo.updateView();
},
/**
* On video muted event.
*/
onVideoMute(id) {
if (APP.conference.isLocalId(id)) {
localVideoThumbnail && localVideoThumbnail.updateView();
} else {
const remoteVideo = remoteVideos[id];
if (remoteVideo) {
remoteVideo.updateView();
}
}
// large video will show avatar instead of muted stream
this._updateLargeVideoIfDisplayed(id, true);
},
/**
* On dominant speaker changed event.
*
* @param {string} id - The participant ID of the new dominant speaker.
* @returns {void}
*/
onDominantSpeakerChanged(id) {
getAllThumbnails().forEach(thumbnail =>
thumbnail.showDominantSpeakerIndicator(id === thumbnail.getId()));
},
/** /**
* Shows/hides warning about a user's connectivity issues. * Shows/hides warning about a user's connectivity issues.
* *
@ -321,12 +118,6 @@ const VideoLayout = {
// We have to trigger full large video update to transition from // We have to trigger full large video update to transition from
// avatar to video on connectivity restored. // avatar to video on connectivity restored.
this._updateLargeVideoIfDisplayed(id, true); this._updateLargeVideoIfDisplayed(id, true);
const remoteVideo = remoteVideos[id];
if (remoteVideo) {
remoteVideo.updateView();
}
}, },
/** /**
@ -339,58 +130,14 @@ const VideoLayout = {
*/ */
onLastNEndpointsChanged(endpointsLeavingLastN, endpointsEnteringLastN) { onLastNEndpointsChanged(endpointsLeavingLastN, endpointsEnteringLastN) {
if (endpointsLeavingLastN) { if (endpointsLeavingLastN) {
endpointsLeavingLastN.forEach(this._updateRemoteVideo, this); endpointsLeavingLastN.forEach(this._updateLargeVideoIfDisplayed, this);
} }
if (endpointsEnteringLastN) { if (endpointsEnteringLastN) {
endpointsEnteringLastN.forEach(this._updateRemoteVideo, this); endpointsEnteringLastN.forEach(this._updateLargeVideoIfDisplayed, this);
} }
}, },
/**
* Updates remote video by id if it exists.
* @param {string} id of the remote video
* @private
*/
_updateRemoteVideo(id) {
const remoteVideo = remoteVideos[id];
if (remoteVideo) {
remoteVideo.updateView();
this._updateLargeVideoIfDisplayed(id);
}
},
removeParticipantContainer(id) {
// Unlock large video
if (this.getPinnedId() === id) {
logger.info('Focused video owner has left the conference');
APP.store.dispatch(pinParticipant(null));
}
const remoteVideo = remoteVideos[id];
if (remoteVideo) {
// Remove remote video
logger.info(`Removing remote video: ${id}`);
delete remoteVideos[id];
remoteVideo.remove();
} else {
logger.warn(`No remote video for ${id}`);
}
},
onVideoTypeChanged(id, newVideoType) {
const remoteVideo = remoteVideos[id];
if (!remoteVideo) {
return;
}
logger.info('Peer video type changed: ', id, newVideoType);
remoteVideo.updateView();
},
/** /**
* Resizes the video area. * Resizes the video area.
*/ */
@ -401,15 +148,6 @@ const VideoLayout = {
} }
}, },
getSmallVideo(id) {
if (APP.conference.isLocalId(id)) {
return localVideoThumbnail;
}
return remoteVideos[id];
},
changeUserAvatar(id, avatarUrl) { changeUserAvatar(id, avatarUrl) {
if (this.isCurrentlyOnLarge(id)) { if (this.isCurrentlyOnLarge(id)) {
largeVideo.updateAvatar(avatarUrl); largeVideo.updateAvatar(avatarUrl);
@ -432,24 +170,6 @@ const VideoLayout = {
return largeVideo && largeVideo.id === id; return largeVideo && largeVideo.id === id;
}, },
/**
* Triggers an update of remote video and large video displays so they may
* pick up any state changes that have occurred elsewhere.
*
* @returns {void}
*/
updateAllVideos() {
const displayedUserId = this.getLargeVideoID();
if (displayedUserId) {
this.updateLargeVideo(displayedUserId, true);
}
Object.keys(remoteVideos).forEach(video => {
remoteVideos[video].updateView();
});
},
updateLargeVideo(id, forceUpdate) { updateLargeVideo(id, forceUpdate) {
if (!largeVideo) { if (!largeVideo) {
return; return;
@ -510,13 +230,6 @@ const VideoLayout = {
return Promise.resolve(); return Promise.resolve();
} }
const currentId = largeVideo.id;
let oldSmallVideo;
if (currentId) {
oldSmallVideo = this.getSmallVideo(currentId);
}
let containerTypeToShow = type; let containerTypeToShow = type;
// if we are hiding a container and there is focusedVideo // if we are hiding a container and there is focusedVideo
@ -533,12 +246,7 @@ const VideoLayout = {
} }
} }
return largeVideo.showContainer(containerTypeToShow) return largeVideo.showContainer(containerTypeToShow);
.then(() => {
if (oldSmallVideo) {
oldSmallVideo && oldSmallVideo.updateView();
}
});
}, },
isLargeContainerTypeVisible(type) { isLargeContainerTypeVisible(type) {
@ -561,14 +269,6 @@ const VideoLayout = {
return largeVideo; return largeVideo;
}, },
/**
* Sets the flipX state of the local video.
* @param {boolean} true for flipped otherwise false;
*/
setLocalFlipX(val) {
this.localFlipX = val;
},
/** /**
* Returns the wrapper jquery selector for the largeVideo * Returns the wrapper jquery selector for the largeVideo
* @returns {JQuerySelector} the wrapper jquery selector for the largeVideo * @returns {JQuerySelector} the wrapper jquery selector for the largeVideo
@ -577,15 +277,6 @@ const VideoLayout = {
return this.getCurrentlyOnLargeContainer().$wrapper; return this.getCurrentlyOnLargeContainer().$wrapper;
}, },
/**
* Returns the number of remove video ids.
*
* @returns {number} The number of remote videos.
*/
getRemoteVideosCount() {
return Object.keys(remoteVideos).length;
},
/** /**
* Helper method to invoke when the video layout has changed and elements * Helper method to invoke when the video layout has changed and elements
* have to be re-arranged and resized. * have to be re-arranged and resized.
@ -593,12 +284,7 @@ const VideoLayout = {
* @returns {void} * @returns {void}
*/ */
refreshLayout() { refreshLayout() {
localVideoThumbnail && localVideoThumbnail.updateDOMLocation();
VideoLayout.resizeVideoArea(); VideoLayout.resizeVideoArea();
// Rerender the thumbnails since they are dependent on the layout because of the tooltip positioning.
localVideoThumbnail && localVideoThumbnail.rerender();
Object.values(remoteVideos).forEach(remoteVideoThumbnail => remoteVideoThumbnail.rerender());
}, },
/** /**
@ -615,26 +301,6 @@ const VideoLayout = {
largeVideo = null; largeVideo = null;
}, },
/**
* Cleans up filmstrip state. While a separate {@code Filmstrip} exists, its
* implementation is mainly for querying and manipulating the DOM while
* state mostly remains in {@code VideoLayout}.
*
* @private
* @returns {void}
*/
_resetFilmstrip() {
Object.keys(remoteVideos).forEach(remoteVideoId => {
this.removeParticipantContainer(remoteVideoId);
delete remoteVideos[remoteVideoId];
});
if (localVideoThumbnail) {
localVideoThumbnail.remove();
localVideoThumbnail = null;
}
},
/** /**
* Triggers an update of large video if the passed in participant is * Triggers an update of large video if the passed in participant is
* currently displayed on large video. * currently displayed on large video.

View File

@ -9,6 +9,7 @@ import {
sendAnalytics sendAnalytics
} from '../../react/features/analytics'; } from '../../react/features/analytics';
import { toggleDialog } from '../../react/features/base/dialog'; import { toggleDialog } from '../../react/features/base/dialog';
import { clickOnVideo } from '../../react/features/filmstrip/actions';
import { KeyboardShortcutsDialog } import { KeyboardShortcutsDialog }
from '../../react/features/keyboard-shortcuts'; from '../../react/features/keyboard-shortcuts';
import { SpeakerStats } from '../../react/features/speaker-stats'; import { SpeakerStats } from '../../react/features/speaker-stats';
@ -54,7 +55,7 @@ const KeyboardShortcut = {
if (_shortcuts.has(key)) { if (_shortcuts.has(key)) {
_shortcuts.get(key).function(e); _shortcuts.get(key).function(e);
} else if (!isNaN(num) && num >= 0 && num <= 9) { } else if (!isNaN(num) && num >= 0 && num <= 9) {
APP.UI.clickOnVideo(num); APP.store.dispatch(clickOnVideo(num));
} }
} }

5
package-lock.json generated
View File

@ -10090,11 +10090,6 @@
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
}, },
"jquery-contextmenu": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/jquery-contextmenu/-/jquery-contextmenu-2.4.5.tgz",
"integrity": "sha1-5lrOBg2M2tTQ5d94FdDXA55RdFA="
},
"jquery-i18next": { "jquery-i18next": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/jquery-i18next/-/jquery-i18next-1.2.1.tgz", "resolved": "https://registry.npmjs.org/jquery-i18next/-/jquery-i18next-1.2.1.tgz",

View File

@ -51,7 +51,6 @@
"jQuery-Impromptu": "github:trentrichardson/jQuery-Impromptu#v6.0.0", "jQuery-Impromptu": "github:trentrichardson/jQuery-Impromptu#v6.0.0",
"jitsi-meet-logger": "github:jitsi/jitsi-meet-logger#v1.0.0", "jitsi-meet-logger": "github:jitsi/jitsi-meet-logger#v1.0.0",
"jquery": "3.5.1", "jquery": "3.5.1",
"jquery-contextmenu": "2.4.5",
"jquery-i18next": "1.2.1", "jquery-i18next": "1.2.1",
"js-md5": "0.6.1", "js-md5": "0.6.1",
"jwt-decode": "2.2.0", "jwt-decode": "2.2.0",

View File

@ -38,7 +38,103 @@ type Props = {
* Used to determine the value of the autoplay attribute of the underlying * Used to determine the value of the autoplay attribute of the underlying
* video element. * video element.
*/ */
playsinline: boolean playsinline: boolean,
/**
* A map of the event handlers for the video HTML element.
*/
eventHandlers?: {|
/**
* onAbort event handler.
*/
onAbort?: ?Function,
/**
* onCanPlay event handler.
*/
onCanPlay?: ?Function,
/**
* onCanPlayThrough event handler.
*/
onCanPlayThrough?: ?Function,
/**
* onEmptied event handler.
*/
onEmptied?: ?Function,
/**
* onEnded event handler.
*/
onEnded?: ?Function,
/**
* onError event handler.
*/
onError?: ?Function,
/**
* onLoadedData event handler.
*/
onLoadedData?: ?Function,
/**
* onLoadedMetadata event handler.
*/
onLoadedMetadata?: ?Function,
/**
* onLoadStart event handler.
*/
onLoadStart?: ?Function,
/**
* onPause event handler.
*/
onPause?: ?Function,
/**
* onPlay event handler.
*/
onPlay?: ?Function,
/**
* onPlaying event handler.
*/
onPlaying?: ?Function,
/**
* onRateChange event handler.
*/
onRateChange?: ?Function,
/**
* onStalled event handler.
*/
onStalled?: ?Function,
/**
* onSuspend event handler.
*/
onSuspend?: ?Function,
/**
* onWaiting event handler.
*/
onWaiting?: ?Function
|},
/**
* A styles that will be applied on the video element.
*/
style?: Object,
/**
* The value of the muted attribute for the underlying video element.
*/
muted?: boolean
}; };
/** /**
@ -139,6 +235,10 @@ class Video extends Component<Props> {
this._attachTrack(nextProps.videoTrack); this._attachTrack(nextProps.videoTrack);
} }
if (this.props.style !== nextProps.style || this.props.className !== nextProps.className) {
return true;
}
return false; return false;
} }
@ -149,13 +249,26 @@ class Video extends Component<Props> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const {
autoPlay,
className,
id,
muted,
playsinline,
style,
eventHandlers
} = this.props;
return ( return (
<video <video
autoPlay = { this.props.autoPlay } autoPlay = { autoPlay }
className = { this.props.className } className = { className }
id = { this.props.id } id = { id }
playsInline = { this.props.playsinline } muted = { muted }
ref = { this._setVideoElement } /> playsInline = { playsinline }
ref = { this._setVideoElement }
style = { style }
{ ...eventHandlers } />
); );
} }

View File

@ -29,7 +29,103 @@ type Props = AbstractVideoTrackProps & {
* Used to determine the value of the autoplay attribute of the underlying * Used to determine the value of the autoplay attribute of the underlying
* video element. * video element.
*/ */
_noAutoPlayVideo: boolean _noAutoPlayVideo: boolean,
/**
* A map of the event handlers for the video HTML element.
*/
eventHandlers?: {|
/**
* onAbort event handler.
*/
onAbort?: ?Function,
/**
* onCanPlay event handler.
*/
onCanPlay?: ?Function,
/**
* onCanPlayThrough event handler.
*/
onCanPlayThrough?: ?Function,
/**
* onEmptied event handler.
*/
onEmptied?: ?Function,
/**
* onEnded event handler.
*/
onEnded?: ?Function,
/**
* onError event handler.
*/
onError?: ?Function,
/**
* onLoadedData event handler.
*/
onLoadedData?: ?Function,
/**
* onLoadedMetadata event handler.
*/
onLoadedMetadata?: ?Function,
/**
* onLoadStart event handler.
*/
onLoadStart?: ?Function,
/**
* onPause event handler.
*/
onPause?: ?Function,
/**
* onPlay event handler.
*/
onPlay?: ?Function,
/**
* onPlaying event handler.
*/
onPlaying?: ?Function,
/**
* onRateChange event handler.
*/
onRateChange?: ?Function,
/**
* onStalled event handler.
*/
onStalled?: ?Function,
/**
* onSuspend event handler.
*/
onSuspend?: ?Function,
/**
* onWaiting event handler.
*/
onWaiting?: ?Function,
|},
/**
* A styles that will be applied on the video element.
*/
style: Object,
/**
* The value of the muted attribute for the underlying element.
*/
muted?: boolean
}; };
/** /**
@ -57,13 +153,27 @@ class VideoTrack extends AbstractVideoTrack<Props> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
const {
_noAutoPlayVideo,
className,
id,
muted,
videoTrack,
style,
eventHandlers
} = this.props;
return ( return (
<Video <Video
autoPlay = { !this.props._noAutoPlayVideo } autoPlay = { !_noAutoPlayVideo }
className = { this.props.className } className = { className }
id = { this.props.id } eventHandlers = { eventHandlers }
id = { id }
muted = { muted }
onVideoPlaying = { this._onVideoPlaying } onVideoPlaying = { this._onVideoPlaying }
videoTrack = { this.props.videoTrack } /> style = { style }
videoTrack = { videoTrack } />
); );
} }

View File

@ -14,6 +14,7 @@ import { SETTINGS_UPDATED } from './actionTypes';
import { updateSettings } from './actions'; import { updateSettings } from './actions';
import { handleCallIntegrationChange, handleCrashReportingChange } from './functions'; import { handleCallIntegrationChange, handleCrashReportingChange } from './functions';
/** /**
* The middleware of the feature base/settings. Distributes changes to the state * The middleware of the feature base/settings. Distributes changes to the state
* of base/settings to the states of other features computed from the state of * of base/settings to the states of other features computed from the state of

View File

@ -75,6 +75,16 @@ export const TRACK_NO_DATA_FROM_SOURCE = 'TRACK_NO_DATA_FROM_SOURCE';
*/ */
export const TRACK_REMOVED = 'TRACK_REMOVED'; export const TRACK_REMOVED = 'TRACK_REMOVED';
/**
* The type of redux action dispatched when a track has stopped.
*
* {
* type: TRACK_STOPPED,
* track: Track
* }
*/
export const TRACK_STOPPED = 'TRACK_STOPPED';
/** /**
* The type of redux action dispatched when a track's properties were updated. * The type of redux action dispatched when a track's properties were updated.
* *

View File

@ -25,9 +25,10 @@ import {
TRACK_CREATE_ERROR, TRACK_CREATE_ERROR,
TRACK_NO_DATA_FROM_SOURCE, TRACK_NO_DATA_FROM_SOURCE,
TRACK_REMOVED, TRACK_REMOVED,
TRACK_STOPPED,
TRACK_UPDATED, TRACK_UPDATED,
TRACK_WILL_CREATE, TRACK_UPDATE_LAST_VIDEO_MEDIA_EVENT,
TRACK_UPDATE_LAST_VIDEO_MEDIA_EVENT TRACK_WILL_CREATE
} from './actionTypes'; } from './actionTypes';
import { import {
createLocalTracksF, createLocalTracksF,
@ -400,8 +401,15 @@ export function trackAdded(track) {
noDataFromSourceNotificationInfo = { timeout }; noDataFromSourceNotificationInfo = { timeout };
} }
} }
track.on(JitsiTrackEvents.LOCAL_TRACK_STOPPED,
() => dispatch({
type: TRACK_STOPPED,
track: {
jitsiTrack: track
}
}));
} else { } else {
participantId = track.getParticipantId(); participantId = track.getParticipantId();
isReceivingData = true; isReceivingData = true;

View File

@ -163,7 +163,6 @@ MiddlewareRegistry.register(store => next => action => {
} else { } else {
APP.UI.setVideoMuted(participantID); APP.UI.setVideoMuted(participantID);
} }
APP.UI.onPeerVideoTypeChanged(participantID, jitsiTrack.videoType);
} else if (jitsiTrack.isLocal()) { } else if (jitsiTrack.isLocal()) {
APP.conference.setAudioMuteStatus(muted); APP.conference.setAudioMuteStatus(muted);
} else { } else {

View File

@ -145,6 +145,18 @@ type Props = {
transport: Array<Object> transport: Array<Object>
}; };
/**
* Click handler.
*
* @param {SyntheticEvent} event - The click event.
* @returns {void}
*/
function onClick(event) {
// If the event is propagated to the thumbnail container the participant will be pinned. That's why the propagation
// needs to be stopped.
event.stopPropagation();
}
/** /**
* React {@code Component} for displaying connection statistics. * React {@code Component} for displaying connection statistics.
* *
@ -161,7 +173,9 @@ class ConnectionStatsTable extends Component<Props> {
const { isLocalVideo, enableSaveLogs } = this.props; const { isLocalVideo, enableSaveLogs } = this.props;
return ( return (
<div className = 'connection-info'> <div
className = 'connection-info'
onClick = { onClick }>
{ this._renderStatistics() } { this._renderStatistics() }
<div className = 'connection-actions'> <div className = 'connection-actions'>
{ isLocalVideo && enableSaveLogs ? this._renderSaveLogs() : null} { isLocalVideo && enableSaveLogs ? this._renderSaveLogs() : null}

View File

@ -1,5 +1,6 @@
// @flow // @flow
import { pinParticipant } from '../base/participants';
import { toState } from '../base/redux'; import { toState } from '../base/redux';
import { CHAT_SIZE } from '../chat/constants'; import { CHAT_SIZE } from '../chat/constants';
@ -69,4 +70,20 @@ export function setHorizontalViewDimensions(clientHeight: number = 0) {
}; };
} }
/**
* Emulates a click on the n-th video.
*
* @param {number} n - Number that identifies the video.
* @returns {Function}
*/
export function clickOnVideo(n: number) {
return (dispatch: Function, getState: Function) => {
const participants = getState()['features/base/participants'];
const nThParticipant = participants[n];
const { id, pinned } = nThParticipant;
dispatch(pinParticipant(pinned ? null : id));
};
}
export * from './actions.native'; export * from './actions.native';

View File

@ -20,9 +20,9 @@ import { StyleType } from '../../../base/styles';
import { getTrackByMediaTypeAndParticipant } from '../../../base/tracks'; import { getTrackByMediaTypeAndParticipant } from '../../../base/tracks';
import { ConnectionIndicator } from '../../../connection-indicator'; import { ConnectionIndicator } from '../../../connection-indicator';
import { DisplayNameLabel } from '../../../display-name'; import { DisplayNameLabel } from '../../../display-name';
import { RemoteVideoMenu } from '../../../remote-video-menu';
import ConnectionStatusComponent from '../../../remote-video-menu/components/native/ConnectionStatusComponent';
import { toggleToolboxVisible } from '../../../toolbox/actions.native'; import { toggleToolboxVisible } from '../../../toolbox/actions.native';
import { RemoteVideoMenu } from '../../../video-menu';
import ConnectionStatusComponent from '../../../video-menu/components/native/ConnectionStatusComponent';
import AudioMutedIndicator from './AudioMutedIndicator'; import AudioMutedIndicator from './AudioMutedIndicator';
import DominantSpeakerIndicator from './DominantSpeakerIndicator'; import DominantSpeakerIndicator from './DominantSpeakerIndicator';

View File

@ -11,12 +11,15 @@ import {
import { getToolbarButtons } from '../../../base/config'; import { getToolbarButtons } from '../../../base/config';
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { Icon, IconMenuDown, IconMenuUp } from '../../../base/icons'; import { Icon, IconMenuDown, IconMenuUp } from '../../../base/icons';
import { getLocalParticipant } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { isButtonEnabled } from '../../../toolbox/functions.web'; import { isButtonEnabled } from '../../../toolbox/functions.web';
import { LAYOUTS, getCurrentLayout } from '../../../video-layout'; import { LAYOUTS, getCurrentLayout } from '../../../video-layout';
import { setFilmstripVisible } from '../../actions'; import { setFilmstripVisible } from '../../actions';
import { shouldRemoteVideosBeVisible } from '../../functions'; import { shouldRemoteVideosBeVisible } from '../../functions';
import Thumbnail from './Thumbnail';
declare var APP: Object; declare var APP: Object;
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
@ -60,6 +63,11 @@ type Props = {
*/ */
_isFilmstripButtonEnabled: boolean, _isFilmstripButtonEnabled: boolean,
/**
* The participants in the call.
*/
_participants: Array<Object>,
/** /**
* The number of rows in tile view. * The number of rows in tile view.
*/ */
@ -138,18 +146,15 @@ class Filmstrip extends Component <Props> {
* @returns {ReactElement} * @returns {ReactElement}
*/ */
render() { render() {
// Note: Appending of {@code RemoteVideo} views is handled through
// VideoLayout. The views do not get blown away on render() because
// ReactDOMComponent is only aware of the given JSX and not new appended
// DOM. As such, when updateDOMProperties gets called, only attributes
// will get updated without replacing the DOM. If the known DOM gets
// modified, then the views will get blown away.
const filmstripStyle = { }; const filmstripStyle = { };
const filmstripRemoteVideosContainerStyle = {}; const filmstripRemoteVideosContainerStyle = {};
let remoteVideoContainerClassName = 'remote-videos-container'; let remoteVideoContainerClassName = 'remote-videos-container';
const { _currentLayout, _participants } = this.props;
const remoteParticipants = _participants.filter(p => !p.local);
const localParticipant = getLocalParticipant(_participants);
const tileViewActive = _currentLayout === LAYOUTS.TILE_VIEW;
switch (this.props._currentLayout) { switch (_currentLayout) {
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: case LAYOUTS.VERTICAL_FILMSTRIP_VIEW:
// Adding 18px for the 2px margins, 2px borders on the left and right and 5px padding on the left and right. // Adding 18px for the 2px margins, 2px borders on the left and right and 5px padding on the left and right.
// Also adding 7px for the scrollbar. // Also adding 7px for the scrollbar.
@ -191,7 +196,13 @@ class Filmstrip extends Component <Props> {
<div <div
className = 'filmstrip__videos' className = 'filmstrip__videos'
id = 'filmstripLocalVideo'> id = 'filmstripLocalVideo'>
<div id = 'filmstripLocalVideoThumbnail' /> <div id = 'filmstripLocalVideoThumbnail'>
{
!tileViewActive && <Thumbnail
key = 'local'
participantID = { localParticipant.id } />
}
</div>
</div> </div>
<div <div
className = { remoteVideosWrapperClassName } className = { remoteVideosWrapperClassName }
@ -205,7 +216,21 @@ class Filmstrip extends Component <Props> {
className = { remoteVideoContainerClassName } className = { remoteVideoContainerClassName }
id = 'filmstripRemoteVideosContainer' id = 'filmstripRemoteVideosContainer'
style = { filmstripRemoteVideosContainerStyle }> style = { filmstripRemoteVideosContainerStyle }>
<div id = 'localVideoTileViewContainer' /> {
remoteParticipants.map(
p => (
<Thumbnail
key = { `remote_${p.id}` }
participantID = { p.id } />
))
}
<div id = 'localVideoTileViewContainer'>
{
tileViewActive && <Thumbnail
key = 'local'
participantID = { localParticipant.id } />
}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -314,6 +339,7 @@ function _mapStateToProps(state) {
_hideScrollbar: Boolean(iAmSipGateway), _hideScrollbar: Boolean(iAmSipGateway),
_hideToolbar: Boolean(iAmSipGateway), _hideToolbar: Boolean(iAmSipGateway),
_isFilmstripButtonEnabled: isButtonEnabled('filmstrip', state), _isFilmstripButtonEnabled: isButtonEnabled('filmstrip', state),
_participants: state['features/base/participants'],
_rows: gridDimensions.rows, _rows: gridDimensions.rows,
_videosClassName: videosClassName, _videosClassName: videosClassName,
_visible: visible _visible: visible

View File

@ -1,8 +1,8 @@
// @flow // @flow
import { AtlasKitThemeProvider } from '@atlaskit/theme';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { createScreenSharingIssueEvent, sendAnalytics } from '../../../analytics';
import { AudioLevelIndicator } from '../../../audio-level-indicator'; import { AudioLevelIndicator } from '../../../audio-level-indicator';
import { Avatar } from '../../../base/avatar'; import { Avatar } from '../../../base/avatar';
import JitsiMeetJS from '../../../base/lib-jitsi-meet/_'; import JitsiMeetJS from '../../../base/lib-jitsi-meet/_';
@ -11,42 +11,72 @@ import AudioTrack from '../../../base/media/components/web/AudioTrack';
import { import {
getLocalParticipant, getLocalParticipant,
getParticipantById, getParticipantById,
getParticipantCount getParticipantCount,
pinParticipant
} from '../../../base/participants'; } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { getLocalAudioTrack, getLocalVideoTrack, getTrackByMediaTypeAndParticipant } from '../../../base/tracks'; import { isTestModeEnabled } from '../../../base/testing';
import {
getLocalAudioTrack,
getLocalVideoTrack,
getTrackByMediaTypeAndParticipant,
updateLastTrackVideoMediaEvent
} from '../../../base/tracks';
import { ConnectionIndicator } from '../../../connection-indicator'; import { ConnectionIndicator } from '../../../connection-indicator';
import { DisplayName } from '../../../display-name'; import { DisplayName } from '../../../display-name';
import { StatusIndicators, RaisedHandIndicator, DominantSpeakerIndicator } from '../../../filmstrip'; import { StatusIndicators, RaisedHandIndicator, DominantSpeakerIndicator } from '../../../filmstrip';
import { PresenceLabel } from '../../../presence-status'; import { PresenceLabel } from '../../../presence-status';
import { RemoteVideoMenuTriggerButton } from '../../../remote-video-menu';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout'; import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import { LocalVideoMenuTriggerButton, RemoteVideoMenuTriggerButton } from '../../../video-menu';
import {
DISPLAY_MODE_TO_CLASS_NAME,
DISPLAY_MODE_TO_STRING,
DISPLAY_VIDEO,
DISPLAY_VIDEO_WITH_NAME,
VIDEO_TEST_EVENTS
} from '../../constants';
import { isVideoPlayable, computeDisplayMode } from '../../functions';
import logger from '../../logger';
const JitsiTrackEvents = JitsiMeetJS.events.track; const JitsiTrackEvents = JitsiMeetJS.events.track;
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
/** /**
* The type of the React {@code Component} state of {@link Thumbnail}. * The type of the React {@code Component} state of {@link Thumbnail}.
*/ */
type State = { export type State = {|
/** /**
* The current audio level value for the Thumbnail. * The current audio level value for the Thumbnail.
*/ */
audioLevel: number, audioLevel: number,
/**
* Indicates that the canplay event has been received.
*/
canPlayEventReceived: boolean,
/**
* The current display mode of the thumbnail.
*/
displayMode: number,
/**
* Indicates whether the thumbnail is hovered or not.
*/
isHovered: boolean,
/** /**
* The current volume setting for the Thumbnail. * The current volume setting for the Thumbnail.
*/ */
volume: ?number volume: ?number
}; |};
/** /**
* The type of the React {@code Component} props of {@link Thumbnail}. * The type of the React {@code Component} props of {@link Thumbnail}.
*/ */
type Props = { export type Props = {|
/** /**
* The audio track related to the participant. * The audio track related to the participant.
@ -73,11 +103,21 @@ type Props = {
*/ */
_defaultLocalDisplayName: string, _defaultLocalDisplayName: string,
/**
* Indicates whether the local video flip feature is disabled or not.
*/
_disableLocalVideoFlip: boolean,
/** /**
* Indicates whether the profile functionality is disabled. * Indicates whether the profile functionality is disabled.
*/ */
_disableProfile: boolean, _disableProfile: boolean,
/**
* The display mode of the thumbnail.
*/
_displayMode: number,
/** /**
* The height of the Thumbnail. * The height of the Thumbnail.
*/ */
@ -88,16 +128,51 @@ type Props = {
*/ */
_heightToWidthPercent: number, _heightToWidthPercent: number,
/**
* Indicates whether the thumbnail should be hidden or not.
*/
_isHidden: boolean,
/**
* Indicates whether audio only mode is enabled.
*/
_isAudioOnly: boolean,
/**
* Indicates whether the participant associated with the thumbnail is displayed on the large video.
*/
_isCurrentlyOnLargeVideo: boolean,
/**
* Indicates whether the participant is screen sharing.
*/
_isScreenSharing: boolean,
/**
* Indicates whether the video associated with the thumbnail is playable.
*/
_isVideoPlayable: boolean,
/** /**
* Disable/enable the dominant speaker indicator. * Disable/enable the dominant speaker indicator.
*/ */
_isDominantSpeakerDisabled: boolean, _isDominantSpeakerDisabled: boolean,
/**
* Indicates whether testing mode is enabled.
*/
_isTestModeEnabled: boolean,
/** /**
* The size of the icon of indicators. * The size of the icon of indicators.
*/ */
_indicatorIconSize: number, _indicatorIconSize: number,
/**
* The current local video flip setting.
*/
_localFlipX: boolean,
/** /**
* An object with information about the participant related to the thumbnaul. * An object with information about the participant related to the thumbnaul.
*/ */
@ -128,16 +203,23 @@ type Props = {
*/ */
dispatch: Function, dispatch: Function,
/**
* Indicates whether the thumbnail is hovered or not.
*/
isHovered: ?boolean,
/** /**
* The ID of the participant related to the thumbnail. * The ID of the participant related to the thumbnail.
*/ */
participantID: ?string participantID: ?string
}; |};
/**
* Click handler for the display name container.
*
* @param {SyntheticEvent} event - The click event.
* @returns {void}
*/
function onClick(event) {
// If the event is propagated to the thumbnail container the participant will be pinned. That's why the propagation
// needs to be stopped.
event.stopPropagation();
}
/** /**
* Implements a thumbnail. * Implements a thumbnail.
@ -145,7 +227,6 @@ type Props = {
* @extends Component * @extends Component
*/ */
class Thumbnail extends Component<Props, State> { class Thumbnail extends Component<Props, State> {
/** /**
* Initializes a new Thumbnail instance. * Initializes a new Thumbnail instance.
* *
@ -155,14 +236,27 @@ class Thumbnail extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { const state = {
audioLevel: 0, audioLevel: 0,
volume: undefined canPlayEventReceived: false,
isHovered: false,
volume: undefined,
displayMode: DISPLAY_VIDEO
};
this.state = {
...state,
displayMode: computeDisplayMode(Thumbnail.getDisplayModeInput(props, state))
}; };
this._updateAudioLevel = this._updateAudioLevel.bind(this); this._updateAudioLevel = this._updateAudioLevel.bind(this);
this._onCanPlay = this._onCanPlay.bind(this);
this._onClick = this._onClick.bind(this);
this._onVolumeChange = this._onVolumeChange.bind(this); this._onVolumeChange = this._onVolumeChange.bind(this);
this._onInitialVolumeSet = this._onInitialVolumeSet.bind(this); this._onInitialVolumeSet = this._onInitialVolumeSet.bind(this);
this._onMouseEnter = this._onMouseEnter.bind(this);
this._onMouseLeave = this._onMouseLeave.bind(this);
this._onTestingEvent = this._onTestingEvent.bind(this);
} }
/** /**
@ -173,6 +267,7 @@ class Thumbnail extends Component<Props, State> {
*/ */
componentDidMount() { componentDidMount() {
this._listenForAudioUpdates(); this._listenForAudioUpdates();
this._onDisplayModeChanged();
} }
/** /**
@ -182,12 +277,121 @@ class Thumbnail extends Component<Props, State> {
* @inheritdoc * @inheritdoc
* @returns {void} * @returns {void}
*/ */
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props, prevState: State) {
if (prevProps._audioTrack !== this.props._audioTrack) { if (prevProps._audioTrack !== this.props._audioTrack) {
this._stopListeningForAudioUpdates(prevProps._audioTrack); this._stopListeningForAudioUpdates(prevProps._audioTrack);
this._listenForAudioUpdates(); this._listenForAudioUpdates();
this._updateAudioLevel(0); this._updateAudioLevel(0);
} }
if (prevState.displayMode !== this.state.displayMode) {
this._onDisplayModeChanged();
}
}
/**
* Handles display mode changes.
*
* @returns {void}
*/
_onDisplayModeChanged() {
const input = Thumbnail.getDisplayModeInput(this.props, this.state);
const displayModeString = DISPLAY_MODE_TO_STRING[this.state.displayMode];
const id = this.props._participant?.id;
this._maybeSendScreenSharingIssueEvents(input);
logger.debug(`Displaying ${displayModeString} for ${id}, data: [${JSON.stringify(input)}]`);
}
/**
* Sends screen sharing issue event if an issue is detected.
*
* @param {Object} input - The input used to compute the thumbnail display mode.
* @returns {void}
*/
_maybeSendScreenSharingIssueEvents(input) {
const {
_currentLayout,
_isAudioOnly,
_isScreenSharing
} = this.props;
const { displayMode } = this.state;
const tileViewActive = _currentLayout === LAYOUTS.TILE_VIEW;
if (![ DISPLAY_VIDEO, DISPLAY_VIDEO_WITH_NAME ].includes(displayMode)
&& tileViewActive
&& _isScreenSharing
&& !_isAudioOnly) {
sendAnalytics(createScreenSharingIssueEvent({
source: 'thumbnail',
...input
}));
}
}
/**
* Implements React's {@link Component#getDerivedStateFromProps()}.
*
* @inheritdoc
*/
static getDerivedStateFromProps(props: Props, prevState: State) {
if (!props._videoTrack && prevState.canPlayEventReceived) {
const newState = {
...prevState,
canPlayEventReceived: false
};
return {
...newState,
dispayMode: computeDisplayMode(Thumbnail.getDisplayModeInput(props, newState))
};
}
const newDisplayMode = computeDisplayMode(Thumbnail.getDisplayModeInput(props, prevState));
if (newDisplayMode !== prevState.displayMode) {
return {
...prevState,
displayMode: newDisplayMode
};
}
return null;
}
/**
* Extracts information for props and state needed to compute the display mode.
*
* @param {Props} props - The component's props.
* @param {State} state - The component's state.
* @returns {Object}
*/
static getDisplayModeInput(props: Props, state: State) {
const {
_currentLayout,
_isAudioOnly,
_isCurrentlyOnLargeVideo,
_isScreenSharing,
_isVideoPlayable,
_participant,
_videoTrack
} = props;
const tileViewActive = _currentLayout === LAYOUTS.TILE_VIEW;
const { canPlayEventReceived, isHovered } = state;
return {
isCurrentlyOnLargeVideo: _isCurrentlyOnLargeVideo,
isHovered,
isAudioOnly: _isAudioOnly,
tileViewActive,
isVideoPlayable: _isVideoPlayable,
connectionStatus: _participant?.connectionStatus,
canPlayEventReceived,
videoStream: Boolean(_videoTrack),
isRemoteParticipant: !_participant?.isFakeParticipant && !_participant?.local,
isScreenSharing: _isScreenSharing,
videoStreamMuted: _videoTrack ? _videoTrack.muted : 'no stream'
};
} }
/** /**
@ -253,8 +457,14 @@ class Thumbnail extends Component<Props, State> {
* @returns {Object} - The styles for the thumbnail. * @returns {Object} - The styles for the thumbnail.
*/ */
_getStyles(): Object { _getStyles(): Object {
const { _height, _heightToWidthPercent, _currentLayout } = this.props; const { _height, _heightToWidthPercent, _currentLayout, _isHidden, _width } = this.props;
let styles; let styles: {
thumbnail: Object,
avatar: Object
} = {
thumbnail: {},
avatar: {}
};
switch (_currentLayout) { switch (_currentLayout) {
case LAYOUTS.TILE_VIEW: case LAYOUTS.TILE_VIEW:
@ -262,7 +472,13 @@ class Thumbnail extends Component<Props, State> {
const avatarSize = _height / 2; const avatarSize = _height / 2;
styles = { styles = {
avatarContainer: { thumbnail: {
height: `${_height}px`,
minHeight: `${_height}px`,
minWidth: `${_width}px`,
width: `${_width}px`
},
avatar: {
height: `${avatarSize}px`, height: `${avatarSize}px`,
width: `${avatarSize}px` width: `${avatarSize}px`
} }
@ -271,7 +487,10 @@ class Thumbnail extends Component<Props, State> {
} }
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: { case LAYOUTS.VERTICAL_FILMSTRIP_VIEW: {
styles = { styles = {
avatarContainer: { thumbnail: {
paddingTop: `${_heightToWidthPercent}%`
},
avatar: {
height: '50%', height: '50%',
width: `${_heightToWidthPercent / 2}%` width: `${_heightToWidthPercent / 2}%`
} }
@ -280,9 +499,49 @@ class Thumbnail extends Component<Props, State> {
} }
} }
if (_isHidden) {
styles.thumbnail.display = 'none';
}
return styles; return styles;
} }
_onClick: () => void;
/**
* On click handler.
*
* @returns {void}
*/
_onClick() {
const { _participant, dispatch } = this.props;
const { id, pinned } = _participant;
dispatch(pinParticipant(pinned ? null : id));
}
_onMouseEnter: () => void;
/**
* Mouse enter handler.
*
* @returns {void}
*/
_onMouseEnter() {
this.setState({ isHovered: true });
}
_onMouseLeave: () => void;
/**
* Mouse leave handler.
*
* @returns {void}
*/
_onMouseLeave() {
this.setState({ isHovered: false });
}
/** /**
* Renders a fake participant (youtube video) thumbnail. * Renders a fake participant (youtube video) thumbnail.
* *
@ -292,9 +551,17 @@ class Thumbnail extends Component<Props, State> {
_renderFakeParticipant() { _renderFakeParticipant() {
const { _participant } = this.props; const { _participant } = this.props;
const { id } = _participant; const { id } = _participant;
const styles = this._getStyles();
const containerClassName = this._getContainerClassName();
return ( return (
<> <span
className = { containerClassName }
id = 'sharedVideoContainer'
onClick = { this._onClick }
onMouseEnter = { this._onMouseEnter }
onMouseLeave = { this._onMouseLeave }
style = { styles.thumbnail }>
<img <img
className = 'sharedVideoAvatar' className = 'sharedVideoAvatar'
src = { `https://img.youtube.com/vi/${id}/0.jpg` } /> src = { `https://img.youtube.com/vi/${id}/0.jpg` } />
@ -303,7 +570,7 @@ class Thumbnail extends Component<Props, State> {
elementID = 'sharedVideoContainer_name' elementID = 'sharedVideoContainer_name'
participantID = { id } /> participantID = { id } />
</div> </div>
</> </span>
); );
} }
@ -320,9 +587,9 @@ class Thumbnail extends Component<Props, State> {
_isDominantSpeakerDisabled, _isDominantSpeakerDisabled,
_indicatorIconSize: iconSize, _indicatorIconSize: iconSize,
_participant, _participant,
_participantCount, _participantCount
isHovered
} = this.props; } = this.props;
const { isHovered } = this.state;
const showConnectionIndicator = isHovered || !_connectionIndicatorAutoHideEnabled; const showConnectionIndicator = isHovered || !_connectionIndicatorAutoHideEnabled;
const { id, local = false, dominantSpeaker = false } = _participant; const { id, local = false, dominantSpeaker = false } = _participant;
const showDominantSpeaker = !_isDominantSpeakerDisabled && dominantSpeaker; const showDominantSpeaker = !_isDominantSpeakerDisabled && dominantSpeaker;
@ -344,43 +611,41 @@ class Thumbnail extends Component<Props, State> {
return ( return (
<div> <div>
<AtlasKitThemeProvider mode = 'dark'> { !_connectionIndicatorDisabled
{ !_connectionIndicatorDisabled && <ConnectionIndicator
&& <ConnectionIndicator alwaysVisible = { showConnectionIndicator }
alwaysVisible = { showConnectionIndicator } enableStatsDisplay = { true }
enableStatsDisplay = { true }
iconSize = { iconSize }
isLocalVideo = { local }
participantId = { id }
statsPopoverPosition = { statsPopoverPosition } />
}
<RaisedHandIndicator
iconSize = { iconSize } iconSize = { iconSize }
isLocalVideo = { local }
participantId = { id } participantId = { id }
statsPopoverPosition = { statsPopoverPosition } />
}
<RaisedHandIndicator
iconSize = { iconSize }
participantId = { id }
tooltipPosition = { tooltipPosition } />
{ showDominantSpeaker && _participantCount > 2
&& <DominantSpeakerIndicator
iconSize = { iconSize }
tooltipPosition = { tooltipPosition } /> tooltipPosition = { tooltipPosition } />
{ showDominantSpeaker && _participantCount > 2 }
&& <DominantSpeakerIndicator
iconSize = { iconSize }
tooltipPosition = { tooltipPosition } />
}
</AtlasKitThemeProvider>
</div>); </div>);
} }
/** /**
* Renders the avatar. * Renders the avatar.
* *
* @param {Object} styles - The styles that will be applied to the avatar.
* @returns {ReactElement} * @returns {ReactElement}
*/ */
_renderAvatar() { _renderAvatar(styles) {
const { _participant } = this.props; const { _participant } = this.props;
const { id } = _participant; const { id } = _participant;
const styles = this._getStyles();
return ( return (
<div <div
className = 'avatar-container' className = 'avatar-container'
style = { styles.avatarContainer }> style = { styles }>
<Avatar <Avatar
className = 'userAvatar' className = 'userAvatar'
participantId = { id } /> participantId = { id } />
@ -388,6 +653,38 @@ class Thumbnail extends Component<Props, State> {
); );
} }
/**
* Returns the container class name.
*
* @returns {string} - The class name that will be used for the container.
*/
_getContainerClassName() {
let className = 'videocontainer';
const { displayMode } = this.state;
const { _isAudioOnly, _isDominantSpeakerDisabled, _isHidden, _participant } = this.props;
const isRemoteParticipant = !_participant?.local && !_participant?.isFakeParticipant;
className += ` ${DISPLAY_MODE_TO_CLASS_NAME[displayMode]}`;
if (_participant?.pinned) {
className += ' videoContainerFocused';
}
if (!_isDominantSpeakerDisabled && _participant?.dominantSpeaker) {
className += ' active-speaker';
}
if (_isHidden) {
className += ' hidden';
}
if (isRemoteParticipant && _isAudioOnly) {
className += ' audio-only';
}
return className;
}
/** /**
* Renders the local participant's thumbnail. * Renders the local participant's thumbnail.
* *
@ -396,19 +693,33 @@ class Thumbnail extends Component<Props, State> {
_renderLocalParticipant() { _renderLocalParticipant() {
const { const {
_defaultLocalDisplayName, _defaultLocalDisplayName,
_disableLocalVideoFlip,
_isScreenSharing,
_localFlipX,
_disableProfile, _disableProfile,
_participant, _participant,
_videoTrack _videoTrack
} = this.props; } = this.props;
const { id } = _participant || {}; const { id } = _participant || {};
const { audioLevel } = this.state; const { audioLevel } = this.state;
const styles = this._getStyles();
const containerClassName = this._getContainerClassName();
const videoTrackClassName
= !_disableLocalVideoFlip && _videoTrack && !_isScreenSharing && _localFlipX ? 'flipVideoX' : '';
return ( return (
<> <span
className = { containerClassName }
id = 'localVideoContainer'
onClick = { this._onClick }
onMouseEnter = { this._onMouseEnter }
onMouseLeave = { this._onMouseLeave }
style = { styles.thumbnail }>
<div className = 'videocontainer__background' /> <div className = 'videocontainer__background' />
<span id = 'localVideoWrapper'> <span id = 'localVideoWrapper'>
<VideoTrack <VideoTrack
className = { videoTrackClassName }
id = 'localVideo_container' id = 'localVideo_container'
videoTrack = { _videoTrack } /> videoTrack = { _videoTrack } />
</span> </span>
@ -419,21 +730,64 @@ class Thumbnail extends Component<Props, State> {
{ this._renderTopIndicators() } { this._renderTopIndicators() }
</div> </div>
<div className = 'videocontainer__hoverOverlay' /> <div className = 'videocontainer__hoverOverlay' />
<div className = 'displayNameContainer'> <div
className = 'displayNameContainer'
onClick = { onClick }>
<DisplayName <DisplayName
allowEditing = { !_disableProfile } allowEditing = { !_disableProfile }
displayNameSuffix = { _defaultLocalDisplayName } displayNameSuffix = { _defaultLocalDisplayName }
elementID = 'localDisplayName' elementID = 'localDisplayName'
participantID = { id } /> participantID = { id } />
</div> </div>
{ this._renderAvatar() } { this._renderAvatar(styles.avatar) }
<span className = 'localvideomenu'>
<LocalVideoMenuTriggerButton />
</span>
<span className = 'audioindicator-container'> <span className = 'audioindicator-container'>
<AudioLevelIndicator audioLevel = { audioLevel } /> <AudioLevelIndicator audioLevel = { audioLevel } />
</span> </span>
</> </span>
); );
} }
_onCanPlay: Object => void;
/**
* Canplay event listener.
*
* @param {SyntheticEvent} event - The event.
* @returns {void}
*/
_onCanPlay(event) {
this.setState({ canPlayEventReceived: true });
const {
_isTestModeEnabled,
_videoTrack
} = this.props;
if (_videoTrack && _isTestModeEnabled) {
this._onTestingEvent(event);
}
}
_onTestingEvent: Object => void;
/**
* Event handler for testing events.
*
* @param {SyntheticEvent} event - The event.
* @returns {void}
*/
_onTestingEvent(event) {
const {
_videoTrack,
dispatch
} = this.props;
const jitsiVideoTrack = _videoTrack?.jitsiTrack;
dispatch(updateLastTrackVideoMediaEvent(jitsiVideoTrack, event.type));
}
/** /**
* Renders a remote participant's 'thumbnail. * Renders a remote participant's 'thumbnail.
@ -443,29 +797,59 @@ class Thumbnail extends Component<Props, State> {
_renderRemoteParticipant() { _renderRemoteParticipant() {
const { const {
_audioTrack, _audioTrack,
_isTestModeEnabled,
_participant, _participant,
_startSilent _startSilent,
_videoTrack
} = this.props; } = this.props;
const { id } = _participant; const { id } = _participant;
const { audioLevel, volume } = this.state; const { audioLevel, canPlayEventReceived, volume } = this.state;
const styles = this._getStyles();
const containerClassName = this._getContainerClassName();
// hide volume when in silent mode // hide volume when in silent mode
const onVolumeChange = _startSilent ? undefined : this._onVolumeChange; const onVolumeChange = _startSilent ? undefined : this._onVolumeChange;
const jitsiTrack = _audioTrack?.jitsiTrack; const jitsiAudioTrack = _audioTrack?.jitsiTrack;
const audioTrackId = jitsiTrack && jitsiTrack.getId(); const audioTrackId = jitsiAudioTrack && jitsiAudioTrack.getId();
const jitsiVideoTrack = _videoTrack?.jitsiTrack;
const videoTrackId = jitsiVideoTrack && jitsiVideoTrack.getId();
const videoEventListeners = {};
if (_videoTrack && _isTestModeEnabled) {
VIDEO_TEST_EVENTS.forEach(attribute => {
videoEventListeners[attribute] = this._onTestingEvent;
});
}
videoEventListeners.onCanPlay = this._onCanPlay;
const videoElementStyle = canPlayEventReceived ? null : {
display: 'none'
};
return ( return (
<> <span
className = { containerClassName }
id = { `participant_${id}` }
onClick = { this._onClick }
onMouseEnter = { this._onMouseEnter }
onMouseLeave = { this._onMouseLeave }
style = { styles.thumbnail }>
{ {
_audioTrack _videoTrack && <VideoTrack
? <AudioTrack eventHandlers = { videoEventListeners }
audioTrack = { _audioTrack } id = { `remoteVideo_${videoTrackId || ''}` }
id = { `remoteAudio_${audioTrackId || ''}` } muted = { true }
muted = { _startSilent } style = { videoElementStyle }
onInitialVolumeSet = { this._onInitialVolumeSet } videoTrack = { _videoTrack } />
volume = { this.state.volume } /> }
: null {
_audioTrack && <AudioTrack
audioTrack = { _audioTrack }
id = { `remoteAudio_${audioTrackId || ''}` }
muted = { _startSilent }
onInitialVolumeSet = { this._onInitialVolumeSet }
volume = { volume } />
} }
<div className = 'videocontainer__background' /> <div className = 'videocontainer__background' />
<div className = 'videocontainer__toptoolbar'> <div className = 'videocontainer__toptoolbar'>
@ -480,24 +864,22 @@ class Thumbnail extends Component<Props, State> {
elementID = { `participant_${id}_name` } elementID = { `participant_${id}_name` }
participantID = { id } /> participantID = { id } />
</div> </div>
{ this._renderAvatar() } { this._renderAvatar(styles.avatar) }
<div className = 'presence-label-container'> <div className = 'presence-label-container'>
<PresenceLabel <PresenceLabel
className = 'presence-label' className = 'presence-label'
participantID = { id } /> participantID = { id } />
</div> </div>
<span className = 'remotevideomenu'> <span className = 'remotevideomenu'>
<AtlasKitThemeProvider mode = 'dark'> <RemoteVideoMenuTriggerButton
<RemoteVideoMenuTriggerButton initialVolumeValue = { volume }
initialVolumeValue = { volume } onVolumeChange = { onVolumeChange }
onVolumeChange = { onVolumeChange } participantID = { id } />
participantID = { id } />
</AtlasKitThemeProvider>
</span> </span>
<span className = 'audioindicator-container'> <span className = 'audioindicator-container'>
<AudioLevelIndicator audioLevel = { audioLevel } /> <AudioLevelIndicator audioLevel = { audioLevel } />
</span> </span>
</> </span>
); );
} }
@ -527,7 +909,6 @@ class Thumbnail extends Component<Props, State> {
this.setState({ volume: value }); this.setState({ volume: value });
} }
/** /**
* Implements React's {@link Component#render()}. * Implements React's {@link Component#render()}.
* *
@ -568,17 +949,24 @@ function _mapStateToProps(state, ownProps): Object {
// Only the local participant won't have id for the time when the conference is not yet joined. // Only the local participant won't have id for the time when the conference is not yet joined.
const participant = participantID ? getParticipantById(state, participantID) : getLocalParticipant(state); const participant = participantID ? getParticipantById(state, participantID) : getLocalParticipant(state);
const { id } = participant;
const isLocal = participant?.local ?? true; const isLocal = participant?.local ?? true;
const tracks = state['features/base/tracks'];
const _videoTrack = isLocal const _videoTrack = isLocal
? getLocalVideoTrack(state['features/base/tracks']) ? getLocalVideoTrack(tracks) : getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.VIDEO, participantID);
: getTrackByMediaTypeAndParticipant(state['features/base/tracks'], MEDIA_TYPE.VIDEO, participantID);
const _audioTrack = isLocal const _audioTrack = isLocal
? getLocalAudioTrack(state['features/base/tracks']) ? getLocalAudioTrack(tracks) : getTrackByMediaTypeAndParticipant(tracks, MEDIA_TYPE.AUDIO, participantID);
: getTrackByMediaTypeAndParticipant(state['features/base/tracks'], MEDIA_TYPE.AUDIO, participantID);
const _currentLayout = getCurrentLayout(state); const _currentLayout = getCurrentLayout(state);
let size = {}; let size = {};
const { startSilent, disableProfile = false } = state['features/base/config']; const {
startSilent,
disableLocalVideoFlip,
disableProfile,
iAmRecorder,
iAmSipGateway
} = state['features/base/config'];
const { NORMAL = 8 } = interfaceConfig.INDICATOR_FONT_SIZES || {}; const { NORMAL = 8 } = interfaceConfig.INDICATOR_FONT_SIZES || {};
const { localFlipX } = state['features/base/settings'];
switch (_currentLayout) { switch (_currentLayout) {
@ -617,16 +1005,23 @@ function _mapStateToProps(state, ownProps): Object {
} }
} }
return { return {
_audioTrack, _audioTrack,
_connectionIndicatorAutoHideEnabled: interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED, _connectionIndicatorAutoHideEnabled: interfaceConfig.CONNECTION_INDICATOR_AUTO_HIDE_ENABLED,
_connectionIndicatorDisabled: interfaceConfig.CONNECTION_INDICATOR_DISABLED, _connectionIndicatorDisabled: interfaceConfig.CONNECTION_INDICATOR_DISABLED,
_currentLayout, _currentLayout,
_defaultLocalDisplayName: interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME, _defaultLocalDisplayName: interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME,
_disableLocalVideoFlip: Boolean(disableLocalVideoFlip),
_disableProfile: disableProfile, _disableProfile: disableProfile,
_isHidden: isLocal && iAmRecorder && !iAmSipGateway,
_isAudioOnly: Boolean(state['features/base/audio-only'].enabled),
_isCurrentlyOnLargeVideo: state['features/large-video']?.participantId === id,
_isDominantSpeakerDisabled: interfaceConfig.DISABLE_DOMINANT_SPEAKER_INDICATOR, _isDominantSpeakerDisabled: interfaceConfig.DISABLE_DOMINANT_SPEAKER_INDICATOR,
_isScreenSharing: _videoTrack?.videoType === 'desktop',
_isTestModeEnabled: isTestModeEnabled(state),
_isVideoPlayable: isVideoPlayable(state, id),
_indicatorIconSize: NORMAL, _indicatorIconSize: NORMAL,
_localFlipX: Boolean(localFlipX),
_participant: participant, _participant: participant,
_participantCount: getParticipantCount(state), _participantCount: getParticipantCount(state),
_startSilent: Boolean(startSilent), _startSilent: Boolean(startSilent),

View File

@ -47,3 +47,92 @@ export const DEFAULT_MAX_COLUMNS = 5;
* An extended number of columns for tile view. * An extended number of columns for tile view.
*/ */
export const ABSOLUTE_MAX_COLUMNS = 7; export const ABSOLUTE_MAX_COLUMNS = 7;
/**
* An array of attributes of the video element that will be used for adding a listener for every event in the list.
* The latest event will be stored in redux. This is currently used by torture only.
*/
export const VIDEO_TEST_EVENTS = [
'onAbort',
'onCanPlay',
'onCanPlayThrough',
'onEmptied',
'onEnded',
'onError',
'onLoadedData',
'onLoadedMetadata',
'onLoadStart',
'onPause',
'onPlay',
'onPlaying',
'onRateChange',
'onStalled',
'onSuspend',
'onWaiting'
];
/**
* Display mode constant used when video is being displayed on the small video.
* @type {number}
* @constant
*/
export const DISPLAY_VIDEO = 0;
/**
* Display mode constant used when the user's avatar is being displayed on
* the small video.
* @type {number}
* @constant
*/
export const DISPLAY_AVATAR = 1;
/**
* Display mode constant used when neither video nor avatar is being displayed
* on the small video. And we just show the display name.
* @type {number}
* @constant
*/
export const DISPLAY_BLACKNESS_WITH_NAME = 2;
/**
* Display mode constant used when video is displayed and display name
* at the same time.
* @type {number}
* @constant
*/
export const DISPLAY_VIDEO_WITH_NAME = 3;
/**
* Display mode constant used when neither video nor avatar is being displayed
* on the small video. And we just show the display name.
* @type {number}
* @constant
*/
export const DISPLAY_AVATAR_WITH_NAME = 4;
/**
* Maps the display modes to class name that will be applied on the thumbnail container.
* @type {Array<string>}
* @constant
*/
export const DISPLAY_MODE_TO_CLASS_NAME = [
'display-video',
'display-avatar-only',
'display-name-on-black',
'display-name-on-video',
'display-avatar-with-name'
];
/**
* Maps the display modes to string.
* @type {Array<string>}
* @constant
*/
export const DISPLAY_MODE_TO_STRING = [
'video',
'avatar',
'blackness-with-name',
'video-with-name',
'avatar-with-name'
];

View File

@ -16,7 +16,16 @@ import {
isRemoteTrackMuted isRemoteTrackMuted
} from '../base/tracks/functions'; } from '../base/tracks/functions';
import { ASPECT_RATIO_BREAKPOINT, SQUARE_TILE_ASPECT_RATIO, TILE_ASPECT_RATIO } from './constants'; import {
ASPECT_RATIO_BREAKPOINT,
DISPLAY_AVATAR,
DISPLAY_AVATAR_WITH_NAME,
DISPLAY_BLACKNESS_WITH_NAME,
DISPLAY_VIDEO,
DISPLAY_VIDEO_WITH_NAME,
SQUARE_TILE_ASPECT_RATIO,
TILE_ASPECT_RATIO
} from './constants';
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
@ -176,3 +185,36 @@ export function getVerticalFilmstripVisibleAreaWidth() {
return Math.min(filmstripMaxWidth, window.innerWidth); return Math.min(filmstripMaxWidth, window.innerWidth);
} }
/**
* Computes information that determine the display mode.
*
* @param {Object} input - Obejct containing all necessary information for determining the display mode for
* the thumbnail.
* @returns {number} - One of <tt>DISPLAY_VIDEO</tt>, <tt>DISPLAY_AVATAR</tt> or <tt>DISPLAY_BLACKNESS_WITH_NAME</tt>.
*/
export function computeDisplayMode(input: Object) {
const {
isAudioOnly,
isCurrentlyOnLargeVideo,
isScreenSharing,
canPlayEventReceived,
isHovered,
isRemoteParticipant,
tileViewActive
} = input;
const adjustedIsVideoPlayable = input.isVideoPlayable && (!isRemoteParticipant || canPlayEventReceived);
if (!tileViewActive && isScreenSharing && isRemoteParticipant) {
return isHovered ? DISPLAY_AVATAR_WITH_NAME : DISPLAY_AVATAR;
} else if (isCurrentlyOnLargeVideo && !tileViewActive) {
// Display name is always and only displayed when user is on the stage
return adjustedIsVideoPlayable && !isAudioOnly ? DISPLAY_BLACKNESS_WITH_NAME : DISPLAY_AVATAR_WITH_NAME;
} else if (adjustedIsVideoPlayable && !isAudioOnly) {
// check hovering and change state to video with name
return isHovered ? DISPLAY_VIDEO_WITH_NAME : DISPLAY_VIDEO;
}
// check hovering and change state to avatar with name
return isHovered ? DISPLAY_AVATAR_WITH_NAME : DISPLAY_AVATAR;
}

View File

@ -0,0 +1,5 @@
// @flow
import { getLogger } from '../base/logging/functions';
export default getLogger('features/filmstrip');

View File

@ -1,15 +1,14 @@
// @flow // @flow
import Filmstrip from '../../../modules/UI/videolayout/Filmstrip'; import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { MiddlewareRegistry } from '../base/redux'; import { MiddlewareRegistry } from '../base/redux';
import { CLIENT_RESIZED } from '../base/responsive-ui'; import { CLIENT_RESIZED } from '../base/responsive-ui';
import { SETTINGS_UPDATED } from '../base/settings';
import { import {
getCurrentLayout, getCurrentLayout,
LAYOUTS, LAYOUTS
shouldDisplayTileView
} from '../video-layout'; } from '../video-layout';
import { SET_HORIZONTAL_VIEW_DIMENSIONS, SET_TILE_VIEW_DIMENSIONS } from './actionTypes';
import { setHorizontalViewDimensions, setTileViewDimensions } from './actions.web'; import { setHorizontalViewDimensions, setTileViewDimensions } from './actions.web';
import './subscriber.web'; import './subscriber.web';
@ -48,29 +47,13 @@ MiddlewareRegistry.register(store => next => action => {
} }
break; break;
} }
case SET_TILE_VIEW_DIMENSIONS: { case SETTINGS_UPDATED: {
const state = store.getState(); if (typeof action.settings?.localFlipX === 'boolean') {
// TODO: This needs to be removed once the large video is Reactified.
if (shouldDisplayTileView(state)) { VideoLayout.onLocalFlipXChanged();
const { width, height } = state['features/filmstrip'].tileViewDimensions.thumbnailSize;
// Once the thumbnails are reactified this should be moved there too.
Filmstrip.resizeThumbnailsForTileView(width, height, true);
} }
break; break;
} }
case SET_HORIZONTAL_VIEW_DIMENSIONS: {
const state = store.getState();
if (getCurrentLayout(state) === LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW) {
const { horizontalViewDimensions = {} } = state['features/filmstrip'];
// Once the thumbnails are reactified this should be moved there too.
Filmstrip.resizeThumbnailsForHorizontalView(horizontalViewDimensions, true);
}
break;
}
} }
return result; return result;

View File

@ -1,7 +1,5 @@
// @flow // @flow
import Filmstrip from '../../../modules/UI/videolayout/Filmstrip';
import VideoLayout from '../../../modules/UI/videolayout/VideoLayout';
import { StateListenerRegistry, equals } from '../base/redux'; import { StateListenerRegistry, equals } from '../base/redux';
import { setFilmstripVisible } from '../filmstrip/actions'; import { setFilmstripVisible } from '../filmstrip/actions';
import { setOverflowDrawer } from '../toolbox/actions.web'; import { setOverflowDrawer } from '../toolbox/actions.web';
@ -71,32 +69,9 @@ StateListenerRegistry.register(
case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW: case LAYOUTS.HORIZONTAL_FILMSTRIP_VIEW:
store.dispatch(setHorizontalViewDimensions(state['features/base/responsive-ui'].clientHeight)); store.dispatch(setHorizontalViewDimensions(state['features/base/responsive-ui'].clientHeight));
break; break;
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW:
// Once the thumbnails are reactified this should be moved there too.
Filmstrip.resizeThumbnailsForVerticalView();
break;
} }
}); });
/**
* Handles on stage participant updates.
*/
StateListenerRegistry.register(
/* selector */ state => state['features/large-video'].participantId,
/* listener */ (participantId, store, oldParticipantId) => {
const newThumbnail = VideoLayout.getSmallVideo(participantId);
const oldThumbnail = VideoLayout.getSmallVideo(oldParticipantId);
if (newThumbnail) {
newThumbnail.updateView();
}
if (oldThumbnail) {
oldThumbnail.updateView();
}
}
);
/** /**
* Listens for changes in the chat state to calculate the dimensions of the tile view grid and the tiles. * Listens for changes in the chat state to calculate the dimensions of the tile view grid and the tiles.
*/ */

View File

@ -34,7 +34,7 @@ import { toggleScreensharing } from '../../base/tracks';
import { OPEN_CHAT, CLOSE_CHAT } from '../../chat'; import { OPEN_CHAT, CLOSE_CHAT } from '../../chat';
import { openChat } from '../../chat/actions'; import { openChat } from '../../chat/actions';
import { sendMessage, setPrivateMessageRecipient, closeChat } from '../../chat/actions.any'; import { sendMessage, setPrivateMessageRecipient, closeChat } from '../../chat/actions.any';
import { muteLocal } from '../../remote-video-menu/actions'; import { muteLocal } from '../../video-menu/actions';
import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture'; import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture';
import { setParticipantsWithScreenShare } from './actions'; import { setParticipantsWithScreenShare } from './actions';

View File

@ -1,44 +0,0 @@
/* @flow */
import React, { Component } from 'react';
/**
* The type of the React {@code Component} props of {@link RemoteVideoMenu}.
*/
type Props = {
/**
* The components to place as the body of the {@code RemoteVideoMenu}.
*/
children: React$Node,
/**
* The id attribute to be added to the component's DOM for retrieval when
* querying the DOM. Not used directly by the component.
*/
id: string
};
/**
* React {@code Component} responsible for displaying other components as a menu
* for manipulating remote participant state.
*
* @extends {Component}
*/
export default class RemoteVideoMenu extends Component<Props> {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<ul
className = 'popupmenu'
id = { this.props.id }>
{ this.props.children }
</ul>
);
}
}

View File

@ -13,7 +13,7 @@ import { connect } from '../../base/redux';
import { AbstractAudioMuteButton } from '../../base/toolbox/components'; import { AbstractAudioMuteButton } from '../../base/toolbox/components';
import type { AbstractButtonProps } from '../../base/toolbox/components'; import type { AbstractButtonProps } from '../../base/toolbox/components';
import { isLocalTrackMuted } from '../../base/tracks'; import { isLocalTrackMuted } from '../../base/tracks';
import { muteLocal } from '../../remote-video-menu/actions'; import { muteLocal } from '../../video-menu/actions';
declare var APP: Object; declare var APP: Object;

View File

@ -7,7 +7,7 @@ import { IconMuteEveryone } from '../../base/icons';
import { getLocalParticipant, PARTICIPANT_ROLE } from '../../base/participants'; import { getLocalParticipant, PARTICIPANT_ROLE } from '../../base/participants';
import { connect } from '../../base/redux'; import { connect } from '../../base/redux';
import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components'; import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components';
import { MuteEveryoneDialog } from '../../remote-video-menu/components'; import { MuteEveryoneDialog } from '../../video-menu/components';
type Props = AbstractButtonProps & { type Props = AbstractButtonProps & {

View File

@ -7,7 +7,7 @@ import { IconMuteVideoEveryone } from '../../base/icons';
import { getLocalParticipant, PARTICIPANT_ROLE } from '../../base/participants'; import { getLocalParticipant, PARTICIPANT_ROLE } from '../../base/participants';
import { connect } from '../../base/redux'; import { connect } from '../../base/redux';
import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components'; import { AbstractButton, type AbstractButtonProps } from '../../base/toolbox/components';
import { MuteEveryonesVideoDialog } from '../../remote-video-menu/components'; import { MuteEveryonesVideoDialog } from '../../video-menu/components';
type Props = AbstractButtonProps & { type Props = AbstractButtonProps & {

View File

@ -4,15 +4,12 @@ import VideoLayout from '../../../modules/UI/videolayout/VideoLayout.js';
import { CONFERENCE_WILL_LEAVE } from '../base/conference'; import { CONFERENCE_WILL_LEAVE } from '../base/conference';
import { MEDIA_TYPE } from '../base/media'; import { MEDIA_TYPE } from '../base/media';
import { import {
DOMINANT_SPEAKER_CHANGED, getLocalParticipant,
PARTICIPANT_JOINED, PARTICIPANT_JOINED,
PARTICIPANT_LEFT, PARTICIPANT_UPDATED
PARTICIPANT_UPDATED,
PIN_PARTICIPANT,
getParticipantById
} from '../base/participants'; } from '../base/participants';
import { MiddlewareRegistry } from '../base/redux'; import { MiddlewareRegistry } from '../base/redux';
import { TRACK_ADDED, TRACK_REMOVED } from '../base/tracks'; import { TRACK_ADDED, TRACK_REMOVED, TRACK_STOPPED } from '../base/tracks';
import { SET_FILMSTRIP_VISIBLE } from '../filmstrip'; import { SET_FILMSTRIP_VISIBLE } from '../filmstrip';
import './middleware.any'; import './middleware.any';
@ -40,15 +37,10 @@ MiddlewareRegistry.register(store => next => action => {
case PARTICIPANT_JOINED: case PARTICIPANT_JOINED:
if (!action.participant.local) { if (!action.participant.local) {
VideoLayout.addRemoteParticipantContainer( VideoLayout.updateVideoMutedForNoTracks(action.participant.id);
getParticipantById(store.getState(), action.participant.id));
} }
break; break;
case PARTICIPANT_LEFT:
VideoLayout.removeParticipantContainer(action.participant.id);
break;
case PARTICIPANT_UPDATED: { case PARTICIPANT_UPDATED: {
// Look for actions that triggered a change to connectionStatus. This is // Look for actions that triggered a change to connectionStatus. This is
// done instead of changing the connection status change action to be // done instead of changing the connection status change action to be
@ -61,27 +53,28 @@ MiddlewareRegistry.register(store => next => action => {
break; break;
} }
case DOMINANT_SPEAKER_CHANGED:
VideoLayout.onDominantSpeakerChanged(action.participant.id);
break;
case PIN_PARTICIPANT:
VideoLayout.onPinChange(action.participant?.id);
break;
case SET_FILMSTRIP_VISIBLE: case SET_FILMSTRIP_VISIBLE:
VideoLayout.resizeVideoArea(); VideoLayout.resizeVideoArea();
break; break;
case TRACK_ADDED: case TRACK_ADDED:
if (!action.track.local && action.track.mediaType !== MEDIA_TYPE.AUDIO) { if (action.track.mediaType !== MEDIA_TYPE.AUDIO) {
VideoLayout.onRemoteStreamAdded(action.track.jitsiTrack); VideoLayout._updateLargeVideoIfDisplayed(action.track.participantId, true);
} }
break; break;
case TRACK_STOPPED: {
if (action.track.jitsiTrack.isLocal()) {
const participant = getLocalParticipant(store.getState);
VideoLayout._updateLargeVideoIfDisplayed(participant?.id);
}
break;
}
case TRACK_REMOVED: case TRACK_REMOVED:
if (!action.track.local && action.track.mediaType !== MEDIA_TYPE.AUDIO) { if (!action.track.local && action.track.mediaType !== MEDIA_TYPE.AUDIO) {
VideoLayout.onRemoteStreamRemoved(action.track.jitsiTrack); VideoLayout.updateVideoMutedForNoTracks(action.track.jitsiTrack.getParticipantId());
} }
break; break;

View File

@ -10,7 +10,6 @@ import {
sendAnalytics, sendAnalytics,
VIDEO_MUTE VIDEO_MUTE
} from '../analytics'; } from '../analytics';
import { hideDialog } from '../base/dialog';
import { import {
MEDIA_TYPE, MEDIA_TYPE,
setAudioMuted, setAudioMuted,
@ -22,21 +21,10 @@ import {
muteRemoteParticipant muteRemoteParticipant
} from '../base/participants'; } from '../base/participants';
import { RemoteVideoMenu } from './components';
declare var APP: Object; declare var APP: Object;
const logger = getLogger(__filename); const logger = getLogger(__filename);
/**
* Hides the remote video menu.
*
* @returns {Function}
*/
export function hideRemoteVideoMenu() {
return hideDialog(RemoteVideoMenu);
}
/** /**
* Mutes the local participant. * Mutes the local participant.
* *

View File

@ -0,0 +1,15 @@
// @flow
import { hideDialog } from '../base/dialog';
import { RemoteVideoMenu } from './components/native';
/**
* Hides the remote video menu.
*
* @returns {Function}
*/
export function hideRemoteVideoMenu() {
return hideDialog(RemoteVideoMenu);
}
export * from './actions.any';

View File

@ -0,0 +1,2 @@
// @flow
export * from './actions.any';

View File

@ -11,7 +11,7 @@ import { getParticipantDisplayName } from '../../../base/participants';
import { connect } from '../../../base/redux'; import { connect } from '../../../base/redux';
import { StyleType } from '../../../base/styles'; import { StyleType } from '../../../base/styles';
import { PrivateMessageButton } from '../../../chat'; import { PrivateMessageButton } from '../../../chat';
import { hideRemoteVideoMenu } from '../../actions'; import { hideRemoteVideoMenu } from '../../actions.native';
import ConnectionStatusButton from './ConnectionStatusButton'; import ConnectionStatusButton from './ConnectionStatusButton';
import GrantModeratorButton from './GrantModeratorButton'; import GrantModeratorButton from './GrantModeratorButton';

View File

@ -0,0 +1,103 @@
/* @flow */
import React, { PureComponent } from 'react';
import { translate } from '../../../base/i18n';
import { connect } from '../../../base/redux';
import { updateSettings } from '../../../base/settings';
import VideoMenuButton from './VideoMenuButton';
/**
* The type of the React {@code Component} props of {@link FlipLocalVideoButton}.
*/
type Props = {
/**
* The current local flip x status.
*/
_localFlipX: boolean,
/**
* The redux dispatch function.
*/
dispatch: Function,
/**
* Invoked to obtain translated strings.
*/
t: Function
};
/**
* Implements a React {@link Component} which displays a button for flipping the local viedo.
*
* @extends Component
*/
class FlipLocalVideoButton extends PureComponent<Props> {
/**
* Initializes a new {@code FlipLocalVideoButton} instance.
*
* @param {Object} props - The read-only React Component props with which
* the new instance is to be initialized.
*/
constructor(props: Props) {
super(props);
// Bind event handlers so they are only bound once for every instance.
this._onClick = this._onClick.bind(this);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {null|ReactElement}
*/
render() {
const {
t
} = this.props;
return (
<VideoMenuButton
buttonText = { t('videothumbnail.flip') }
displayClass = 'fliplink'
id = 'flipLocalVideoButton'
onClick = { this._onClick } />
);
}
_onClick: () => void;
/**
* Flips the local video.
*
* @private
* @returns {void}
*/
_onClick() {
const { _localFlipX, dispatch } = this.props;
dispatch(updateSettings({
localFlipX: !_localFlipX
}));
}
}
/**
* Maps (parts of) the Redux state to the associated {@code FlipLocalVideoButton}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Props}
*/
function _mapStateToProps(state) {
const { localFlipX } = state['features/base/settings'];
return {
_localFlipX: Boolean(localFlipX)
};
}
export default translate(connect(_mapStateToProps)(FlipLocalVideoButton));

View File

@ -10,7 +10,7 @@ import AbstractGrantModeratorButton, {
type Props type Props
} from '../AbstractGrantModeratorButton'; } from '../AbstractGrantModeratorButton';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
@ -44,7 +44,7 @@ class GrantModeratorButton extends AbstractGrantModeratorButton {
} }
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t('videothumbnail.grantModerator') } buttonText = { t('videothumbnail.grantModerator') }
displayClass = 'grantmoderatorlink' displayClass = 'grantmoderatorlink'
icon = { IconCrown } icon = { IconCrown }

View File

@ -9,7 +9,7 @@ import AbstractKickButton, {
type Props type Props
} from '../AbstractKickButton'; } from '../AbstractKickButton';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
/** /**
* Implements a React {@link Component} which displays a button for kicking out * Implements a React {@link Component} which displays a button for kicking out
@ -43,7 +43,7 @@ class KickButton extends AbstractKickButton {
const { participantID, t } = this.props; const { participantID, t } = this.props;
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t('videothumbnail.kick') } buttonText = { t('videothumbnail.kick') }
displayClass = 'kicklink' displayClass = 'kicklink'
icon = { IconKick } icon = { IconKick }

View File

@ -0,0 +1,100 @@
// @flow
import React from 'react';
import { Icon, IconMenuThumb } from '../../../base/icons';
import { Popover } from '../../../base/popover';
import { connect } from '../../../base/redux';
import { getLocalVideoTrack } from '../../../base/tracks';
import { getCurrentLayout, LAYOUTS } from '../../../video-layout';
import FlipLocalVideoButton from './FlipLocalVideoButton';
import VideoMenu from './VideoMenu';
/**
* The type of the React {@code Component} props of
* {@link LocalVideoMenuTriggerButton}.
*/
type Props = {
/**
* The position relative to the trigger the local video menu should display
* from. Valid values are those supported by AtlasKit
* {@code InlineDialog}.
*/
_menuPosition: string,
/**
* Whether to display the Popover as a drawer.
*/
_overflowDrawer: boolean,
/**
* Shows/hides the local video flip button.
*/
_showLocalVideoFlipButton: boolean
};
/**
* React Component for displaying an icon associated with opening the
* the video menu for the local participant.
*
* @param {Props} props - The props passed to the component.
* @returns {ReactElement}
*/
function LocalVideoMenuTriggerButton(props: Props) {
return (
props._showLocalVideoFlipButton
? <Popover
content = {
<VideoMenu id = 'localVideoMenu'>
<FlipLocalVideoButton />
</VideoMenu>
}
overflowDrawer = { props._overflowDrawer }
position = { props._menuPosition }>
<span
className = 'popover-trigger local-video-menu-trigger'>
<Icon
size = '1em'
src = { IconMenuThumb }
title = 'Local user controls' />
</span>
</Popover>
: null
);
}
/**
* Maps (parts of) the Redux state to the associated {@code LocalVideoMenuTriggerButton}'s props.
*
* @param {Object} state - The Redux state.
* @private
* @returns {Props}
*/
function _mapStateToProps(state) {
const currentLayout = getCurrentLayout(state);
const { disableLocalVideoFlip } = state['features/base/config'];
const videoTrack = getLocalVideoTrack(state['features/base/tracks']);
const { overflowDrawer } = state['features/toolbox'];
let _menuPosition;
switch (currentLayout) {
case LAYOUTS.TILE_VIEW:
_menuPosition = 'left-start';
break;
case LAYOUTS.VERTICAL_FILMSTRIP_VIEW:
_menuPosition = 'left-end';
break;
default:
_menuPosition = 'auto';
}
return {
_menuPosition,
_showLocalVideoFlipButton: !disableLocalVideoFlip && videoTrack?.videoType !== 'desktop',
_overflowDrawer: overflowDrawer
};
}
export default connect(_mapStateToProps)(LocalVideoMenuTriggerButton);

View File

@ -10,7 +10,7 @@ import AbstractMuteButton, {
type Props type Props
} from '../AbstractMuteButton'; } from '../AbstractMuteButton';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
/** /**
* Implements a React {@link Component} which displays a button for audio muting * Implements a React {@link Component} which displays a button for audio muting
@ -51,7 +51,7 @@ class MuteButton extends AbstractMuteButton {
}; };
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t(muteConfig.translationKey) } buttonText = { t(muteConfig.translationKey) }
displayClass = { muteConfig.muteClassName } displayClass = { muteConfig.muteClassName }
icon = { IconMicDisabled } icon = { IconMicDisabled }

View File

@ -9,7 +9,7 @@ import AbstractMuteEveryoneElseButton, {
type Props type Props
} from '../AbstractMuteEveryoneElseButton'; } from '../AbstractMuteEveryoneElseButton';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
/** /**
* Implements a React {@link Component} which displays a button for audio muting * Implements a React {@link Component} which displays a button for audio muting
@ -38,7 +38,7 @@ class MuteEveryoneElseButton extends AbstractMuteEveryoneElseButton {
const { participantID, t } = this.props; const { participantID, t } = this.props;
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t('videothumbnail.domuteOthers') } buttonText = { t('videothumbnail.domuteOthers') }
displayClass = { 'mutelink' } displayClass = { 'mutelink' }
icon = { IconMuteEveryoneElse } icon = { IconMuteEveryoneElse }

View File

@ -9,7 +9,7 @@ import AbstractMuteEveryoneElsesVideoButton, {
type Props type Props
} from '../AbstractMuteEveryoneElsesVideoButton'; } from '../AbstractMuteEveryoneElsesVideoButton';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
/** /**
* Implements a React {@link Component} which displays a button for audio muting * Implements a React {@link Component} which displays a button for audio muting
@ -38,7 +38,7 @@ class MuteEveryoneElsesVideoButton extends AbstractMuteEveryoneElsesVideoButton
const { participantID, t } = this.props; const { participantID, t } = this.props;
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t('videothumbnail.domuteVideoOfOthers') } buttonText = { t('videothumbnail.domuteVideoOfOthers') }
displayClass = { 'mutelink' } displayClass = { 'mutelink' }
icon = { IconMuteVideoEveryoneElse } icon = { IconMuteVideoEveryoneElse }

View File

@ -10,7 +10,7 @@ import AbstractMuteVideoButton, {
type Props type Props
} from '../AbstractMuteVideoButton'; } from '../AbstractMuteVideoButton';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
/** /**
* Implements a React {@link Component} which displays a button for disabling * Implements a React {@link Component} which displays a button for disabling
@ -51,7 +51,7 @@ class MuteVideoButton extends AbstractMuteVideoButton {
}; };
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t(muteConfig.translationKey) } buttonText = { t(muteConfig.translationKey) }
displayClass = { muteConfig.muteClassName } displayClass = { muteConfig.muteClassName }
icon = { IconCameraDisabled } icon = { IconCameraDisabled }

View File

@ -12,7 +12,7 @@ import {
} from '../../../chat/components/PrivateMessageButton'; } from '../../../chat/components/PrivateMessageButton';
import { isButtonEnabled } from '../../../toolbox/functions.web'; import { isButtonEnabled } from '../../../toolbox/functions.web';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
declare var interfaceConfig: Object; declare var interfaceConfig: Object;
@ -56,7 +56,7 @@ class PrivateMessageMenuButton extends Component<Props> {
} }
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t('toolbar.privateMessage') } buttonText = { t('toolbar.privateMessage') }
icon = { IconMessage } icon = { IconMessage }
id = { `privmsglink_${participantID}` } id = { `privmsglink_${participantID}` }

View File

@ -9,7 +9,7 @@ import {
import { translate } from '../../../base/i18n'; import { translate } from '../../../base/i18n';
import { IconRemoteControlStart, IconRemoteControlStop } from '../../../base/icons'; import { IconRemoteControlStart, IconRemoteControlStop } from '../../../base/icons';
import RemoteVideoMenuButton from './RemoteVideoMenuButton'; import VideoMenuButton from './VideoMenuButton';
// TODO: Move these enums into the store after further reactification of the // TODO: Move these enums into the store after further reactification of the
// non-react RemoteVideo component. // non-react RemoteVideo component.
@ -102,7 +102,7 @@ class RemoteControlButton extends Component<Props> {
} }
return ( return (
<RemoteVideoMenuButton <VideoMenuButton
buttonText = { t('videothumbnail.remoteControl') } buttonText = { t('videothumbnail.remoteControl') }
displayClass = { className } displayClass = { className }
icon = { icon } icon = { icon }

View File

@ -20,7 +20,7 @@ import {
KickButton, KickButton,
PrivateMessageMenuButton, PrivateMessageMenuButton,
RemoteControlButton, RemoteControlButton,
RemoteVideoMenu, VideoMenu,
VolumeSlider VolumeSlider
} from './'; } from './';
@ -91,21 +91,11 @@ type Props = {
/** /**
* React {@code Component} for displaying an icon associated with opening the * React {@code Component} for displaying an icon associated with opening the
* the {@code RemoteVideoMenu}. * the {@code VideoMenu}.
* *
* @extends {Component} * @extends {Component}
*/ */
class RemoteVideoMenuTriggerButton extends Component<Props> { class RemoteVideoMenuTriggerButton extends Component<Props> {
/**
* The internal reference to topmost DOM/HTML element backing the React
* {@code Component}. Accessed directly for associating an element as
* the trigger for a popover.
*
* @private
* @type {HTMLDivElement}
*/
_rootElement = null;
/** /**
* Implements React's {@link Component#render()}. * Implements React's {@link Component#render()}.
* *
@ -136,7 +126,7 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
} }
/** /**
* Creates a new {@code RemoteVideoMenu} with buttons for interacting with * Creates a new {@code VideoMenu} with buttons for interacting with
* the remote participant. * the remote participant.
* *
* @private * @private
@ -230,9 +220,9 @@ class RemoteVideoMenuTriggerButton extends Component<Props> {
if (buttons.length > 0) { if (buttons.length > 0) {
return ( return (
<RemoteVideoMenu id = { participantID }> <VideoMenu id = { participantID }>
{ buttons } { buttons }
</RemoteVideoMenu> </VideoMenu>
); );
} }

View File

@ -0,0 +1,51 @@
// @flow
import React from 'react';
/**
* The type of the React {@code Component} props of {@link VideoMenu}.
*/
type Props = {
/**
* The components to place as the body of the {@code VideoMenu}.
*/
children: React$Node,
/**
* The id attribute to be added to the component's DOM for retrieval when
* querying the DOM. Not used directly by the component.
*/
id: string
};
/**
* Click handler.
*
* @param {SyntheticEvent} event - The click event.
* @returns {void}
*/
function onClick(event) {
// If the event is propagated to the thumbnail container the participant will be pinned. That's why the propagation
// needs to be stopped.
event.stopPropagation();
}
/**
* React {@code Component} responsible for displaying other components as a menu
* for manipulating participant state.
*
* @param {Props} props - The component's props.
* @returns {Component}
*/
export default function VideoMenu(props: Props) {
return (
<ul
className = 'popupmenu'
id = { props.id }
onClick = { onClick }>
{ props.children }
</ul>
);
}

View File

@ -6,7 +6,7 @@ import { Icon } from '../../../base/icons';
/** /**
* The type of the React {@code Component} props of * The type of the React {@code Component} props of
* {@link RemoteVideoMenuButton}. * {@link VideoMenuButton}.
*/ */
type Props = { type Props = {
@ -23,7 +23,7 @@ type Props = {
/** /**
* The icon that will display within the component. * The icon that will display within the component.
*/ */
icon: Object, icon?: Object,
/** /**
* The id attribute to be added to the component's DOM for retrieval when * The id attribute to be added to the component's DOM for retrieval when
@ -38,11 +38,11 @@ type Props = {
}; };
/** /**
* React {@code Component} for displaying an action in {@code RemoteVideoMenu}. * React {@code Component} for displaying an action in {@code VideoMenuButton}.
* *
* @extends {Component} * @extends {Component}
*/ */
export default class RemoteVideoMenuButton extends Component<Props> { export default class VideoMenuButton extends Component<Props> {
/** /**
* Implements React's {@link Component#render()}. * Implements React's {@link Component#render()}.
* *
@ -67,7 +67,7 @@ export default class RemoteVideoMenuButton extends Component<Props> {
id = { id } id = { id }
onClick = { onClick }> onClick = { onClick }>
<span className = 'popupmenu__icon'> <span className = 'popupmenu__icon'>
<Icon src = { icon } /> { icon && <Icon src = { icon } /> }
</span> </span>
<span className = 'popupmenu__text'> <span className = 'popupmenu__text'>
{ buttonText } { buttonText }

View File

@ -14,6 +14,7 @@ export { default as MuteRemoteParticipantDialog } from './MuteRemoteParticipantD
export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog'; export { default as MuteRemoteParticipantsVideoDialog } from './MuteRemoteParticipantsVideoDialog';
export { default as PrivateMessageMenuButton } from './PrivateMessageMenuButton'; export { default as PrivateMessageMenuButton } from './PrivateMessageMenuButton';
export { REMOTE_CONTROL_MENU_STATES, default as RemoteControlButton } from './RemoteControlButton'; export { REMOTE_CONTROL_MENU_STATES, default as RemoteControlButton } from './RemoteControlButton';
export { default as RemoteVideoMenu } from './RemoteVideoMenu'; export { default as VideoMenu } from './VideoMenu';
export { default as RemoteVideoMenuTriggerButton } from './RemoteVideoMenuTriggerButton'; export { default as RemoteVideoMenuTriggerButton } from './RemoteVideoMenuTriggerButton';
export { default as LocalVideoMenuTriggerButton } from './LocalVideoMenuTriggerButton';
export { default as VolumeSlider } from './VolumeSlider'; export { default as VolumeSlider } from './VolumeSlider';

View File

@ -48,11 +48,6 @@ export default {
VIDEO_DEVICE_CHANGED: 'UI.video_device_changed', VIDEO_DEVICE_CHANGED: 'UI.video_device_changed',
AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed', AUDIO_DEVICE_CHANGED: 'UI.audio_device_changed',
/**
* Notifies that flipX property of the local video is changed.
*/
LOCAL_FLIPX_CHANGED: 'UI.local_flipx_changed',
/** /**
* Notifies that the side toolbar container has been toggled. The actual * Notifies that the side toolbar container has been toggled. The actual
* event must contain the identifier of the container that has been toggled * event must contain the identifier of the container that has been toggled
@ -63,15 +58,5 @@ export default {
/** /**
* Notifies that the raise hand has been changed. * Notifies that the raise hand has been changed.
*/ */
LOCAL_RAISE_HAND_CHANGED: 'UI.local_raise_hand_changed', LOCAL_RAISE_HAND_CHANGED: 'UI.local_raise_hand_changed'
/**
* Notifies that the avatar is displayed or not on the largeVideo.
*/
LARGE_VIDEO_AVATAR_VISIBLE: 'UI.large_video_avatar_visible',
/**
* Notifies that the displayed particpant id on the largeVideo is changed.
*/
LARGE_VIDEO_ID_CHANGED: 'UI.large_video_id_changed'
}; };