diff --git a/conference.js b/conference.js index ed346eca2..c06c19181 100644 --- a/conference.js +++ b/conference.js @@ -691,6 +691,51 @@ export default { isConnectionInterrupted () { 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 JitsiParticipant identified by + * the given id. + * + * @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 () { return this._room && this._room.myUserId(); @@ -1085,7 +1130,7 @@ export default { console.log('USER %s connnected', id, user); APP.API.notifyUserJoined(id); - APP.UI.addUser(id, user.getDisplayName()); + APP.UI.addUser(user); // check the roles for the new user and reflect them APP.UI.updateUserRole(user); @@ -1174,6 +1219,10 @@ export default { ConferenceEvents.LAST_N_ENDPOINTS_CHANGED, (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) => { if (this.isLocalId(id)) { this.isDominantSpeaker = true; @@ -1205,10 +1254,12 @@ export default { room.on(ConferenceEvents.CONNECTION_INTERRUPTED, () => { connectionIsInterrupted = true; ConnectionQuality.updateLocalConnectionQuality(0); + APP.UI.showLocalConnectionInterrupted(true); }); room.on(ConferenceEvents.CONNECTION_RESTORED, () => { connectionIsInterrupted = false; + APP.UI.showLocalConnectionInterrupted(false); }); room.on(ConferenceEvents.DISPLAY_NAME_CHANGED, (id, displayName) => { diff --git a/css/_videolayout_default.scss b/css/_videolayout_default.scss index 9a276de2c..b5346e729 100644 --- a/css/_videolayout_default.scss +++ b/css/_videolayout_default.scss @@ -233,6 +233,12 @@ overflow: hidden; } +.connection.connection_lost +{ + color: #8B8B8B; + overflow: visible; +} + .connection.connection_full { color: #FFFFFF;/*#15A1ED*/ @@ -456,12 +462,44 @@ filter: grayscale(.5) opacity(0.8); } +.remoteVideoProblemFilter { + -webkit-filter: grayscale(100%); + filter: grayscale(100%); +} + .videoProblemFilter { -webkit-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; position: absolute; width: 100%; diff --git a/index.html b/index.html index 48c8e1e3a..760cfd276 100644 --- a/index.html +++ b/index.html @@ -228,10 +228,11 @@ +
- + HD diff --git a/lang/main.json b/lang/main.json index 0e5f69792..d891fb625 100644 --- a/lang/main.json +++ b/lang/main.json @@ -324,7 +324,8 @@ "ATTACHED": "Attached", "FETCH_SESSION_ID": "Obtaining session-id...", "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": { diff --git a/modules/UI/UI.js b/modules/UI/UI.js index eae77ddb8..bbd62e25f 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -261,6 +261,17 @@ UI.changeDisplayName = function (id, displayName) { } }; +/** + * Shows/hides the indication about local connection being interrupted. + * + * @param {boolean} isInterrupted true if local connection is + * currently in the interrupted state or false if the connection + * is fine. + */ +UI.showLocalConnectionInterrupted = function (isInterrupted) { + VideoLayout.showLocalConnectionInterrupted(isInterrupted); +}; + /** * Sets the "raised hand" status for a participant. */ @@ -602,10 +613,11 @@ UI.getSharedDocumentManager = function () { /** * Show user on UI. - * @param {string} id user id - * @param {string} displayName user nickname + * @param {JitsiParticipant} user */ -UI.addUser = function (id, displayName) { +UI.addUser = function (user) { + var id = user.getId(); + var displayName = user.getDisplayName(); UI.hideRingOverLay(); ContactList.addContact(id); @@ -618,7 +630,7 @@ UI.addUser = function (id, displayName) { UIUtil.playSoundNotification('userJoined'); // Add Peer's container - VideoLayout.addParticipantContainer(id); + VideoLayout.addParticipantContainer(user); // Configure avatar UI.setUserEmail(id); @@ -983,6 +995,17 @@ UI.handleLastNEndpoints = function (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. * @param {string} id user id diff --git a/modules/UI/shared_video/SharedVideo.js b/modules/UI/shared_video/SharedVideo.js index 92e153190..3d777c6ed 100644 --- a/modules/UI/shared_video/SharedVideo.js +++ b/modules/UI/shared_video/SharedVideo.js @@ -243,7 +243,7 @@ export default class SharedVideoManager { let thumb = new SharedVideoThumb(self.url); thumb.setDisplayName(player.getVideoData().title); - VideoLayout.addParticipantContainer(self.url, thumb); + VideoLayout.addRemoteVideoContainer(self.url, thumb); let iframe = player.getIframe(); self.sharedVideo = new SharedVideoContainer( diff --git a/modules/UI/videolayout/ConnectionIndicator.js b/modules/UI/videolayout/ConnectionIndicator.js index ff5eb9b61..ddcd65d4f 100644 --- a/modules/UI/videolayout/ConnectionIndicator.js +++ b/modules/UI/videolayout/ConnectionIndicator.js @@ -245,13 +245,13 @@ ConnectionIndicator.prototype.showMore = function () { }; -function createIcon(classes) { +function createIcon(classes, iconClass) { var icon = document.createElement("span"); for(var i in classes) { icon.classList.add(classes[i]); } icon.appendChild( - document.createElement("i")).classList.add("icon-connection"); + document.createElement("i")).classList.add(iconClass); return icon; } @@ -282,9 +282,12 @@ ConnectionIndicator.prototype.create = function () { }.bind(this); this.emptyIcon = this.connectionIndicatorContainer.appendChild( - createIcon(["connection", "connection_empty"])); + createIcon(["connection", "connection_empty"], "icon-connection")); 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(); }; +/** + * 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 * @param percent the percent of connection quality @@ -314,12 +338,14 @@ ConnectionIndicator.prototype.updateConnectionQuality = this.connectionIndicatorContainer.style.display = "block"; } } - this.bandwidth = object.bandwidth; - this.bitrate = object.bitrate; - this.packetLoss = object.packetLoss; - this.transport = object.transport; - if (object.resolution) { - this.resolution = object.resolution; + if (object) { + this.bandwidth = object.bandwidth; + this.bitrate = object.bitrate; + this.packetLoss = object.packetLoss; + this.transport = object.transport; + if (object.resolution) { + this.resolution = object.resolution; + } } for (var quality in ConnectionIndicator.connectionQualityValues) { if (percent >= quality) { @@ -327,7 +353,7 @@ ConnectionIndicator.prototype.updateConnectionQuality = ConnectionIndicator.connectionQualityValues[quality]; } } - if (object.isResolutionHD) { + if (object && typeof object.isResolutionHD === 'boolean') { this.isResolutionHD = object.isResolutionHD; } this.updateResolutionIndicator(); diff --git a/modules/UI/videolayout/LargeVideoManager.js b/modules/UI/videolayout/LargeVideoManager.js index e0934ee74..4a342f1d2 100644 --- a/modules/UI/videolayout/LargeVideoManager.js +++ b/modules/UI/videolayout/LargeVideoManager.js @@ -5,12 +5,18 @@ import Avatar from "../avatar/Avatar"; import {createDeferred} from '../../util/helpers'; import UIUtil from "../util/UIUtil"; import {VideoContainer, VIDEO_CONTAINER_TYPE} from "./VideoContainer"; +import LargeContainer from "./LargeContainer"; /** * Manager for all Large containers. */ export default class LargeVideoManager { constructor (emitter) { + /** + * The map of LargeContainers where the key is the video + * container type. + * @type {Object.} + */ this.containers = {}; this.state = VIDEO_CONTAINER_TYPE; @@ -85,21 +91,18 @@ export default class LargeVideoManager { * Called when the media connection has been interrupted. */ onVideoInterrupted () { - this.enableVideoProblemFilter(true); - let reconnectingKey = "connection.RECONNECTING"; - $('#videoConnectionMessage') - .attr("data-i18n", reconnectingKey) - .text(APP.translation.translateString(reconnectingKey)); + this.enableLocalConnectionProblemFilter(true); + this._setLocalConnectionMessage("connection.RECONNECTING") // 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. */ onVideoRestored () { - this.enableVideoProblemFilter(false); - this.showVideoConnectionMessage(false); + this.enableLocalConnectionProblemFilter(false); + this.showLocalConnectionMessage(false); } get id () { @@ -118,7 +121,8 @@ export default class LargeVideoManager { // Include hide()/fadeOut only if we're switching between users let preUpdate; - if (this.newStreamData.id != this.id) { + let isUserSwitch = this.newStreamData.id != this.id; + if (isUserSwitch) { preUpdate = container.hide(); } else { preUpdate = Promise.resolve(); @@ -136,27 +140,53 @@ export default class LargeVideoManager { // change the avatar url on large 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 // its stream whether exist and is muted to set isVideoMuted // in rest of the cases it is false - let isVideoMuted = false; + let showAvatar = false; if (videoType == VIDEO_CONTAINER_TYPE) - isVideoMuted = stream ? stream.isMuted() : true; + showAvatar = stream ? stream.isMuted() : true; - // show the avatar on large if needed - container.showAvatar(isVideoMuted); + // If the user's connection is disrupted then the avatar will be + // 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; // do not show stream if video is muted // but we still should show watermark - if (isVideoMuted) { + if (showAvatar) { 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 { 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 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. * 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 true to enable, false to disable */ - enableVideoProblemFilter (enable) { - this.videoContainer.enableVideoProblemFilter(enable); + enableLocalConnectionProblemFilter (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 * displayed or not. If missing the condition will be based on the value * obtained from {@link APP.conference.isConnectionInterrupted}. */ - showVideoConnectionMessage (show) { + showLocalConnectionMessage (show) { if (typeof show !== 'boolean') { show = APP.conference.isConnectionInterrupted(); } if (show) { - $('#videoConnectionMessage').css({display: "block"}); + $('#localConnectionMessage').css({display: "block"}); + // Avatar message conflicts with 'videoConnectionMessage', + // so it must be hidden + this.showRemoteConnectionMessage(false); } 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) true to show the avatar + * message or false 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. * @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 if (this.state === VIDEO_CONTAINER_TYPE) { this.showWatermark(false); - this.showVideoConnectionMessage(false); + this.showLocalConnectionMessage(false); + this.showRemoteConnectionMessage(false); } oldContainer.hide(); @@ -342,7 +470,11 @@ export default class LargeVideoManager { // the container would be taking care of it by itself, but that // is a bigger refactoring 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 */); } }); } diff --git a/modules/UI/videolayout/LocalVideo.js b/modules/UI/videolayout/LocalVideo.js index 9c2111e4e..1a070652b 100644 --- a/modules/UI/videolayout/LocalVideo.js +++ b/modules/UI/videolayout/LocalVideo.js @@ -201,7 +201,7 @@ LocalVideo.prototype.changeVideo = function (stream) { localVideoContainer.removeChild(localVideo); // when removing only the video element and we are on stage // update the stage - if(this.VideoLayout.isCurrentlyOnLarge(this.id)) + if(this.isCurrentlyOnLargeVideo()) this.VideoLayout.updateLargeVideo(this.id); stream.off(TrackEvents.LOCAL_TRACK_STOPPED, endedHandler); }; diff --git a/modules/UI/videolayout/RemoteVideo.js b/modules/UI/videolayout/RemoteVideo.js index 52a769c15..6ae3a1657 100644 --- a/modules/UI/videolayout/RemoteVideo.js +++ b/modules/UI/videolayout/RemoteVideo.js @@ -8,17 +8,45 @@ import UIUtils from "../util/UIUtil"; import UIEvents from '../../../service/UI/UIEvents'; import JitsiPopover from "../util/JitsiPopover"; -function RemoteVideo(id, VideoLayout, emitter) { - this.id = id; +/** + * Creates new instance of the RemoteVideo. + * @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.videoSpanId = `participant_${id}`; + this.videoSpanId = `participant_${this.id}`; SmallVideo.call(this, VideoLayout); this.hasRemoteVideoMenu = false; this.addRemoteVideoContainer(); - this.connectionIndicator = new ConnectionIndicator(this, id); + this.connectionIndicator = new ConnectionIndicator(this, this.id); this.setDisplayName(); this.flipX = false; this.isLocal = false; + /** + * The flag is set to true after the 'onplay' event has been + * triggered on the current video element. It goes back to false + * 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 true 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); @@ -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 true if the remote participant is + * currently having connectivity issues or false 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 id in the * given parentElement. @@ -209,13 +264,88 @@ RemoteVideo.prototype.removeRemoteStreamElement = function (stream) { var select = $('#' + elementID); select.remove(); + if (isVideo) { + this.wasVideoPlayed = false; + } + console.info((isVideo ? "Video" : "Audio") + " removed " + this.id, select); // when removing only the video element and we are on stage // update the stage - if (isVideo && this.VideoLayout.isCurrentlyOnLarge(this.id)) + if (isVideo && this.isCurrentlyOnLargeVideo()) this.VideoLayout.updateLargeVideo(this.id); + else + // Missing video stream will affect display mode + this.updateView(); +}; + +/** + * Checks whether the remote user associated with this RemoteVideo + * has connectivity issues. + * + * @return {boolean} true if the user's connection is fine or + * false 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 // when video playback starts var onPlayingHandler = function () { + self.wasVideoPlayed = true; self.VideoLayout.videoactive(streamElement, self.id); streamElement.onplaying = null; + // Refresh to show the video + self.updateView(); }; streamElement.onplaying = onPlayingHandler; }; /** - * Checks whether or not video stream exists and has started for this - * 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. + * Checks whether the video stream has started for this RemoteVideo instance. * - * @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 () { - var videoSelector = this.selectVideoElement(); - return videoSelector.length && videoSelector[0].currentTime > 0; + return this.wasVideoPlayed; }; RemoteVideo.prototype.addRemoteStreamElement = function (stream) { diff --git a/modules/UI/videolayout/SmallVideo.js b/modules/UI/videolayout/SmallVideo.js index 7b7192fae..65a433469 100644 --- a/modules/UI/videolayout/SmallVideo.js +++ b/modules/UI/videolayout/SmallVideo.js @@ -5,6 +5,27 @@ import UIEvents from "../../../service/UI/UIEvents"; 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) { this.isAudioMuted = false; this.hasAvatar = false; @@ -337,6 +358,16 @@ SmallVideo.prototype.selectVideoElement = function () { 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 * thumbnail. @@ -359,6 +390,47 @@ SmallVideo.prototype.hasVideo = function () { return this.selectVideoElement().length !== 0; }; +/** + * Checks whether the user associated with this SmallVideo is currently + * being displayed on the "large video". + * + * @return {boolean} true if the user is displayed on the large video + * or false 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 SmallVideo. + * + * @return {boolean} true if there is a playable video stream available + * or false 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 DISPLAY_VIDEO,DISPLAY_AVATAR + * or DISPLAY_BLACKNESS. + */ +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. * This update assumes that large video had been updated and we will @@ -378,46 +450,28 @@ SmallVideo.prototype.updateView = function () { } } - let video = this.selectVideoElement(); - - let avatar = $('#' + this.videoSpanId + ' .userAvatar'); - - var isCurrentlyOnLarge = this.VideoLayout.isCurrentlyOnLarge(this.id); - - 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); + // Determine whether video, avatar or blackness should be displayed + let displayMode = this.selectDisplayMode(); + // Show/hide video + setVisibility(this.selectVideoElement(), displayMode === DISPLAY_VIDEO); + // Show/hide the avatar + setVisibility(this.$avatar(), displayMode === DISPLAY_AVATAR); }; SmallVideo.prototype.avatarChanged = function (avatarUrl) { var thumbnail = $('#' + this.videoSpanId); - var avatar = $('#' + this.videoSpanId + ' .userAvatar'); + var avatarSel = this.$avatar(); this.hasAvatar = true; // set the avatar in the thumbnail - if (avatar && avatar.length > 0) { - avatar[0].src = avatarUrl; + if (avatarSel && avatarSel.length > 0) { + avatarSel[0].src = avatarUrl; } else { if (thumbnail && thumbnail.length > 0) { - avatar = document.createElement('img'); - avatar.className = 'userAvatar'; - avatar.src = avatarUrl; - thumbnail.append(avatar); + var avatarElement = document.createElement('img'); + avatarElement.className = 'userAvatar'; + avatarElement.src = avatarUrl; + thumbnail.append(avatarElement); } } }; diff --git a/modules/UI/videolayout/VideoContainer.js b/modules/UI/videolayout/VideoContainer.js index 2043344c2..5ebfe979c 100644 --- a/modules/UI/videolayout/VideoContainer.js +++ b/modules/UI/videolayout/VideoContainer.js @@ -173,25 +173,51 @@ export class VideoContainer extends LargeContainer { this.isVisible = false; + /** + * Flag indicates whether or not the avatar is currently displayed. + * @type {boolean} + */ + this.avatarDisplayed = false; 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.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 // copied between new elements //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 - * problems with the media connection. + * problems with the local media connection. * * @param {boolean} enable true if the filter is to be enabled or * false otherwise. */ - enableVideoProblemFilter (enable) { + enableLocalConnectionProblemFilter (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) { let [width, height] = this.getVideoSize(containerWidth, containerHeight); @@ -263,6 +313,8 @@ export class VideoContainer extends LargeContainer { this.$avatar.css('top', top); + this.positionRemoteConnectionMessage(); + this.$wrapper.animate({ width: width, height: height, @@ -284,6 +336,14 @@ export class VideoContainer extends LargeContainer { * @param {string} videoType video type */ 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 if (this.stream) { this.stream.detach(this.$video[0]); @@ -339,10 +399,23 @@ export class VideoContainer extends LargeContainer { (show) ? interfaceConfig.DEFAULT_BACKGROUND : "#000"); this.$avatar.css("visibility", show ? "visible" : "hidden"); + this.avatarDisplayed = 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 true to show or false 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 // largeVideo, because when Temasys plugin is in use it replaces //