Merge pull request #938 from jitsi/participant_conn_status

Adds participant connection status notifications
This commit is contained in:
hristoterezov 2016-09-27 17:54:22 -05:00 committed by GitHub
commit 2a8700bca3
13 changed files with 680 additions and 95 deletions

View File

@ -691,6 +691,51 @@ export default {
isConnectionInterrupted () { isConnectionInterrupted () {
return connectionIsInterrupted; return connectionIsInterrupted;
}, },
/**
* Finds JitsiParticipant for given id.
*
* @param {string} id participant's identifier(MUC nickname).
*
* @returns {JitsiParticipant|null} participant instance for given id or
* null if not found.
*/
getParticipantById (id) {
return room ? room.getParticipantById(id) : null;
},
/**
* Checks whether the user identified by given id is currently connected.
*
* @param {string} id participant's identifier(MUC nickname)
*
* @returns {boolean|null} true if participant's connection is ok or false
* if the user is having connectivity issues.
*/
isParticipantConnectionActive (id) {
let participant = this.getParticipantById(id);
return participant ? participant.isConnectionActive() : null;
},
/**
* Gets the display name foe the <tt>JitsiParticipant</tt> identified by
* the given <tt>id</tt>.
*
* @param id {string} the participant's id(MUC nickname/JVB endpoint id)
*
* @return {string} the participant's display name or the default string if
* absent.
*/
getParticipantDisplayName (id) {
let displayName = getDisplayName(id);
if (displayName) {
return displayName;
} else {
if (APP.conference.isLocalId(id)) {
return APP.translation.generateTranslationHTML(
interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME);
} else {
return interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME;
}
}
},
getMyUserId () { getMyUserId () {
return this._room return this._room
&& this._room.myUserId(); && this._room.myUserId();
@ -1085,7 +1130,7 @@ export default {
console.log('USER %s connnected', id, user); console.log('USER %s connnected', id, user);
APP.API.notifyUserJoined(id); APP.API.notifyUserJoined(id);
APP.UI.addUser(id, user.getDisplayName()); APP.UI.addUser(user);
// check the roles for the new user and reflect them // check the roles for the new user and reflect them
APP.UI.updateUserRole(user); APP.UI.updateUserRole(user);
@ -1174,6 +1219,10 @@ export default {
ConferenceEvents.LAST_N_ENDPOINTS_CHANGED, (ids, enteringIds) => { ConferenceEvents.LAST_N_ENDPOINTS_CHANGED, (ids, enteringIds) => {
APP.UI.handleLastNEndpoints(ids, enteringIds); APP.UI.handleLastNEndpoints(ids, enteringIds);
}); });
room.on(
ConferenceEvents.PARTICIPANT_CONN_STATUS_CHANGED, (id, isActive) => {
APP.UI.participantConnectionStatusChanged(id, isActive);
});
room.on(ConferenceEvents.DOMINANT_SPEAKER_CHANGED, (id) => { room.on(ConferenceEvents.DOMINANT_SPEAKER_CHANGED, (id) => {
if (this.isLocalId(id)) { if (this.isLocalId(id)) {
this.isDominantSpeaker = true; this.isDominantSpeaker = true;
@ -1205,10 +1254,12 @@ export default {
room.on(ConferenceEvents.CONNECTION_INTERRUPTED, () => { room.on(ConferenceEvents.CONNECTION_INTERRUPTED, () => {
connectionIsInterrupted = true; connectionIsInterrupted = true;
ConnectionQuality.updateLocalConnectionQuality(0); ConnectionQuality.updateLocalConnectionQuality(0);
APP.UI.showLocalConnectionInterrupted(true);
}); });
room.on(ConferenceEvents.CONNECTION_RESTORED, () => { room.on(ConferenceEvents.CONNECTION_RESTORED, () => {
connectionIsInterrupted = false; connectionIsInterrupted = false;
APP.UI.showLocalConnectionInterrupted(false);
}); });
room.on(ConferenceEvents.DISPLAY_NAME_CHANGED, (id, displayName) => { room.on(ConferenceEvents.DISPLAY_NAME_CHANGED, (id, displayName) => {

View File

@ -233,6 +233,12 @@
overflow: hidden; overflow: hidden;
} }
.connection.connection_lost
{
color: #8B8B8B;
overflow: visible;
}
.connection.connection_full .connection.connection_full
{ {
color: #FFFFFF;/*#15A1ED*/ color: #FFFFFF;/*#15A1ED*/
@ -456,12 +462,44 @@
filter: grayscale(.5) opacity(0.8); filter: grayscale(.5) opacity(0.8);
} }
.remoteVideoProblemFilter {
-webkit-filter: grayscale(100%);
filter: grayscale(100%);
}
.videoProblemFilter { .videoProblemFilter {
-webkit-filter: blur(10px) grayscale(.5) opacity(0.8); -webkit-filter: blur(10px) grayscale(.5) opacity(0.8);
filter: blur(10px) grayscale(.5) opacity(0.8); filter: blur(10px) grayscale(.5) opacity(0.8);
} }
#videoConnectionMessage { .videoThumbnailProblemFilter {
-webkit-filter: grayscale(100%);
filter: grayscale(100%);
}
#remoteConnectionMessage {
display: none;
position: absolute;
width: auto;
z-index: 1011;
font-weight: 600;
font-size: 14px;
text-align: center;
color: #FFF;
opacity: .80;
text-shadow: 0px 0px 1px rgba(0,0,0,0.3),
0px 1px 1px rgba(0,0,0,0.3),
1px 0px 1px rgba(0,0,0,0.3),
0px 0px 1px rgba(0,0,0,0.3);
background: rgba(0,0,0,.5);
border-radius: 5px;
padding: 5px;
padding-left: 10px;
padding-right: 10px;
}
#localConnectionMessage {
display: none; display: none;
position: absolute; position: absolute;
width: 100%; width: 100%;

View File

@ -228,10 +228,11 @@
<img id="dominantSpeakerAvatar" src=""/> <img id="dominantSpeakerAvatar" src=""/>
<canvas id="dominantSpeakerAudioLevel"></canvas> <canvas id="dominantSpeakerAudioLevel"></canvas>
</div> </div>
<span id="remoteConnectionMessage"></span>
<div id="largeVideoWrapper"> <div id="largeVideoWrapper">
<video id="largeVideo" muted="true" autoplay></video> <video id="largeVideo" muted="true" autoplay></video>
</div> </div>
<span id="videoConnectionMessage"></span> <span id="localConnectionMessage"></span>
<span id="videoResolutionLabel">HD</span> <span id="videoResolutionLabel">HD</span>
<span id="recordingLabel" class="centeredVideoLabel"> <span id="recordingLabel" class="centeredVideoLabel">
<span id="recordingLabelText"></span> <span id="recordingLabelText"></span>

View File

@ -324,7 +324,8 @@
"ATTACHED": "Attached", "ATTACHED": "Attached",
"FETCH_SESSION_ID": "Obtaining session-id...", "FETCH_SESSION_ID": "Obtaining session-id...",
"GOT_SESSION_ID": "Obtaining session-id... Done", "GOT_SESSION_ID": "Obtaining session-id... Done",
"GET_SESSION_ID_ERROR": "Get session-id error: " "GET_SESSION_ID_ERROR": "Get session-id error: ",
"USER_CONNECTION_INTERRUPTED": "__displayName__ is having connectivity issues..."
}, },
"recording": "recording":
{ {

View File

@ -261,6 +261,17 @@ UI.changeDisplayName = function (id, displayName) {
} }
}; };
/**
* Shows/hides the indication about local connection being interrupted.
*
* @param {boolean} isInterrupted <tt>true</tt> if local connection is
* currently in the interrupted state or <tt>false</tt> if the connection
* is fine.
*/
UI.showLocalConnectionInterrupted = function (isInterrupted) {
VideoLayout.showLocalConnectionInterrupted(isInterrupted);
};
/** /**
* Sets the "raised hand" status for a participant. * Sets the "raised hand" status for a participant.
*/ */
@ -602,10 +613,11 @@ UI.getSharedDocumentManager = function () {
/** /**
* Show user on UI. * Show user on UI.
* @param {string} id user id * @param {JitsiParticipant} user
* @param {string} displayName user nickname
*/ */
UI.addUser = function (id, displayName) { UI.addUser = function (user) {
var id = user.getId();
var displayName = user.getDisplayName();
UI.hideRingOverLay(); UI.hideRingOverLay();
ContactList.addContact(id); ContactList.addContact(id);
@ -618,7 +630,7 @@ UI.addUser = function (id, displayName) {
UIUtil.playSoundNotification('userJoined'); UIUtil.playSoundNotification('userJoined');
// Add Peer's container // Add Peer's container
VideoLayout.addParticipantContainer(id); VideoLayout.addParticipantContainer(user);
// Configure avatar // Configure avatar
UI.setUserEmail(id); UI.setUserEmail(id);
@ -983,6 +995,17 @@ UI.handleLastNEndpoints = function (ids, enteringIds) {
VideoLayout.onLastNEndpointsChanged(ids, enteringIds); VideoLayout.onLastNEndpointsChanged(ids, enteringIds);
}; };
/**
* Will handle notification about participant's connectivity status change.
*
* @param {string} id the id of remote participant(MUC jid)
* @param {boolean} isActive true if the connection is ok or false if the user
* is having connectivity issues.
*/
UI.participantConnectionStatusChanged = function (id, isActive) {
VideoLayout.onParticipantConnectionStatusChanged(id, isActive);
};
/** /**
* Update audio level visualization for specified user. * Update audio level visualization for specified user.
* @param {string} id user id * @param {string} id user id

View File

@ -243,7 +243,7 @@ export default class SharedVideoManager {
let thumb = new SharedVideoThumb(self.url); let thumb = new SharedVideoThumb(self.url);
thumb.setDisplayName(player.getVideoData().title); thumb.setDisplayName(player.getVideoData().title);
VideoLayout.addParticipantContainer(self.url, thumb); VideoLayout.addRemoteVideoContainer(self.url, thumb);
let iframe = player.getIframe(); let iframe = player.getIframe();
self.sharedVideo = new SharedVideoContainer( self.sharedVideo = new SharedVideoContainer(

View File

@ -245,13 +245,13 @@ ConnectionIndicator.prototype.showMore = function () {
}; };
function createIcon(classes) { function createIcon(classes, iconClass) {
var icon = document.createElement("span"); var icon = document.createElement("span");
for(var i in classes) { for(var i in classes) {
icon.classList.add(classes[i]); icon.classList.add(classes[i]);
} }
icon.appendChild( icon.appendChild(
document.createElement("i")).classList.add("icon-connection"); document.createElement("i")).classList.add(iconClass);
return icon; return icon;
} }
@ -282,9 +282,12 @@ ConnectionIndicator.prototype.create = function () {
}.bind(this); }.bind(this);
this.emptyIcon = this.connectionIndicatorContainer.appendChild( this.emptyIcon = this.connectionIndicatorContainer.appendChild(
createIcon(["connection", "connection_empty"])); createIcon(["connection", "connection_empty"], "icon-connection"));
this.fullIcon = this.connectionIndicatorContainer.appendChild( this.fullIcon = this.connectionIndicatorContainer.appendChild(
createIcon(["connection", "connection_full"])); createIcon(["connection", "connection_full"], "icon-connection"));
this.interruptedIndicator = this.connectionIndicatorContainer.appendChild(
createIcon(["connection", "connection_lost"],"icon-connection-lost"));
$(this.interruptedIndicator).hide();
}; };
/** /**
@ -298,6 +301,27 @@ ConnectionIndicator.prototype.remove = function() {
this.popover.forceHide(); this.popover.forceHide();
}; };
/**
* Updates the UI which displays warning about user's connectivity problems.
*
* @param {boolean} isActive true if the connection is working fine or false if
* the user is having connectivity issues.
*/
ConnectionIndicator.prototype.updateConnectionStatusIndicator
= function (isActive) {
this.isConnectionActive = isActive;
if (this.isConnectionActive) {
$(this.interruptedIndicator).hide();
$(this.emptyIcon).show();
$(this.fullIcon).show();
} else {
$(this.interruptedIndicator).show();
$(this.emptyIcon).hide();
$(this.fullIcon).hide();
this.updateConnectionQuality(0 /* zero bars */);
}
};
/** /**
* Updates the data of the indicator * Updates the data of the indicator
* @param percent the percent of connection quality * @param percent the percent of connection quality
@ -314,12 +338,14 @@ ConnectionIndicator.prototype.updateConnectionQuality =
this.connectionIndicatorContainer.style.display = "block"; this.connectionIndicatorContainer.style.display = "block";
} }
} }
this.bandwidth = object.bandwidth; if (object) {
this.bitrate = object.bitrate; this.bandwidth = object.bandwidth;
this.packetLoss = object.packetLoss; this.bitrate = object.bitrate;
this.transport = object.transport; this.packetLoss = object.packetLoss;
if (object.resolution) { this.transport = object.transport;
this.resolution = object.resolution; if (object.resolution) {
this.resolution = object.resolution;
}
} }
for (var quality in ConnectionIndicator.connectionQualityValues) { for (var quality in ConnectionIndicator.connectionQualityValues) {
if (percent >= quality) { if (percent >= quality) {
@ -327,7 +353,7 @@ ConnectionIndicator.prototype.updateConnectionQuality =
ConnectionIndicator.connectionQualityValues[quality]; ConnectionIndicator.connectionQualityValues[quality];
} }
} }
if (object.isResolutionHD) { if (object && typeof object.isResolutionHD === 'boolean') {
this.isResolutionHD = object.isResolutionHD; this.isResolutionHD = object.isResolutionHD;
} }
this.updateResolutionIndicator(); this.updateResolutionIndicator();

View File

@ -5,12 +5,18 @@ import Avatar from "../avatar/Avatar";
import {createDeferred} from '../../util/helpers'; import {createDeferred} from '../../util/helpers';
import UIUtil from "../util/UIUtil"; import UIUtil from "../util/UIUtil";
import {VideoContainer, VIDEO_CONTAINER_TYPE} from "./VideoContainer"; import {VideoContainer, VIDEO_CONTAINER_TYPE} from "./VideoContainer";
import LargeContainer from "./LargeContainer";
/** /**
* Manager for all Large containers. * Manager for all Large containers.
*/ */
export default class LargeVideoManager { export default class LargeVideoManager {
constructor (emitter) { constructor (emitter) {
/**
* The map of <tt>LargeContainer</tt>s where the key is the video
* container type.
* @type {Object.<string, LargeContainer>}
*/
this.containers = {}; this.containers = {};
this.state = VIDEO_CONTAINER_TYPE; this.state = VIDEO_CONTAINER_TYPE;
@ -85,21 +91,18 @@ export default class LargeVideoManager {
* Called when the media connection has been interrupted. * Called when the media connection has been interrupted.
*/ */
onVideoInterrupted () { onVideoInterrupted () {
this.enableVideoProblemFilter(true); this.enableLocalConnectionProblemFilter(true);
let reconnectingKey = "connection.RECONNECTING"; this._setLocalConnectionMessage("connection.RECONNECTING")
$('#videoConnectionMessage')
.attr("data-i18n", reconnectingKey)
.text(APP.translation.translateString(reconnectingKey));
// Show the message only if the video is currently being displayed // Show the message only if the video is currently being displayed
this.showVideoConnectionMessage(this.state === VIDEO_CONTAINER_TYPE); this.showLocalConnectionMessage(this.state === VIDEO_CONTAINER_TYPE);
} }
/** /**
* Called when the media connection has been restored. * Called when the media connection has been restored.
*/ */
onVideoRestored () { onVideoRestored () {
this.enableVideoProblemFilter(false); this.enableLocalConnectionProblemFilter(false);
this.showVideoConnectionMessage(false); this.showLocalConnectionMessage(false);
} }
get id () { get id () {
@ -118,7 +121,8 @@ export default class LargeVideoManager {
// Include hide()/fadeOut only if we're switching between users // Include hide()/fadeOut only if we're switching between users
let preUpdate; let preUpdate;
if (this.newStreamData.id != this.id) { let isUserSwitch = this.newStreamData.id != this.id;
if (isUserSwitch) {
preUpdate = container.hide(); preUpdate = container.hide();
} else { } else {
preUpdate = Promise.resolve(); preUpdate = Promise.resolve();
@ -136,27 +140,53 @@ export default class LargeVideoManager {
// change the avatar url on large // change the avatar url on large
this.updateAvatar(Avatar.getAvatarUrl(id)); this.updateAvatar(Avatar.getAvatarUrl(id));
// FIXME that does not really make sense, because the videoType
// (camera or desktop) is a completely different thing than
// the video container type (Etherpad, SharedVideo, VideoContainer).
// ----------------------------------------------------------------
// If we the continer is VIDEO_CONTAINER_TYPE, we need to check // If we the continer is VIDEO_CONTAINER_TYPE, we need to check
// its stream whether exist and is muted to set isVideoMuted // its stream whether exist and is muted to set isVideoMuted
// in rest of the cases it is false // in rest of the cases it is false
let isVideoMuted = false; let showAvatar = false;
if (videoType == VIDEO_CONTAINER_TYPE) if (videoType == VIDEO_CONTAINER_TYPE)
isVideoMuted = stream ? stream.isMuted() : true; showAvatar = stream ? stream.isMuted() : true;
// show the avatar on large if needed // If the user's connection is disrupted then the avatar will be
container.showAvatar(isVideoMuted); // displayed in case we have no video image cached. That is if
// there was a user switch(image is lost on stream detach) or if
// the video was not rendered, before the connection has failed.
let isHavingConnectivityIssues
= APP.conference.isParticipantConnectionActive(id) === false;
if (isHavingConnectivityIssues
&& (isUserSwitch | !container.wasVideoRendered)) {
showAvatar = true;
}
let promise; let promise;
// do not show stream if video is muted // do not show stream if video is muted
// but we still should show watermark // but we still should show watermark
if (isVideoMuted) { if (showAvatar) {
this.showWatermark(true); this.showWatermark(true);
promise = Promise.resolve(); // If the intention of this switch is to show the avatar
// we need to make sure that the video is hidden
promise = container.hide();
} else { } else {
promise = container.show(); promise = container.show();
} }
// show the avatar on large if needed
container.showAvatar(showAvatar);
// Make sure no notification about remote failure is shown as
// it's UI conflicts with the one for local connection interrupted.
if (APP.conference.isConnectionInterrupted()) {
this.updateParticipantConnStatusIndication(id, true);
} else {
this.updateParticipantConnStatusIndication(
id, !isHavingConnectivityIssues);
}
// resolve updateLargeVideo promise after everything is done // resolve updateLargeVideo promise after everything is done
promise.then(resolve); promise.then(resolve);
@ -169,6 +199,38 @@ export default class LargeVideoManager {
}); });
} }
/**
* Shows/hides notification about participant's connectivity issues to be
* shown on the large video area.
*
* @param {string} id the id of remote participant(MUC nickname)
* @param {boolean} isConnected true if the connection is active or false
* when the user is having connectivity issues.
*
* @private
*/
updateParticipantConnStatusIndication (id, isConnected) {
// Apply grey filter on the large video
this.videoContainer.showRemoteConnectionProblemIndicator(!isConnected);
if (isConnected) {
// Hide the message
this.showRemoteConnectionMessage(false);
} else {
// Get user's display name
let displayName
= APP.conference.getParticipantDisplayName(id);
this._setRemoteConnectionMessage(
"connection.USER_CONNECTION_INTERRUPTED",
{ displayName: displayName });
// Show it now only if the VideoContainer is on top
this.showRemoteConnectionMessage(
this.state === VIDEO_CONTAINER_TYPE);
}
}
/** /**
* Update large video. * Update large video.
* Switches to large video even if previously other container was visible. * Switches to large video even if previously other container was visible.
@ -229,12 +291,13 @@ export default class LargeVideoManager {
} }
/** /**
* Enables/disables the filter indicating a video problem to the user. * Enables/disables the filter indicating a video problem to the user caused
* by the problems with local media connection.
* *
* @param enable <tt>true</tt> to enable, <tt>false</tt> to disable * @param enable <tt>true</tt> to enable, <tt>false</tt> to disable
*/ */
enableVideoProblemFilter (enable) { enableLocalConnectionProblemFilter (enable) {
this.videoContainer.enableVideoProblemFilter(enable); this.videoContainer.enableLocalConnectionProblemFilter(enable);
} }
/** /**
@ -253,23 +316,87 @@ export default class LargeVideoManager {
} }
/** /**
* Shows/hides the "video connection message". * Shows/hides the message indicating problems with local media connection.
* @param {boolean|null} show(optional) tells whether the message is to be * @param {boolean|null} show(optional) tells whether the message is to be
* displayed or not. If missing the condition will be based on the value * displayed or not. If missing the condition will be based on the value
* obtained from {@link APP.conference.isConnectionInterrupted}. * obtained from {@link APP.conference.isConnectionInterrupted}.
*/ */
showVideoConnectionMessage (show) { showLocalConnectionMessage (show) {
if (typeof show !== 'boolean') { if (typeof show !== 'boolean') {
show = APP.conference.isConnectionInterrupted(); show = APP.conference.isConnectionInterrupted();
} }
if (show) { if (show) {
$('#videoConnectionMessage').css({display: "block"}); $('#localConnectionMessage').css({display: "block"});
// Avatar message conflicts with 'videoConnectionMessage',
// so it must be hidden
this.showRemoteConnectionMessage(false);
} else { } else {
$('#videoConnectionMessage').css({display: "none"}); $('#localConnectionMessage').css({display: "none"});
} }
} }
/**
* Shows hides the "avatar" message which is to be displayed either in
* the middle of the screen or below the avatar image.
*
* @param {null|boolean} show (optional) <tt>true</tt> to show the avatar
* message or <tt>false</tt> to hide it. If not provided then the connection
* status of the user currently on the large video will be obtained form
* "APP.conference" and the message will be displayed if the user's
* connection is interrupted.
*/
showRemoteConnectionMessage (show) {
if (typeof show !== 'boolean') {
show = APP.conference.isParticipantConnectionActive(this.id);
}
if (show) {
$('#remoteConnectionMessage').css({display: "block"});
// 'videoConnectionMessage' message conflicts with 'avatarMessage',
// so it must be hidden
this.showLocalConnectionMessage(false);
} else {
$('#remoteConnectionMessage').hide();
}
}
/**
* Updates the text which describes that the remote user is having
* connectivity issues.
*
* @param {string} msgKey the translation key which will be used to get
* the message text.
* @param {object} msgOptions translation options object.
*
* @private
*/
_setRemoteConnectionMessage (msgKey, msgOptions) {
if (msgKey) {
let text = APP.translation.translateString(msgKey, msgOptions);
$('#remoteConnectionMessage')
.attr("data-i18n", msgKey).text(text);
}
this.videoContainer.positionRemoteConnectionMessage();
}
/**
* Updated the text which is to be shown on the top of large video, when
* local media connection is interrupted.
*
* @param {string} msgKey the translation key which will be used to get
* the message text to be displayed on the large video.
* @param {object} msgOptions translation options object
*
* @private
*/
_setLocalConnectionMessage (msgKey, msgOptions) {
$('#localConnectionMessage')
.attr("data-i18n", msgKey)
.text(APP.translation.translateString(msgKey, msgOptions));
}
/** /**
* Add container of specified type. * Add container of specified type.
* @param {string} type container type * @param {string} type container type
@ -328,7 +455,8 @@ export default class LargeVideoManager {
// be taking care of it by itself, but that is a bigger refactoring // be taking care of it by itself, but that is a bigger refactoring
if (this.state === VIDEO_CONTAINER_TYPE) { if (this.state === VIDEO_CONTAINER_TYPE) {
this.showWatermark(false); this.showWatermark(false);
this.showVideoConnectionMessage(false); this.showLocalConnectionMessage(false);
this.showRemoteConnectionMessage(false);
} }
oldContainer.hide(); oldContainer.hide();
@ -342,7 +470,11 @@ export default class LargeVideoManager {
// the container would be taking care of it by itself, but that // the container would be taking care of it by itself, but that
// is a bigger refactoring // is a bigger refactoring
this.showWatermark(true); this.showWatermark(true);
this.showVideoConnectionMessage(/* fetch the current state */); // "avatar" and "video connection" can not be displayed both
// at the same time, but the latter is of higher priority and it
// will hide the avatar one if will be displayed.
this.showRemoteConnectionMessage(/* fet the current state */);
this.showLocalConnectionMessage(/* fetch the current state */);
} }
}); });
} }

View File

@ -201,7 +201,7 @@ LocalVideo.prototype.changeVideo = function (stream) {
localVideoContainer.removeChild(localVideo); localVideoContainer.removeChild(localVideo);
// when removing only the video element and we are on stage // when removing only the video element and we are on stage
// update the stage // update the stage
if(this.VideoLayout.isCurrentlyOnLarge(this.id)) if(this.isCurrentlyOnLargeVideo())
this.VideoLayout.updateLargeVideo(this.id); this.VideoLayout.updateLargeVideo(this.id);
stream.off(TrackEvents.LOCAL_TRACK_STOPPED, endedHandler); stream.off(TrackEvents.LOCAL_TRACK_STOPPED, endedHandler);
}; };

View File

@ -8,17 +8,45 @@ import UIUtils from "../util/UIUtil";
import UIEvents from '../../../service/UI/UIEvents'; import UIEvents from '../../../service/UI/UIEvents';
import JitsiPopover from "../util/JitsiPopover"; import JitsiPopover from "../util/JitsiPopover";
function RemoteVideo(id, VideoLayout, emitter) { /**
this.id = id; * Creates new instance of the <tt>RemoteVideo</tt>.
* @param user {JitsiParticipant} the user for whom remote video instance will
* be created.
* @param {VideoLayout} VideoLayout the video layout instance.
* @param {EventEmitter} emitter the event emitter which will be used by
* the new instance to emit events.
* @constructor
*/
function RemoteVideo(user, VideoLayout, emitter) {
this.user = user;
this.id = user.getId();
this.emitter = emitter; this.emitter = emitter;
this.videoSpanId = `participant_${id}`; this.videoSpanId = `participant_${this.id}`;
SmallVideo.call(this, VideoLayout); SmallVideo.call(this, VideoLayout);
this.hasRemoteVideoMenu = false; this.hasRemoteVideoMenu = false;
this.addRemoteVideoContainer(); this.addRemoteVideoContainer();
this.connectionIndicator = new ConnectionIndicator(this, id); this.connectionIndicator = new ConnectionIndicator(this, this.id);
this.setDisplayName(); this.setDisplayName();
this.flipX = false; this.flipX = false;
this.isLocal = false; this.isLocal = false;
/**
* The flag is set to <tt>true</tt> after the 'onplay' 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.wasVideoPlayed = false;
/**
* The flag is set to <tt>true</tt> if remote participant's video gets muted
* during his media connection disruption. This is to prevent black video
* being render on the thumbnail, because even though once the video has
* been played the image usually remains on the video element it seems that
* after longer period of the video element being hidden this image can be
* lost.
* @type {boolean}
*/
this.mutedWhileDisconnected = false;
} }
RemoteVideo.prototype = Object.create(SmallVideo.prototype); RemoteVideo.prototype = Object.create(SmallVideo.prototype);
@ -162,6 +190,33 @@ RemoteVideo.prototype.updateRemoteVideoMenu = function (isMuted, force) {
} }
}; };
/**
* @inheritDoc
*/
RemoteVideo.prototype.setMutedView = function(isMuted) {
SmallVideo.prototype.setMutedView.call(this, isMuted);
// Update 'mutedWhileDisconnected' flag
this._figureOutMutedWhileDisconnected(this.isConnectionActive() === false);
}
/**
* Figures out the value of {@link #mutedWhileDisconnected} flag by taking into
* account remote participant's network connectivity and video muted status.
*
* @param {boolean} isDisconnected <tt>true</tt> if the remote participant is
* currently having connectivity issues or <tt>false</tt> otherwise.
*
* @private
*/
RemoteVideo.prototype._figureOutMutedWhileDisconnected
= function(isDisconnected) {
if (isDisconnected && this.isVideoMuted) {
this.mutedWhileDisconnected = true;
} else if (!isDisconnected && !this.isVideoMuted) {
this.mutedWhileDisconnected = false;
}
}
/** /**
* Adds the remote video menu element for the given <tt>id</tt> in the * Adds the remote video menu element for the given <tt>id</tt> in the
* given <tt>parentElement</tt>. * given <tt>parentElement</tt>.
@ -209,13 +264,88 @@ RemoteVideo.prototype.removeRemoteStreamElement = function (stream) {
var select = $('#' + elementID); var select = $('#' + elementID);
select.remove(); select.remove();
if (isVideo) {
this.wasVideoPlayed = false;
}
console.info((isVideo ? "Video" : "Audio") + console.info((isVideo ? "Video" : "Audio") +
" removed " + this.id, select); " removed " + this.id, select);
// when removing only the video element and we are on stage // when removing only the video element and we are on stage
// update the stage // update the stage
if (isVideo && this.VideoLayout.isCurrentlyOnLarge(this.id)) if (isVideo && this.isCurrentlyOnLargeVideo())
this.VideoLayout.updateLargeVideo(this.id); this.VideoLayout.updateLargeVideo(this.id);
else
// Missing video stream will affect display mode
this.updateView();
};
/**
* Checks whether the remote user associated with this <tt>RemoteVideo</tt>
* has connectivity issues.
*
* @return {boolean} <tt>true</tt> if the user's connection is fine or
* <tt>false</tt> otherwise.
*/
RemoteVideo.prototype.isConnectionActive = function() {
return this.user.isConnectionActive();
};
/**
* The remote video is considered "playable" once the stream has started
* according to the {@link #hasVideoStarted} result.
*
* @inheritdoc
* @override
*/
RemoteVideo.prototype.isVideoPlayable = function () {
return SmallVideo.prototype.isVideoPlayable.call(this)
&& this.hasVideoStarted() && !this.mutedWhileDisconnected;
};
/**
* @inheritDoc
*/
RemoteVideo.prototype.updateView = function () {
this.updateConnectionStatusIndicator(
null /* will obtain the status from 'conference' */);
// This must be called after 'updateConnectionStatusIndicator' because it
// affects the display mode by modifying 'mutedWhileDisconnected' flag
SmallVideo.prototype.updateView.call(this);
};
/**
* Updates the UI to reflect user's connectivity status.
* @param isActive {boolean|null} 'true' if user's connection is active or
* 'false' when the use is having some connectivity issues and a warning
* should be displayed. When 'null' is passed then the current value will be
* obtained from the conference instance.
*/
RemoteVideo.prototype.updateConnectionStatusIndicator = function (isActive) {
// Check for initial value if 'isActive' is not defined
if (typeof isActive !== "boolean") {
isActive = this.isConnectionActive();
if (isActive === null) {
// Cancel processing at this point - no update
return;
}
}
console.debug(this.id + " thumbnail is connection active ? " + isActive);
// Update 'mutedWhileDisconnected' flag
this._figureOutMutedWhileDisconnected(!isActive);
if(this.connectionIndicator)
this.connectionIndicator.updateConnectionStatusIndicator(isActive);
// Toggle thumbnail video problem filter
this.selectVideoElement().toggleClass(
"videoThumbnailProblemFilter", !isActive);
this.$avatar().toggleClass(
"videoThumbnailProblemFilter", !isActive);
}; };
/** /**
@ -246,22 +376,23 @@ RemoteVideo.prototype.waitForPlayback = function (streamElement, stream) {
// Register 'onplaying' listener to trigger 'videoactive' on VideoLayout // Register 'onplaying' listener to trigger 'videoactive' on VideoLayout
// when video playback starts // when video playback starts
var onPlayingHandler = function () { var onPlayingHandler = function () {
self.wasVideoPlayed = true;
self.VideoLayout.videoactive(streamElement, self.id); self.VideoLayout.videoactive(streamElement, self.id);
streamElement.onplaying = null; streamElement.onplaying = null;
// Refresh to show the video
self.updateView();
}; };
streamElement.onplaying = onPlayingHandler; streamElement.onplaying = onPlayingHandler;
}; };
/** /**
* Checks whether or not video stream exists and has started for this * Checks whether the video stream has started for this RemoteVideo instance.
* RemoteVideo instance. This is checked by trying to select video element in
* this container and checking if 'currentTime' field's value is greater than 0.
* *
* @returns {*|boolean} true if this RemoteVideo has active video stream running * @returns {boolean} true if this RemoteVideo has a video stream for which
* the playback has been started.
*/ */
RemoteVideo.prototype.hasVideoStarted = function () { RemoteVideo.prototype.hasVideoStarted = function () {
var videoSelector = this.selectVideoElement(); return this.wasVideoPlayed;
return videoSelector.length && videoSelector[0].currentTime > 0;
}; };
RemoteVideo.prototype.addRemoteStreamElement = function (stream) { RemoteVideo.prototype.addRemoteStreamElement = function (stream) {

View File

@ -5,6 +5,27 @@ import UIEvents from "../../../service/UI/UIEvents";
const RTCUIHelper = JitsiMeetJS.util.RTCUIHelper; const RTCUIHelper = JitsiMeetJS.util.RTCUIHelper;
/**
* 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.
* @type {number}
* @constant
*/
const DISPLAY_BLACKNESS = 2;
function SmallVideo(VideoLayout) { function SmallVideo(VideoLayout) {
this.isAudioMuted = false; this.isAudioMuted = false;
this.hasAvatar = false; this.hasAvatar = false;
@ -337,6 +358,16 @@ SmallVideo.prototype.selectVideoElement = function () {
return $(RTCUIHelper.findVideoElement($('#' + this.videoSpanId)[0])); return $(RTCUIHelper.findVideoElement($('#' + this.videoSpanId)[0]));
}; };
/**
* Selects the HTML image element which displays user's avatar.
*
* @return {jQuery|HTMLElement} a jQuery selector pointing to the HTML image
* element which displays the user's avatar.
*/
SmallVideo.prototype.$avatar = function () {
return $('#' + this.videoSpanId + ' .userAvatar');
};
/** /**
* Enables / disables the css responsible for focusing/pinning a video * Enables / disables the css responsible for focusing/pinning a video
* thumbnail. * thumbnail.
@ -359,6 +390,47 @@ SmallVideo.prototype.hasVideo = function () {
return this.selectVideoElement().length !== 0; 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.
*/
SmallVideo.prototype.isCurrentlyOnLargeVideo = function () {
return this.VideoLayout.isCurrentlyOnLarge(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.
*/
SmallVideo.prototype.isVideoPlayable = function() {
return this.videoStream // Is there anything to display ?
&& !this.isVideoMuted && !this.videoStream.isMuted() // Muted ?
&& (this.isLocal || this.VideoLayout.isInLastN(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</tt>.
*/
SmallVideo.prototype.selectDisplayMode = function() {
// Display name is always and only displayed when user is on the stage
if (this.isCurrentlyOnLargeVideo()) {
return DISPLAY_BLACKNESS;
} else if (this.isVideoPlayable() && this.selectVideoElement().length) {
return DISPLAY_VIDEO;
} else {
return DISPLAY_AVATAR;
}
};
/** /**
* Hides or shows the user's avatar. * Hides or shows the user's avatar.
* This update assumes that large video had been updated and we will * This update assumes that large video had been updated and we will
@ -378,46 +450,28 @@ SmallVideo.prototype.updateView = function () {
} }
} }
let video = this.selectVideoElement(); // Determine whether video, avatar or blackness should be displayed
let displayMode = this.selectDisplayMode();
let avatar = $('#' + this.videoSpanId + ' .userAvatar'); // Show/hide video
setVisibility(this.selectVideoElement(), displayMode === DISPLAY_VIDEO);
var isCurrentlyOnLarge = this.VideoLayout.isCurrentlyOnLarge(this.id); // Show/hide the avatar
setVisibility(this.$avatar(), displayMode === DISPLAY_AVATAR);
var showVideo = !this.isVideoMuted && !isCurrentlyOnLarge;
var showAvatar;
if ((!this.isLocal
&& !this.VideoLayout.isInLastN(this.id))
|| this.isVideoMuted) {
showAvatar = true;
} else {
// We want to show the avatar when the video is muted or not exists
// that is when 'true' or 'null' is returned
showAvatar = !this.videoStream || this.videoStream.isMuted();
}
showAvatar = showAvatar && !isCurrentlyOnLarge;
if (video && video.length > 0) {
setVisibility(video, showVideo);
}
setVisibility(avatar, showAvatar);
}; };
SmallVideo.prototype.avatarChanged = function (avatarUrl) { SmallVideo.prototype.avatarChanged = function (avatarUrl) {
var thumbnail = $('#' + this.videoSpanId); var thumbnail = $('#' + this.videoSpanId);
var avatar = $('#' + this.videoSpanId + ' .userAvatar'); var avatarSel = this.$avatar();
this.hasAvatar = true; this.hasAvatar = true;
// set the avatar in the thumbnail // set the avatar in the thumbnail
if (avatar && avatar.length > 0) { if (avatarSel && avatarSel.length > 0) {
avatar[0].src = avatarUrl; avatarSel[0].src = avatarUrl;
} else { } else {
if (thumbnail && thumbnail.length > 0) { if (thumbnail && thumbnail.length > 0) {
avatar = document.createElement('img'); var avatarElement = document.createElement('img');
avatar.className = 'userAvatar'; avatarElement.className = 'userAvatar';
avatar.src = avatarUrl; avatarElement.src = avatarUrl;
thumbnail.append(avatar); thumbnail.append(avatarElement);
} }
} }
}; };

View File

@ -173,25 +173,51 @@ export class VideoContainer extends LargeContainer {
this.isVisible = false; this.isVisible = false;
/**
* Flag indicates whether or not the avatar is currently displayed.
* @type {boolean}
*/
this.avatarDisplayed = false;
this.$avatar = $('#dominantSpeaker'); this.$avatar = $('#dominantSpeaker');
/**
* A jQuery selector of the remote connection message.
* @type {jQuery|HTMLElement}
*/
this.$remoteConnectionMessage = $('#remoteConnectionMessage');
/**
* Indicates whether or not the video stream attached to the video
* element has started(which means that there is any image rendered
* even if the video is stalled).
* @type {boolean}
*/
this.wasVideoRendered = false;
this.$wrapper = $('#largeVideoWrapper'); this.$wrapper = $('#largeVideoWrapper');
this.avatarHeight = $("#dominantSpeakerAvatar").height(); this.avatarHeight = $("#dominantSpeakerAvatar").height();
var onPlayCallback = function (event) {
if (typeof onPlay === 'function') {
onPlay(event);
}
this.wasVideoRendered = true;
}.bind(this);
// This does not work with Temasys plugin - has to be a property to be // This does not work with Temasys plugin - has to be a property to be
// copied between new <object> elements // copied between new <object> elements
//this.$video.on('play', onPlay); //this.$video.on('play', onPlay);
this.$video[0].onplay = onPlay; this.$video[0].onplay = onPlayCallback;
} }
/** /**
* Enables a filter on the video which indicates that there are some * Enables a filter on the video which indicates that there are some
* problems with the media connection. * problems with the local media connection.
* *
* @param {boolean} enable <tt>true</tt> if the filter is to be enabled or * @param {boolean} enable <tt>true</tt> if the filter is to be enabled or
* <tt>false</tt> otherwise. * <tt>false</tt> otherwise.
*/ */
enableVideoProblemFilter (enable) { enableLocalConnectionProblemFilter (enable) {
this.$video.toggleClass("videoProblemFilter", enable); this.$video.toggleClass("videoProblemFilter", enable);
} }
@ -251,6 +277,30 @@ export class VideoContainer extends LargeContainer {
} }
} }
/**
* Update position of the remote connection message which describes that
* the remote user is having connectivity issues.
*/
positionRemoteConnectionMessage () {
if (this.avatarDisplayed) {
let $avatarImage = $("#dominantSpeakerAvatar");
this.$remoteConnectionMessage.css(
'top',
$avatarImage.offset().top + $avatarImage.height() + 10);
} else {
let height = this.$remoteConnectionMessage.height();
let parentHeight = this.$remoteConnectionMessage.parent().height();
this.$remoteConnectionMessage.css(
'top', (parentHeight/2) - (height/2));
}
let width = this.$remoteConnectionMessage.width();
let parentWidth = this.$remoteConnectionMessage.parent().width();
this.$remoteConnectionMessage.css(
'left', ((parentWidth/2) - (width/2)));
}
resize (containerWidth, containerHeight, animate = false) { resize (containerWidth, containerHeight, animate = false) {
let [width, height] let [width, height]
= this.getVideoSize(containerWidth, containerHeight); = this.getVideoSize(containerWidth, containerHeight);
@ -263,6 +313,8 @@ export class VideoContainer extends LargeContainer {
this.$avatar.css('top', top); this.$avatar.css('top', top);
this.positionRemoteConnectionMessage();
this.$wrapper.animate({ this.$wrapper.animate({
width: width, width: width,
height: height, height: height,
@ -284,6 +336,14 @@ export class VideoContainer extends LargeContainer {
* @param {string} videoType video type * @param {string} videoType video type
*/ */
setStream (stream, videoType) { setStream (stream, videoType) {
if (this.stream === stream) {
return;
} else {
// The stream has changed, so the image will be lost on detach
this.wasVideoRendered = false;
}
// detach old stream // detach old stream
if (this.stream) { if (this.stream) {
this.stream.detach(this.$video[0]); this.stream.detach(this.$video[0]);
@ -339,10 +399,23 @@ export class VideoContainer extends LargeContainer {
(show) ? interfaceConfig.DEFAULT_BACKGROUND : "#000"); (show) ? interfaceConfig.DEFAULT_BACKGROUND : "#000");
this.$avatar.css("visibility", show ? "visible" : "hidden"); this.$avatar.css("visibility", show ? "visible" : "hidden");
this.avatarDisplayed = show;
this.emitter.emit(UIEvents.LARGE_VIDEO_AVATAR_DISPLAYED, show); this.emitter.emit(UIEvents.LARGE_VIDEO_AVATAR_DISPLAYED, show);
} }
/**
* Indicates that the remote user who is currently displayed by this video
* container is having connectivity issues.
*
* @param {boolean} show <tt>true</tt> to show or <tt>false</tt> to hide
* the indication.
*/
showRemoteConnectionProblemIndicator (show) {
this.$video.toggleClass("remoteVideoProblemFilter", show);
this.$avatar.toggleClass("remoteVideoProblemFilter", show);
}
// We are doing fadeOut/fadeIn animations on parent div which wraps // We are doing fadeOut/fadeIn animations on parent div which wraps
// largeVideo, because when Temasys plugin is in use it replaces // largeVideo, because when Temasys plugin is in use it replaces
// <video> elements with plugin <object> tag. In Safari jQuery is // <video> elements with plugin <object> tag. In Safari jQuery is

View File

@ -384,18 +384,30 @@ var VideoLayout = {
}, },
/** /**
* Creates a participant container for the given id and smallVideo. * Creates or adds a participant container for the given id and smallVideo.
* *
* @param id the id of the participant to add * @param {JitsiParticipant} user the participant to add
* @param {SmallVideo} smallVideo optional small video instance to add as a * @param {SmallVideo} smallVideo optional small video instance to add as a
* remote video, if undefined RemoteVideo will be created * remote video, if undefined <tt>RemoteVideo</tt> will be created
*/ */
addParticipantContainer (id, smallVideo) { addParticipantContainer (user, smallVideo) {
let id = user.getId();
let remoteVideo; let remoteVideo;
if(smallVideo) if(smallVideo)
remoteVideo = smallVideo; remoteVideo = smallVideo;
else else
remoteVideo = new RemoteVideo(id, VideoLayout, eventEmitter); remoteVideo = new RemoteVideo(user, VideoLayout, eventEmitter);
this.addRemoteVideoContainer(id, remoteVideo);
},
/**
* 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; remoteVideos[id] = remoteVideo;
let videoType = VideoLayout.getRemoteVideoType(id); let videoType = VideoLayout.getRemoteVideoType(id);
@ -413,6 +425,8 @@ var VideoLayout = {
} else { } else {
VideoLayout.resizeThumbnails(false, true); VideoLayout.resizeThumbnails(false, true);
} }
// Initialize the view
remoteVideo.updateView();
}, },
videoactive (videoelem, resourceJid) { videoactive (videoelem, resourceJid) {
@ -487,6 +501,18 @@ var VideoLayout = {
localVideoThumbnail.showAudioIndicator(isMuted); localVideoThumbnail.showAudioIndicator(isMuted);
}, },
/**
* Shows/hides the indication about local connection being interrupted.
*
* @param {boolean} isInterrupted <tt>true</tt> if local connection is
* currently in the interrupted state or <tt>false</tt> if the connection
* is fine.
*/
showLocalConnectionInterrupted (isInterrupted) {
localVideoThumbnail.connectionIndicator
.updateConnectionStatusIndicator(!isInterrupted);
},
/** /**
* Resizes thumbnails. * Resizes thumbnails.
*/ */
@ -618,6 +644,35 @@ var VideoLayout = {
} }
}, },
/**
* Shows/hides warning about remote user's connectivity issues.
*
* @param {string} id the ID of the remote participant(MUC nickname)
* @param {boolean} isActive true if the connection is ok or false when
* the user is having connectivity issues.
*/
onParticipantConnectionStatusChanged (id, isActive) {
// Show/hide warning on the large video
if (this.isCurrentlyOnLarge(id)) {
if (largeVideo) {
// We have to trigger full large video update to transition from
// avatar to video on connectivity restored.
this.updateLargeVideo(id, true /* force update */);
}
}
// Show/hide warning on the thumbnail
let remoteVideo = remoteVideos[id];
if (remoteVideo) {
// Updating only connection status indicator is not enough, because
// when we the connection is restored while the avatar was displayed
// (due to 'muted while disconnected' condition) we may want to show
// the video stream again and in order to do that the display mode
// must be updated.
//remoteVideo.updateConnectionStatusIndicator(isActive);
remoteVideo.updateView();
}
},
/** /**
* On last N change event. * On last N change event.
* *