diff --git a/conference.js b/conference.js index 9fe20db93..497e673cf 100644 --- a/conference.js +++ b/conference.js @@ -78,6 +78,7 @@ import { import { getLocationContextRoot } from './react/features/base/util'; import { statsEmitter } from './react/features/connection-indicator'; import { showDesktopPicker } from './react/features/desktop-picker'; +import { appendSuffix } from './react/features/display-name'; import { maybeOpenFeedbackDialog } from './react/features/feedback'; import { mediaPermissionPromptVisibilityChanged, @@ -1726,15 +1727,20 @@ export default { if (user.isHidden()) { return; } + const displayName = user.getDisplayName(); APP.store.dispatch(participantJoined({ id, - name: user.getDisplayName(), + name: displayName, role: user.getRole() })); logger.log('USER %s connnected', id, user); - APP.API.notifyUserJoined(id); + APP.API.notifyUserJoined(id, { + displayName, + formattedDisplayName: appendSuffix( + displayName || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME) + }); APP.UI.addUser(user); // check the roles for the new user and reflect them @@ -1892,6 +1898,7 @@ export default { } APP.UI.addListener(UIEvents.SELECTED_ENDPOINT, id => { + APP.API.notifyOnStageParticipantChanged(id); try { // do not try to select participant if there is none (we // are alone in the room), otherwise an error will be @@ -1938,7 +1945,13 @@ export default { id, name: formattedDisplayName })); - APP.API.notifyDisplayNameChanged(id, formattedDisplayName); + APP.API.notifyDisplayNameChanged(id, { + displayName: formattedDisplayName, + formattedDisplayName: + appendSuffix( + formattedDisplayName + || interfaceConfig.DEFAULT_REMOTE_DISPLAY_NAME) + }); APP.UI.changeDisplayName(id, formattedDisplayName); } ); @@ -2377,7 +2390,19 @@ export default { APP.store.dispatch(conferenceJoined(room)); APP.UI.mucJoined(); - APP.API.notifyConferenceJoined(APP.conference.roomName); + const displayName = APP.settings.getDisplayName(); + + APP.API.notifyConferenceJoined( + this.roomName, + this._room.myUserId(), + { + displayName, + formattedDisplayName: appendSuffix( + displayName, + interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME), + avatarURL: APP.UI.getAvatarUrl() + } + ); APP.UI.markVideoInterrupted(false); }, @@ -2748,6 +2773,14 @@ export default { })); APP.settings.setDisplayName(formattedNickname); + APP.API.notifyDisplayNameChanged(id, { + displayName: formattedNickname, + formattedDisplayName: + appendSuffix( + formattedNickname, + interfaceConfig.DEFAULT_LOCAL_DISPLAY_NAME) + }); + if (room) { room.setDisplayName(formattedNickname); APP.UI.changeDisplayName(id, formattedNickname); diff --git a/css/_videolayout_default.scss b/css/_videolayout_default.scss index 352ece5c2..6595286a8 100644 --- a/css/_videolayout_default.scss +++ b/css/_videolayout_default.scss @@ -250,6 +250,7 @@ /** * Positions video thumbnail display name and editor. */ +#alwaysOnTop .displayname, .videocontainer .displayname, .videocontainer .editdisplayname { display: inline-block; @@ -269,6 +270,15 @@ z-index: $zindex2; } +#alwaysOnTop .displayname { + font-size: 15px; + position: inherit; + width: 100%; + left: 0px; + top: 0px; + margin-top: 10px; +} + /** * Positions video thumbnail display name editor. */ @@ -507,6 +517,20 @@ width: auto; } +#videoNotAvailableScreen { + text-align: center; + #avatarContainer { + height: 50vh; + display:inline-block; + margin-top: 25vh; + + #avatar { + border-radius: 50%; + height: 100%; + } + } +} + .sharedVideoAvatar { height: 100%; width: 100%; diff --git a/doc/api.md b/doc/api.md index ce1f34f70..b811ab3ed 100644 --- a/doc/api.md +++ b/doc/api.md @@ -140,18 +140,26 @@ The `event` parameter is a String object with the name of the event. The `listener` parameter is a Function object with one argument that will be notified when the event occurs with data related to the event. The following events are currently supported: +* **avatarChanged** - event notifications about avatar +changes. The listener will receive an object with the following structure: +```javascript +{ +"id": id, // the id of the participant that changed his avatar. +"avatarURL": avatarURL // the new avatar URL. +} +``` * **audioAvailabilityChanged** - event notifications about audio availability status changes. The listener will receive an object with the following structure: ```javascript { -"available": available // new available status - boolean +"available": available // new available status - boolean } ``` * **audioMuteStatusChanged** - event notifications about audio mute status changes. The listener will receive an object with the following structure: ```javascript { -"muted": muted // new muted status - boolean +"muted": muted // new muted status - boolean } ``` @@ -159,9 +167,9 @@ The following events are currently supported: messages. The listener will receive an object with the following structure: ```javascript { -"from": from, // JID of the user that sent the message -"nick": nick, // the nickname of the user that sent the message -"message": txt // the text of the message +"from": from, // The id of the user that sent the message +"nick": nick, // the nickname of the user that sent the message +"message": txt // the text of the message } ``` @@ -169,7 +177,7 @@ messages. The listener will receive an object with the following structure: messages. The listener will receive an object with the following structure: ```javascript { -"message": txt // the text of the message +"message": txt // the text of the message } ``` @@ -177,50 +185,54 @@ messages. The listener will receive an object with the following structure: changes. The listener will receive an object with the following structure: ```javascript { -"jid": jid, // the JID of the participant that changed his display name -"displayname": displayName // the new display name +"id": id, // the id of the participant that changed his display name +"displayname": displayName // the new display name } ``` * **participantJoined** - event notifications about new participants who join the room. The listener will receive an object with the following structure: ```javascript { -"jid": jid // the JID of the participant +"id": id, // the id of the participant +"displayName": displayName // the display name of the participant } ``` * **participantLeft** - event notifications about participants that leave the room. The listener will receive an object with the following structure: ```javascript { -"jid": jid // the JID of the participant +"id": id // the id of the participant } ``` * **videoConferenceJoined** - event notifications fired when the local user has joined the video conference. The listener will receive an object with the following structure: ```javascript { -"roomName": room // the room name of the conference +"roomName": room, // the room name of the conference +"id": id, // the id of the local participant +"displayName": displayName, // the display name of the local participant +"avatarURL": avatarURL // the avatar URL of the local participant } ``` * **videoConferenceLeft** - event notifications fired when the local user has left the video conference. The listener will receive an object with the following structure: ```javascript { -"roomName": room // the room name of the conference +"roomName": room // the room name of the conference } ``` * **videoAvailabilityChanged** - event notifications about video availability status changes. The listener will receive an object with the following structure: ```javascript { -"available": available // new available status - boolean +"available": available // new available status - boolean } ``` * **videoMuteStatusChanged** - event notifications about video mute status changes. The listener will receive an object with the following structure: ```javascript { -"muted": muted // new muted status - boolean +"muted": muted // new muted status - boolean } ``` @@ -264,6 +276,16 @@ You can get the number of participants in the conference with the following API var numberOfParticipants = api.getNumberOfParticipants(); ``` +You can get the avatar URL of a participant in the conference with the following API function: +```javascript +var avatarURL = api.getAvatarURL(participantId); +``` + +You can get the display name of a participant in the conference with the following API function: +```javascript +var displayName = api.getDisplayName(participantId); +``` + You can get the iframe HTML element where Jitsi Meet is loaded with the following API function: ```javascript var iframe = api.getIFrame(); diff --git a/modules/API/API.js b/modules/API/API.js index b8a0e90c9..216158eb8 100644 --- a/modules/API/API.js +++ b/modules/API/API.js @@ -184,6 +184,21 @@ class API { initCommands(); } + /** + * Notify external application (if API is enabled) that the large video + * visibility changed. + * + * @param {boolean} isHidden - True if the large video is hidden and false + * otherwise. + * @returns {void} + */ + notifyLargeVideoVisibilityChanged(isHidden: boolean) { + this._sendEvent({ + name: 'large-video-visibility-changed', + isVisible: !isHidden + }); + } + /** * Sends event to the external application. * @@ -238,12 +253,14 @@ class API { * conference. * * @param {string} id - User id. + * @param {Object} props - The display name of the user. * @returns {void} */ - notifyUserJoined(id: string) { + notifyUserJoined(id: string, props: Object) { this._sendEvent({ name: 'participant-joined', - id + id, + ...props }); } @@ -261,18 +278,39 @@ class API { }); } + /** + * Notify external application (if API is enabled) that user changed their + * avatar. + * + * @param {string} id - User id. + * @param {string} avatarURL - The new avatar URL of the participant. + * @returns {void} + */ + notifyAvatarChanged(id: string, avatarURL: string) { + this._sendEvent({ + name: 'avatar-changed', + avatarURL, + id + }); + } + /** * Notify external application (if API is enabled) that user changed their * nickname. * * @param {string} id - User id. * @param {string} displayname - User nickname. + * @param {string} formattedDisplayName - The display name shown in Jitsi + * meet's UI for the user. * @returns {void} */ - notifyDisplayNameChanged(id: string, displayname: string) { + notifyDisplayNameChanged( + id: string, + { displayName, formattedDisplayName }: Object) { this._sendEvent({ name: 'display-name-change', - displayname, + displayname: displayName, + formattedDisplayName, id }); } @@ -282,12 +320,17 @@ class API { * been joined. * * @param {string} roomName - The room name. + * @param {string} id - The id of the local user. + * @param {Object} props - The display name and avatar URL of the local + * user. * @returns {void} */ - notifyConferenceJoined(roomName: string) { + notifyConferenceJoined(roomName: string, id: string, props: Object) { this._sendEvent({ name: 'video-conference-joined', - roomName + roomName, + id, + ...props }); } @@ -373,6 +416,20 @@ class API { }); } + /** + * Notify external application (if API is enabled) that the on stage + * participant has changed. + * + * @param {string} id - User id of the new on stage participant. + * @returns {void} + */ + notifyOnStageParticipantChanged(id: string) { + this._sendEvent({ + name: 'on-stage-participant-changed', + id + }); + } + /** * Disposes the allocated resources. diff --git a/modules/API/external/external_api.js b/modules/API/external/external_api.js index 962edda2a..1a3a4a53a 100644 --- a/modules/API/external/external_api.js +++ b/modules/API/external/external_api.js @@ -34,6 +34,7 @@ const commands = { * events expected by jitsi-meet */ const events = { + 'avatar-changed': 'avatarChanged', 'audio-availability-changed': 'audioAvailabilityChanged', 'audio-mute-status-changed': 'audioMuteStatusChanged', 'display-name-change': 'displayNameChange', @@ -224,7 +225,11 @@ export default class JitsiMeetExternalAPI extends EventEmitter { } }) }); - this._numberOfParticipants = 1; + this._isLargeVideoVisible = true; + this._numberOfParticipants = 0; + this._participants = {}; + this._myUserID = undefined; + this._onStageParticipant = undefined; this._setupListeners(); id++; } @@ -278,6 +283,34 @@ export default class JitsiMeetExternalAPI extends EventEmitter { ); } + /** + * Returns the id of the on stage participant. + * + * @returns {string} - The id of the on stage participant. + */ + _getOnStageParticipant() { + return this._onStageParticipant; + } + + + /** + * Getter for the large video element in Jitsi Meet. + * + * @returns {HTMLElement|undefined} - The large video. + */ + _getLargeVideo() { + const iframe = this.getIFrame(); + + if (!this._isLargeVideoVisible + || !iframe + || !iframe.contentWindow + || !iframe.contentWindow.document) { + return; + } + + return iframe.contentWindow.document.getElementById('largeVideo'); + } + /** * Sets the size of the iframe element. * @@ -308,12 +341,58 @@ export default class JitsiMeetExternalAPI extends EventEmitter { * @private */ _setupListeners() { - this._transport.on('event', ({ name, ...data }) => { - if (name === 'participant-joined') { + const userID = data.id; + + switch (name) { + case 'video-conference-joined': + this._myUserID = userID; + this._participants[userID] = { + avatarURL: data.avatarURL + }; + + // eslint-disable-next-line no-fallthrough + case 'participant-joined': { + this._participants[userID] = this._participants[userID] || {}; + this._participants[userID].displayName = data.displayName; + this._participants[userID].formattedDisplayName + = data.formattedDisplayName; changeParticipantNumber(this, 1); - } else if (name === 'participant-left') { + break; + } + case 'participant-left': changeParticipantNumber(this, -1); + delete this._participants[userID]; + break; + case 'display-name-change': { + const user = this._participants[userID]; + + if (user) { + user.displayName = data.displayname; + user.formattedDisplayName = data.formattedDisplayName; + } + break; + } + case 'avatar-changed': { + const user = this._participants[userID]; + + if (user) { + user.avatarURL = data.avatarURL; + } + break; + } + case 'on-stage-participant-changed': + this._onStageParticipant = userID; + this.emit('largeVideoChanged'); + break; + case 'large-video-visibility-changed': + this._isLargeVideoVisible = data.isVisible; + this.emit('largeVideoChanged'); + break; + case 'video-conference-left': + changeParticipantNumber(this, -1); + delete this._participants[this._myUserID]; + break; } const eventName = events[name]; @@ -487,6 +566,43 @@ export default class JitsiMeetExternalAPI extends EventEmitter { }); } + /** + * Returns the avatar URL of a participant. + * + * @param {string} participantId - The id of the participant. + * @returns {string} The avatar URL. + */ + getAvatarURL(participantId) { + const { avatarURL } = this._participants[participantId] || {}; + + return avatarURL; + } + + /** + * Returns the display name of a participant. + * + * @param {string} participantId - The id of the participant. + * @returns {string} The display name. + */ + getDisplayName(participantId) { + const { displayName } = this._participants[participantId] || {}; + + return displayName; + } + + /** + * Returns the formatted display name of a participant. + * + * @param {string} participantId - The id of the participant. + * @returns {string} The formatted display name. + */ + _getFormattedDisplayName(participantId) { + const { formattedDisplayName } + = this._participants[participantId] || {}; + + return formattedDisplayName; + } + /** * Returns the iframe that loads Jitsi Meet. * diff --git a/modules/UI/UI.js b/modules/UI/UI.js index 2358cc52f..400f66e23 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -801,6 +801,16 @@ function changeAvatar(id, avatarUrl) { } } +/** + * Returns the avatar URL for a given user. + * + * @param {string} id - The id of the user. + * @returns {string} The avatar URL. + */ +UI.getAvatarUrl = function(id) { + return Avatar.getAvatarUrl(id); +}; + /** * Update user email. * @param {string} id user id diff --git a/modules/UI/avatar/Avatar.js b/modules/UI/avatar/Avatar.js index 6d7ee8085..0b164f309 100644 --- a/modules/UI/avatar/Avatar.js +++ b/modules/UI/avatar/Avatar.js @@ -48,6 +48,10 @@ export default { users[id] = {}; } users[id][prop] = val; + APP.API.notifyAvatarChanged( + id === 'local' ? APP.conference.getMyUserId() : id, + this.getAvatarUrl(id) + ); }, /** diff --git a/modules/UI/videolayout/VideoContainer.js b/modules/UI/videolayout/VideoContainer.js index d902727ac..0893e8ed9 100644 --- a/modules/UI/videolayout/VideoContainer.js +++ b/modules/UI/videolayout/VideoContainer.js @@ -1,4 +1,4 @@ -/* global $, interfaceConfig */ +/* global $, APP, interfaceConfig */ import Filmstrip from './Filmstrip'; import LargeContainer from './LargeContainer'; @@ -545,6 +545,7 @@ export class VideoContainer extends LargeContainer { this.avatarDisplayed = show; this.emitter.emit(UIEvents.LARGE_VIDEO_AVATAR_VISIBLE, show); + APP.API.notifyLargeVideoVisibilityChanged(show); } /** diff --git a/react/features/always-on-top/AlwaysOnTop.js b/react/features/always-on-top/AlwaysOnTop.js index 8b0e77dec..44645beb9 100644 --- a/react/features/always-on-top/AlwaysOnTop.js +++ b/react/features/always-on-top/AlwaysOnTop.js @@ -61,6 +61,9 @@ const TOOLBAR_TIMEOUT = 4000; type State = { audioAvailable: boolean, audioMuted: boolean, + avatarURL: string, + displayName: string, + isVideoDisplayed: boolean, videoAvailable: boolean, videoMuted: boolean, visible: boolean @@ -89,13 +92,21 @@ export default class AlwaysOnTop extends Component<*, State> { audioMuted: false, videoMuted: false, audioAvailable: false, - videoAvailable: false + videoAvailable: false, + displayName: '', + isVideoDisplayed: true, + avatarURL: '' }; // Bind event handlers so they are only bound once per instance. this._audioAvailabilityListener = this._audioAvailabilityListener.bind(this); this._audioMutedListener = this._audioMutedListener.bind(this); + this._avatarChangedListener = this._avatarChangedListener.bind(this); + this._largeVideoChangedListener + = this._largeVideoChangedListener.bind(this); + this._displayNameChangedListener + = this._displayNameChangedListener.bind(this); this._mouseMove = this._mouseMove.bind(this); this._onMouseOut = this._onMouseOut.bind(this); this._onMouseOver = this._onMouseOver.bind(this); @@ -128,6 +139,40 @@ export default class AlwaysOnTop extends Component<*, State> { this.setState({ audioMuted: muted }); } + _avatarChangedListener: () => void; + + /** + * Handles avatar changed api events. + * + * @returns {void} + */ + _avatarChangedListener({ avatarURL, id }) { + if (api._getOnStageParticipant() !== id) { + return; + } + + if (avatarURL !== this.state.avatarURL) { + this.setState({ avatarURL }); + } + } + + _displayNameChangedListener: () => void; + + /** + * Handles display name changed api events. + * + * @returns {void} + */ + _displayNameChangedListener({ formattedDisplayName, id }) { + if (api._getOnStageParticipant() !== id) { + return; + } + + if (formattedDisplayName !== this.state.displayName) { + this.setState({ displayName: formattedDisplayName }); + } + } + /** * Hides the toolbar after a timeout. * @@ -144,6 +189,26 @@ export default class AlwaysOnTop extends Component<*, State> { }, TOOLBAR_TIMEOUT); } + _largeVideoChangedListener: () => void; + + /** + * Handles large video changed api events. + * + * @returns {void} + */ + _largeVideoChangedListener() { + const userID = api._getOnStageParticipant(); + const displayName = api._getFormattedDisplayName(userID); + const avatarURL = api.getAvatarURL(userID); + const isVideoDisplayed = Boolean(api._getLargeVideo()); + + this.setState({ + avatarURL, + displayName, + isVideoDisplayed + }); + } + _mouseMove: () => void; /** @@ -181,6 +246,38 @@ export default class AlwaysOnTop extends Component<*, State> { _videoAvailabilityListener: ({ available: boolean }) => void; + /** + * Renders display name and avatar for the on stage participant. + * + * @returns {ReactElement} + */ + _renderVideoNotAvailableScreen() { + const { avatarURL, displayName, isVideoDisplayed } = this.state; + + if (isVideoDisplayed) { + return null; + } + + return ( +
+ { + avatarURL + ?
+ +
+ : null + } +
+ { displayName } +
+
+ ); + } + /** * Handles audio available api events. * @@ -214,6 +311,11 @@ export default class AlwaysOnTop extends Component<*, State> { api.on('videoMuteStatusChanged', this._videoMutedListener); api.on('audioAvailabilityChanged', this._audioAvailabilityListener); api.on('videoAvailabilityChanged', this._videoAvailabilityListener); + api.on('largeVideoChanged', this._largeVideoChangedListener); + api.on('displayNameChange', this._displayNameChangedListener); + api.on('avatarChanged', this._avatarChangedListener); + + this._largeVideoChangedListener(); Promise.all([ api.isAudioMuted(), @@ -256,6 +358,11 @@ export default class AlwaysOnTop extends Component<*, State> { this._audioAvailabilityListener); api.removeListener('videoAvailabilityChanged', this._videoAvailabilityListener); + api.removeListener('largeVideoChanged', + this._largeVideoChangedListener); + api.removeListener('displayNameChange', + this._displayNameChangedListener); + api.removeListener('avatarChanged', this._avatarChangedListener); window.removeEventListener('mousemove', this._mouseMove); } @@ -283,50 +390,61 @@ export default class AlwaysOnTop extends Component<*, State> { this.state.visible ? 'fadeIn' : 'fadeOut'}`; return ( - +
+ + { + Object.entries(TOOLBAR_BUTTONS).map( + ([ key, button ]) => { + // XXX The following silences a couple of flow + // errors: + if (button === null + || typeof button !== 'object') { + return null; + } + + const { onClick } = button; + let enabled = false; + let toggled = false; + + switch (key) { + case 'microphone': + enabled = this.state.audioAvailable; + toggled = enabled + ? this.state.audioMuted : true; + break; + case 'camera': + enabled = this.state.videoAvailable; + toggled = enabled + ? this.state.videoMuted : true; + break; + default: // hangup button + toggled = false; + enabled = true; + } + + const updatedButton = { + ...button, + enabled, + toggled + }; + + return ( + + ); + } + ) + } + { - Object.entries(TOOLBAR_BUTTONS).map(([ key, button ]) => { - // XXX The following silences a couple of flow errors: - if (button === null || typeof button !== 'object') { - return null; - } - - const { onClick } = button; - let enabled = false; - let toggled = false; - - switch (key) { - case 'microphone': - enabled = this.state.audioAvailable; - toggled = enabled ? this.state.audioMuted : true; - break; - case 'camera': - enabled = this.state.videoAvailable; - toggled = enabled ? this.state.videoMuted : true; - break; - default: // hangup button - toggled = false; - enabled = true; - } - - const updatedButton = { - ...button, - enabled, - toggled - }; - - return ( - - ); - }) + this._renderVideoNotAvailableScreen() } - +
); } } diff --git a/react/features/display-name/components/DisplayName.web.js b/react/features/display-name/components/DisplayName.web.js index e91c710a0..d624b9da0 100644 --- a/react/features/display-name/components/DisplayName.web.js +++ b/react/features/display-name/components/DisplayName.web.js @@ -2,6 +2,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; +import { appendSuffix } from '../functions'; + import { translate } from '../../base/i18n'; import { participantDisplayNameChanged } from '../../base/participants'; @@ -144,15 +146,12 @@ class DisplayName extends Component { ); } - const suffix - = displayName && displayNameSuffix ? ` (${displayNameSuffix})` : ''; - return ( - { `${displayName || displayNameSuffix || ''}${suffix}` } + { `${appendSuffix(displayName, displayNameSuffix)}` } ); } diff --git a/react/features/display-name/functions.js b/react/features/display-name/functions.js new file mode 100644 index 000000000..9e167b0b1 --- /dev/null +++ b/react/features/display-name/functions.js @@ -0,0 +1,11 @@ +/** + * Appends a suffix to the display name. + * + * @param {string} displayName - The display name. + * @param {string} suffix - Suffix that will be appended. + * @returns {string} The formatted display name. + */ +export function appendSuffix(displayName, suffix) { + return `${displayName || suffix || ''}${ + displayName && suffix ? ` (${suffix})` : ''}`; +} diff --git a/react/features/display-name/index.js b/react/features/display-name/index.js index 3c46ed49d..cc25e46aa 100644 --- a/react/features/display-name/index.js +++ b/react/features/display-name/index.js @@ -1,2 +1,3 @@ export * from './actions'; export * from './components'; +export * from './functions';