diff --git a/Makefile b/Makefile index 735923fbc..12624e6e0 100644 --- a/Makefile +++ b/Makefile @@ -47,5 +47,6 @@ source-package: mkdir -p source_package/jitsi-meet/css && \ cp -r analytics.js external_api.js favicon.ico fonts images index.html interface_config.js libs plugin.*html sounds title.html unsupported_browser.html LICENSE config.js lang source_package/jitsi-meet && \ cp css/all.css source_package/jitsi-meet/css && \ + cp css/unsupported_browser.css source_package/jitsi-meet/css && \ (cd source_package ; tar cjf ../jitsi-meet.tar.bz2 jitsi-meet) && \ rm -rf source_package diff --git a/README.md b/README.md index 7081f2772..07a4f8ddb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Jitsi Meet is an open-source (Apache) WebRTC JavaScript application that uses [J You can also try it out yourself at https://meet.jit.si . -Jitsi Meet allows for very efficient collaboration. It allows users to stream their desktop or only some windows. It also supports shared document editing with Etherpad and remote presentations with Prezi. +Jitsi Meet allows for very efficient collaboration. It allows users to stream their desktop or only some windows. It also supports shared document editing with Etherpad. ## Installation diff --git a/conference.js b/conference.js index 529a6c1aa..90d8bc007 100644 --- a/conference.js +++ b/conference.js @@ -28,10 +28,11 @@ const Commands = { CONNECTION_QUALITY: "stats", EMAIL: "email", ETHERPAD: "etherpad", - PREZI: "prezi", - STOP_PREZI: "stop-prezi" + SHARED_VIDEO: "shared-video" }; +import {VIDEO_CONTAINER_TYPE} from "./modules/UI/videolayout/LargeVideo"; + /** * Open Connection. When authentication failed it shows auth dialog. * @param roomName the room name to use @@ -258,6 +259,10 @@ class ConferenceConnector { APP.UI.notifyFocusLeft(); break; + case ConferenceErrors.CONFERENCE_MAX_USERS: + connection.disconnect(); + APP.UI.notifyMaxUsersLimitReached(); + break; default: this._handleConferenceFailed(err, ...params); } @@ -397,6 +402,15 @@ export default { listMembersIds () { return room.getParticipants().map(p => p.getId()); }, + /** + * Checks whether the participant identified by id is a moderator. + * @id id to search for participant + * @return {boolean} whether the participant is moderator + */ + isParticipantModerator (id) { + let user = room.getParticipantById(id); + return user && user.isModerator(); + }, /** * Check if SIP is supported. * @returns {boolean} @@ -506,6 +520,51 @@ export default { this._setupListeners(); }, + + /** + * Exposes a Command(s) API on this instance. It is necessitated by (1) the + * desire to keep room private to this instance and (2) the need of other + * modules to send and receive commands to and from participants. + * Eventually, this instance remains in control with respect to the + * decision whether the Command(s) API of room (i.e. lib-jitsi-meet's + * JitsiConference) is to be used in the implementation of the Command(s) + * API of this instance. + */ + commands: { + /** + * Receives notifications from other participants about commands aka + * custom events (sent by sendCommand or sendCommandOnce methods). + * @param command {String} the name of the command + * @param handler {Function} handler for the command + */ + addCommandListener () { + room.addCommandListener.apply(room, arguments); + }, + /** + * Removes command. + * @param name {String} the name of the command. + */ + removeCommand () { + room.removeCommand.apply(room, arguments); + }, + /** + * Sends command. + * @param name {String} the name of the command. + * @param values {Object} with keys and values that will be sent. + */ + sendCommand () { + room.sendCommand.apply(room, arguments); + }, + /** + * Sends command one time. + * @param name {String} the name of the command. + * @param values {Object} with keys and values that will be sent. + */ + sendCommandOnce () { + room.sendCommandOnce.apply(room, arguments); + }, + }, + _getConferenceOptions() { let options = config; if(config.enableRecording) { @@ -687,7 +746,7 @@ export default { console.log('USER %s LEFT', id, user); APP.API.notifyUserLeft(id); APP.UI.removeUser(id, user.getDisplayName()); - APP.UI.stopPrezi(id); + APP.UI.stopSharedVideo(id); }); @@ -866,35 +925,6 @@ export default { APP.UI.initEtherpad(value); }); - room.addCommandListener(Commands.PREZI, ({value, attributes}) => { - APP.UI.showPrezi(attributes.id, value, attributes.slide); - }); - - room.addCommandListener(Commands.STOP_PREZI, ({attributes}) => { - APP.UI.stopPrezi(attributes.id); - }); - - APP.UI.addListener(UIEvents.SHARE_PREZI, (url, slide) => { - console.log('Sharing Prezi %s slide %s', url, slide); - room.removeCommand(Commands.PREZI); - room.sendCommand(Commands.PREZI, { - value: url, - attributes: { - id: room.myUserId(), - slide - } - }); - }); - - APP.UI.addListener(UIEvents.STOP_SHARING_PREZI, () => { - room.removeCommand(Commands.PREZI); - room.sendCommandOnce(Commands.STOP_PREZI, { - attributes: { - id: room.myUserId() - } - }); - }); - APP.UI.addListener(UIEvents.EMAIL_CHANGED, (email = '') => { email = email.trim(); @@ -1011,8 +1041,20 @@ export default { APP.UI.addListener(UIEvents.SELECTED_ENDPOINT, (id) => { room.selectParticipant(id); }); - APP.UI.addListener(UIEvents.PINNED_ENDPOINT, (id) => { - room.pinParticipant(id); + + APP.UI.addListener(UIEvents.PINNED_ENDPOINT, (smallVideo, isPinned) => { + var smallVideoId = smallVideo.getId(); + + if (smallVideo.getVideoType() === VIDEO_CONTAINER_TYPE + && !APP.conference.isLocalId(smallVideoId)) + if (isPinned) + room.pinParticipant(smallVideoId); + // When the library starts supporting multiple pins we would + // pass the isPinned parameter together with the identifier, + // but currently we send null to indicate that we unpin the + // last pinned. + else + room.pinParticipant(null); }); APP.UI.addListener( @@ -1040,5 +1082,47 @@ export default { APP.UI.addListener( UIEvents.TOGGLE_SCREENSHARING, this.toggleScreenSharing.bind(this) ); + + APP.UI.addListener(UIEvents.UPDATE_SHARED_VIDEO, + (url, state, time, volume) => { + // send start and stop commands once, and remove any updates + // that had left + if (state === 'stop' || state === 'start' || state === 'playing') { + room.removeCommand(Commands.SHARED_VIDEO); + room.sendCommandOnce(Commands.SHARED_VIDEO, { + value: url, + attributes: { + state: state, + time: time, + volume: volume + } + }); + } + else { + // in case of paused, in order to allow late users to join + // paused + room.removeCommand(Commands.SHARED_VIDEO); + room.sendCommand(Commands.SHARED_VIDEO, { + value: url, + attributes: { + state: state, + time: time, + volume: volume + } + }); + } + }); + room.addCommandListener( + Commands.SHARED_VIDEO, ({value, attributes}, id) => { + + if (attributes.state === 'stop') { + APP.UI.stopSharedVideo(id); + } else if (attributes.state === 'start') { + APP.UI.showSharedVideo(id, value, attributes); + } else if (attributes.state === 'playing' + || attributes.state === 'pause') { + APP.UI.updateSharedVideo(id, value, attributes); + } + }); } }; diff --git a/config.js b/config.js index 4dda7b85f..8f25c6b76 100644 --- a/config.js +++ b/config.js @@ -70,4 +70,5 @@ var config = { 'During that time service will not be available. ' + 'Apologise for inconvenience.',*/ disableThirdPartyRequests: false, + minHDHeight: 540 }; diff --git a/css/chat.css b/css/chat.css index 1cf801c0e..65a7a068f 100644 --- a/css/chat.css +++ b/css/chat.css @@ -95,8 +95,6 @@ #unreadMessages { font-size: 8px; position: absolute; - left: 46%; - top: 27% } #bottomUnreadMessages { diff --git a/css/font.css b/css/font.css index d7a1d82ed..076d668ac 100644 --- a/css/font.css +++ b/css/font.css @@ -63,9 +63,6 @@ .icon-exit-full-screen:before { content: "\e60e"; } -.icon-prezi:before { - content: "\e60c"; -} .icon-link:before { content: "\e600"; } diff --git a/css/main.css b/css/main.css index 9ae59adb3..463018499 100644 --- a/css/main.css +++ b/css/main.css @@ -38,16 +38,6 @@ html, body{ position: relative; } -#toolbar a.button::after { - content: ''; - display: inline-block; - position: absolute; - left: 40px; - width: 1px; - height: 20px; - background: #373737; -} - #toolbar a.button:last-child::after { content: none; } @@ -56,13 +46,16 @@ html, body{ display: inline-block; position: relative; color: #FFFFFF; - top: 0; - padding: 10px 0; + top:0px; + padding-top: 10px; width: 38px; + height: 28px; cursor: pointer; text-align: center; text-shadow: 0 1px 0 rgba(255,255,255,.3), 0 -1px 0 rgba(0,0,0,.6); z-index: 1; + font-size: 1.22em !important; + vertical-align: middle; } .toolbar_span>span { @@ -103,13 +96,13 @@ html, body{ #numberOfParticipants { position: absolute; top: 0px; - right: -1; + right: -1px; color: white; width: 13px; height: 13px; line-height: 13px; font-weight: bold; - border-radius: 2px; + border-radius: 1px; font-size: 11px; text-shadow: -1px 0 black, 0 1px black, 1px 0 black, 0 -1px black; } @@ -118,6 +111,19 @@ html, body{ color: #00ccff; } +#toolbar { + display:inline-block; + position:relative; + top:5px; + margin-left:auto; + margin-right:auto; + height:38px; + width:auto; + background-color: rgba(0,0,0,0.5); + border-radius: 1px; + pointer-events: auto; +} + #toolbar_button_record { -webkit-transition: all .5s ease-in-out; -moz-transition: all .5s ease-in-out; @@ -141,13 +147,10 @@ html, body{ a.button:hover, a.bottomToolbarButton:hover { - top: 0px; cursor: pointer; background: rgba(255, 255, 255, 0.1); - border-radius: 6px; - background-clip: padding-box; - -webkit-border-radius: 6px; - -webkit-background-clip: padding-box; + border-radius: 1px; + -webkit-border-radius: 1px; } .no-fa-video-camera, .fa-microphone-slash { @@ -157,7 +160,7 @@ a.bottomToolbarButton:hover { .header_button_separator { display: inline-block; position:relative; - top: 5; + top: 5px; width: 1px; height: 20px; background: #373737; @@ -166,7 +169,7 @@ a.bottomToolbarButton:hover { .bottom_button_separator { display: inline-block; position: relative; - left: 5; + left: 5px; width: 20px; height: 1px; background: #373737; @@ -210,7 +213,7 @@ button { height: 35px; padding: 0 1em 0 2em; position: relative; - border-radius: 3px; + border-radius: 1px; font-weight: bold; color: #fff; line-height: 35px; @@ -273,10 +276,9 @@ div.feedbackButton:hover { margin-right: 5px; bottom: 40px; width: 29px; - border-radius: 6px; + border-radius: 1px; color: #FFF; - border: 1px solid rgba(256, 256, 256, 0.2); - background: rgba(0,0,0,0.8); + background: rgba(0,0,0,0.5); z-index: 6; /*+1 from #remoteVideos*/ } @@ -350,7 +352,7 @@ div.feedbackButton:hover { } #toast-container.notification-bottom-right { - bottom: 120px; + bottom: 140px; right: 5px; } diff --git a/css/popup_menu.css b/css/popup_menu.css index 300d95f4b..041e04229 100644 --- a/css/popup_menu.css +++ b/css/popup_menu.css @@ -13,7 +13,7 @@ ul.popupmenu { width: 100px; background-color: rgba(0,0,0,0.9); border: 1px solid rgba(256, 256, 256, 0.2); - border-radius:8px; + border-radius:3px; } ul.popupmenu:after { @@ -31,7 +31,7 @@ ul.popupmenu li { ul.popupmenu li:hover { background-color: rgba(256, 256, 256, .2); - border-radius:6px; + border-radius:3px; } /*Link Appearance*/ diff --git a/css/settingsmenu.css b/css/settingsmenu.css index c990dfb4a..1cd63203e 100644 --- a/css/settingsmenu.css +++ b/css/settingsmenu.css @@ -43,29 +43,25 @@ cursor: pointer; } - -#startMutedOptions { +#startMutedOptions, +#followMeOptions { padding-left: 10%; text-indent: -10%; - margin-top: 10px; - display: none; /* hide by default */ - /* clearfix */ overflow: auto; zoom: 1; } -#startAudioMuted { +#startAudioMuted, +#startVideoMuted, +#followMeCheckBox { width: 13px !important; } -#startVideoMuted { - width: 13px !important; -} - -.startMutedLabel { +.startMutedLabel, +.followMeLabel { width: 94%; float: left; cursor: pointer; diff --git a/css/videolayout_default.css b/css/videolayout_default.css index f298d05be..1e02c4aa4 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -21,6 +21,7 @@ width: auto; height: auto !important; z-index: 5; + font-size: 0pt; /*!!!Removes the gap between the local video container and the remote videos.*/ } #remoteVideos.hidden { @@ -30,18 +31,21 @@ .videocontainer { position: relative; + margin-left: auto; + margin-right: auto; + text-align: center; } #remoteVideos .videocontainer { display: none; background-color: black; background-size: contain; - border-radius:8px; - border: 2px solid #212425; - margin-right: 3px; + border-radius:1px; + border: 1px solid #212425; + /*margin-right: 1px;*/ } -#remoteVideos .videocontainer:hover, +/*#remoteVideos .videocontainer:hover,*/ #remoteVideos .videocontainer.videoContainerFocused { cursor: hand; /* transform:scale(1.08, 1.08); @@ -55,25 +59,21 @@ } #remoteVideos .videocontainer:hover { - box-shadow: inset 0 0 10px #FFFFFF, 0 0 10px #FFFFFF; - border: 2px solid #FFFFFF; + border: 1px solid #c1c1c1; } #remoteVideos .videocontainer.videoContainerFocused { box-shadow: inset 0 0 28px #006d91; - border: 2px solid #006d91; + border: 1px solid #006d91; } #remoteVideos .videocontainer.videoContainerFocused:hover { - box-shadow: inset 0 0 5px #FFFFFF, 0 0 10px #FFFFFF, inset 0 0 60px #006d91; - border: 2px solid #FFFFFF; + box-shadow: inset 0 0 5px #c1c1c1, 0 0 10px #c1c1c1, inset 0 0 60px #006d91; + border: 1px solid #c1c1c1; } #localVideoWrapper { display:inline-block; - -webkit-mask-box-image: url(../images/videomask.svg); - border-radius:4px !important; - border: 0px !important; } /* With TemasysWebRTC plugin element is used @@ -81,7 +81,8 @@ #remoteVideos .videocontainer>video, #remoteVideos .videocontainer>object { cursor: hand; - border-radius:4px; + border-radius:1px; + object-fit: cover; } .flipVideoX { @@ -94,7 +95,8 @@ #localVideoWrapper>video, #localVideoWrapper>object { cursor: hand; - border-radius:4px !important; + border-radius:1px !important; + object-fit: cover; } #largeVideo, @@ -111,6 +113,7 @@ } #presentation, +#sharedVideo, #etherpad, #localVideoWrapper>video, #localVideoWrapper>object, @@ -173,7 +176,7 @@ overflow: hidden; white-space: nowrap; z-index: 2; - border-radius:20px; + border-radius:3px; } .videocontainer>span.status { @@ -192,7 +195,7 @@ overflow: hidden; white-space: nowrap; z-index: 2; - border-radius:20px; + border-radius:3px; } .connectionindicator @@ -390,14 +393,14 @@ display: inline-block; position: absolute; z-index: 0; - border-radius:10px; + border-radius:1px; pointer-events: none; } #dominantSpeaker { visibility: hidden; - width: 150px; - height: 150px; + width: 300px; + height: 300px; margin: auto; overflow: hidden; position: relative; @@ -416,21 +419,28 @@ } #dominantSpeakerAvatar { - width: 100px; - height: 100px; - top: 25px; + width: 200px; + height: 200px; + top: 50px; margin: auto; position: relative; - border-radius: 50px; + border-radius: 100px; z-index: 3; visibility: inherit; + background-color: #000000; } .userAvatar { height: 100%; position: absolute; - left: 35px; - border-radius: 200px; + left: 0; + border-radius: 2px; +} + +.sharedVideoAvatar { + height: 100%; + width: 100%; + object-fit: cover; } .noMic { @@ -483,3 +493,14 @@ 1px 0px 1px rgba(0,0,0,0.3), 0px 0px 1px rgba(0,0,0,0.3); } + +#videoResolutionLabel { + display: none; + position: absolute; + top: 5px; + right: 5px; + background: rgba(0,0,0,.5); + padding: 10px; + color: rgba(255,255,255,.5); + z-index: 10000; +} diff --git a/css/welcome_page.css b/css/welcome_page.css index 0f1a11031..3f38f6e49 100644 --- a/css/welcome_page.css +++ b/css/welcome_page.css @@ -42,11 +42,11 @@ } #enter_room_form { - border-radius: 10px; + border-radius: 1px; background-color: #FFFFFF; border: none; - -moz-border-radius: 10px; - -webkit-border-radius: 10px; + -moz-border-radius: 1px; + -webkit-border-radius: 1px; -webkit-appearance: none; box-shadow: none; } @@ -78,8 +78,8 @@ width: 73px; height: 45px; background-color: #16a8fe; - moz-border-radius: 10px; - -webkit-border-radius: 10px; + moz-border-radius: 1px; + -webkit-border-radius: 1px; color: #ffffff; font-weight: 600; border: none; diff --git a/index.html b/index.html index 5aa91fe89..848e8fe88 100644 --- a/index.html +++ b/index.html @@ -144,8 +144,8 @@ - + @@ -156,11 +156,11 @@
-
+
@@ -175,6 +175,7 @@
+ HD
@@ -262,6 +263,12 @@
+
+ +
diff --git a/interface_config.js b/interface_config.js index aae97d279..9d0f05a62 100644 --- a/interface_config.js +++ b/interface_config.js @@ -1,6 +1,6 @@ var interfaceConfig = { CANVAS_EXTRA: 104, - CANVAS_RADIUS: 7, + CANVAS_RADIUS: 0, SHADOW_COLOR: '#ffffff', INITIAL_TOOLBAR_TIMEOUT: 20000, TOOLBAR_TIMEOUT: 4000, @@ -14,9 +14,8 @@ var interfaceConfig = { GENERATE_ROOMNAMES_ON_WELCOME_PAGE: true, APP_NAME: "Jitsi Meet", INVITATION_POWERED_BY: true, - DOMINANT_SPEAKER_AVATAR_SIZE: 100, TOOLBAR_BUTTONS: ['authentication', 'microphone', 'camera', 'desktop', - 'recording', 'security', 'invite', 'chat', 'prezi', 'etherpad', + 'recording', 'security', 'invite', 'chat', 'etherpad', 'sharedvideo', 'fullscreen', 'sip', 'dialpad', 'settings', 'hangup', 'filmstrip', 'contacts'], // Determines how the video would fit the screen. 'both' would fit the whole @@ -30,5 +29,5 @@ var interfaceConfig = { filmStripOnly: false, RANDOM_AVATAR_URL_PREFIX: false, RANDOM_AVATAR_URL_SUFFIX: false, - FILM_STRIP_MAX_HEIGHT: 160 + FILM_STRIP_MAX_HEIGHT: 120 }; diff --git a/lang/main.json b/lang/main.json index c2863f3b5..24199d190 100644 --- a/lang/main.json +++ b/lang/main.json @@ -9,7 +9,7 @@ "me": "me", "speaker": "Speaker", "defaultNickname": "ex. __name__", - "defaultPreziLink": "e.g. __url__", + "defaultLink": "e.g. __url__", "welcomepage":{ "go": "GO", "roomname": "Enter room name", @@ -55,8 +55,8 @@ "lock": "Lock / unlock room", "invite": "Invite others", "chat": "Open / close chat", - "prezi": "Share Prezi", "etherpad": "Shared document", + "sharedvideo": "Shared video", "sharescreen": "Share screen", "fullscreen": "Enter / Exit Full Screen", "sip": "Call SIP number", @@ -86,7 +86,8 @@ "startAudioMuted": "start without audio", "startVideoMuted": "start without video", "selectCamera": "select camera", - "selectMic": "select microphone" + "selectMic": "select microphone", + "followMe": "Enable follow me" }, "videothumbnail": { @@ -146,6 +147,7 @@ "failedpermissions": "Failed to obtain permissions to use the local microphone and/or camera.", "bridgeUnavailable": "Jitsi Videobridge is currently unavailable. Please try again later!", "jicofoUnavailable": "Jicofo is currently unavailable. Please try again later!", + "maxUsersLimitReached": "The limit for maximum number of participants in the conference has been reached. The conference is full. Please try again later!", "lockTitle": "Lock failed", "lockMessage": "Failed to lock the conference.", "warning": "Warning", @@ -159,11 +161,12 @@ "defaultError": "There was some kind of error", "passwordRequired": "Password required", "Ok": "Ok", - "removePreziTitle": "Remove Prezi", - "removePreziMsg": "Are you sure you would like to remove your Prezi?", - "sharePreziTitle": "Share a Prezi", - "sharePreziMsg": "Another participant is already sharing a Prezi. This conference allows only one Prezi at a time.", "Remove": "Remove", + "shareVideoTitle": "Share a video", + "shareVideoLinkError": "Please provide a correct youtube link.", + "removeSharedVideoTitle": "Remove shared video", + "removeSharedVideoMsg": "Are you sure you would like to remove your shared video?", + "alreadySharedVideoMsg": "Another participant is already sharing video. This conference allows only one shared video at a time.", "WaitingForHost": "Waiting for the host ...", "WaitForHostMsg": "The conference __room__ has not yet started. If you are the host then please authenticate. Otherwise, please wait for the host to arrive.", "IamHost": "I am the host", @@ -175,7 +178,6 @@ "hungUp": "You hung up", "joinAgain": "Join again", "Share": "Share", - "preziLinkError": "Please provide a correct prezi link.", "Save": "Save", "recordingToken": "Enter recording token", "Dial": "Dial", diff --git a/modules/FollowMe.js b/modules/FollowMe.js new file mode 100644 index 000000000..0d5086809 --- /dev/null +++ b/modules/FollowMe.js @@ -0,0 +1,333 @@ +/* + * Copyright @ 2015 Atlassian Pty Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import UIEvents from '../service/UI/UIEvents'; +import VideoLayout from './UI/videolayout/VideoLayout'; +import FilmStrip from './UI/videolayout/FilmStrip'; + +/** + * The (name of the) command which transports the state (represented by + * {State} for the local state at the time of this writing) of a {FollowMe} + * (instance) between participants. + */ +const _COMMAND = "follow-me"; + +/** + * Represents the set of {FollowMe}-related states (properties and their + * respective values) which are to be followed by a participant. {FollowMe} + * will send {_COMMAND} whenever a property of {State} changes (if the local + * participant is in her right to issue such a command, of course). + */ +class State { + /** + * Initializes a new {State} instance. + * + * @param propertyChangeCallback {Function} which is to be called when a + * property of the new instance has its value changed from an old value + * into a (different) new value. The function is supplied with the name of + * the property, the old value of the property before the change, and the + * new value of the property after the change. + */ + constructor (propertyChangeCallback) { + this._propertyChangeCallback = propertyChangeCallback; + } + + get filmStripVisible () { return this._filmStripVisible; } + + set filmStripVisible (b) { + var oldValue = this._filmStripVisible; + if (oldValue !== b) { + this._filmStripVisible = b; + this._firePropertyChange('filmStripVisible', oldValue, b); + } + } + + get nextOnStage() { return this._nextOnStage; } + + set nextOnStage(id) { + var oldValue = this._nextOnStage; + if (oldValue !== id) { + this._nextOnStage = id; + this._firePropertyChange('nextOnStage', oldValue, id); + } + } + + get sharedDocumentVisible () { return this._sharedDocumentVisible; } + + set sharedDocumentVisible (b) { + var oldValue = this._sharedDocumentVisible; + if (oldValue !== b) { + this._sharedDocumentVisible = b; + this._firePropertyChange('sharedDocumentVisible', oldValue, b); + } + } + + /** + * Invokes {_propertyChangeCallback} to notify it that {property} had its + * value changed from {oldValue} to {newValue}. + * + * @param property the name of the property which had its value changed + * from {oldValue} to {newValue} + * @param oldValue the value of {property} before the change + * @param newValue the value of {property} after the change + */ + _firePropertyChange (property, oldValue, newValue) { + var propertyChangeCallback = this._propertyChangeCallback; + if (propertyChangeCallback) + propertyChangeCallback(property, oldValue, newValue); + } +} + +/** + * Represents the "Follow Me" feature which enables a moderator to + * (partially) control the user experience/interface (e.g. film strip + * visibility) of (other) non-moderator particiapnts. + * + * @author Lyubomir Marinov + */ +class FollowMe { + /** + * Initializes a new {FollowMe} instance. + * + * @param conference the {conference} which is to transport + * {FollowMe}-related information between participants + * @param UI the {UI} which is the source (model/state) to be sent to + * remote participants if the local participant is the moderator or the + * destination (model/state) to receive from the remote moderator if the + * local participant is not the moderator + */ + constructor (conference, UI) { + this._conference = conference; + this._UI = UI; + + // The states of the local participant which are to be followed (by the + // remote participants when the local participant is in her right to + // issue such commands). + this._local = new State(this._localPropertyChange.bind(this)); + + // Listen to "Follow Me" commands. I'm not sure whether a moderator can + // (in lib-jitsi-meet and/or Meet) become a non-moderator. If that's + // possible, then it may be easiest to always listen to commands. The + // listener will validate received commands before acting on them. + conference.commands.addCommandListener( + _COMMAND, + this._onFollowMeCommand.bind(this)); + } + + /** + * Adds listeners for the UI states of the local participant which are + * to be followed (by the remote participants). A non-moderator (very + * likely) can become a moderator so it may be easiest to always track + * the states of interest. + * @private + */ + _addFollowMeListeners () { + this.filmStripEventHandler = this._filmStripToggled.bind(this); + this._UI.addListener(UIEvents.TOGGLED_FILM_STRIP, + this.filmStripEventHandler); + + var self = this; + this.pinnedEndpointEventHandler = function (smallVideo, isPinned) { + self._nextOnStage(smallVideo, isPinned); + }; + this._UI.addListener(UIEvents.PINNED_ENDPOINT, + this.pinnedEndpointEventHandler); + + this.sharedDocEventHandler = this._sharedDocumentToggled.bind(this); + this._UI.addListener( UIEvents.TOGGLED_SHARED_DOCUMENT, + this.sharedDocEventHandler); + } + + /** + * Removes all follow me listeners. + * @private + */ + _removeFollowMeListeners () { + this._UI.removeListener(UIEvents.TOGGLED_FILM_STRIP, + this.filmStripEventHandler); + this._UI.removeListener(UIEvents.TOGGLED_SHARED_DOCUMENT, + this.sharedDocEventHandler); + this._UI.removeListener(UIEvents.PINNED_ENDPOINT, + this.pinnedEndpointEventHandler); + } + + /** + * Enables or disabled the follow me functionality + * + * @param enable {true} to enable the follow me functionality, {false} - + * to disable it + */ + enableFollowMe (enable) { + this.isEnabled = enable; + if (this.isEnabled) + this._addFollowMeListeners(); + else + this._removeFollowMeListeners(); + } + + /** + * Notifies this instance that the (visibility of the) film strip was + * toggled (in the user interface of the local participant). + * + * @param filmStripVisible {Boolean} {true} if the film strip was shown (as + * a result of the toggle) or {false} if the film strip was hidden + */ + _filmStripToggled (filmStripVisible) { + this._local.filmStripVisible = filmStripVisible; + } + + /** + * Notifies this instance that the (visibility of the) shared document was + * toggled (in the user interface of the local participant). + * + * @param sharedDocumentVisible {Boolean} {true} if the shared document was + * shown (as a result of the toggle) or {false} if it was hidden + */ + _sharedDocumentToggled (sharedDocumentVisible) { + this._local.sharedDocumentVisible = sharedDocumentVisible; + } + + /** + * Changes the nextOnPage property value. + * + * @param smallVideo the {SmallVideo} that was pinned or unpinned + * @param isPinned indicates if the given {SmallVideo} was pinned or + * unpinned + * @private + */ + _nextOnStage (smallVideo, isPinned) { + if (!this._conference.isModerator) + return; + + var nextOnStage = null; + if(isPinned) + nextOnStage = smallVideo.getId(); + + this._local.nextOnStage = nextOnStage; + } + + /** + * Sends the follow-me command, when a local property change occurs. + * + * @param property the property name + * @param oldValue the old value + * @param newValue the new value + * @private + */ + _localPropertyChange (property, oldValue, newValue) { + // Only a moderator is allowed to send commands. + var conference = this._conference; + if (!conference.isModerator) + return; + + var commands = conference.commands; + // XXX The "Follow Me" command represents a snapshot of all states + // which are to be followed so don't forget to removeCommand before + // sendCommand! + commands.removeCommand(_COMMAND); + var self = this; + commands.sendCommandOnce( + _COMMAND, + { + attributes: { + filmStripVisible: self._local.filmStripVisible, + nextOnStage: self._local.nextOnStage, + sharedDocumentVisible: self._local.sharedDocumentVisible + } + }); + } + + /** + * Notifies this instance about a &qout;Follow Me&qout; command (delivered + * by the Command(s) API of {this._conference}). + * + * @param attributes the attributes {Object} carried by the command + * @param id the identifier of the participant who issued the command. A + * notable idiosyncrasy of the Command(s) API to be mindful of here is that + * the command may be issued by the local participant. + */ + _onFollowMeCommand ({ attributes }, id) { + // We require to know who issued the command because (1) only a + // moderator is allowed to send commands and (2) a command MUST be + // issued by a defined commander. + if (typeof id === 'undefined') + return; + // The Command(s) API will send us our own commands and we don't want + // to act upon them. + if (this._conference.isLocalId(id)) + return; + + if (!this._conference.isParticipantModerator(id)) + { + console.warn('Received follow-me command ' + + 'not from moderator'); + return; + } + + // Applies the received/remote command to the user experience/interface + // of the local participant. + this._onFilmStripVisible(attributes.filmStripVisible); + this._onNextOnStage(attributes.nextOnStage); + this._onSharedDocumentVisible(attributes.sharedDocumentVisible); + } + + _onFilmStripVisible(filmStripVisible) { + if (typeof filmStripVisible !== 'undefined') { + // XXX The Command(s) API doesn't preserve the types (of + // attributes, at least) at the time of this writing so take into + // account that what originated as a Boolean may be a String on + // receipt. + filmStripVisible = (filmStripVisible == 'true'); + + // FIXME The UI (module) very likely doesn't (want to) expose its + // eventEmitter as a public field. I'm not sure at the time of this + // writing whether calling UI.toggleFilmStrip() is acceptable (from + // a design standpoint) either. + if (filmStripVisible !== FilmStrip.isFilmStripVisible()) + this._UI.eventEmitter.emit( + UIEvents.TOGGLE_FILM_STRIP, + filmStripVisible); + } + } + + _onNextOnStage(id) { + + var clickId = null; + if(typeof id !== 'undefined' && !VideoLayout.isPinned(id)) + clickId = id; + else if (typeof id == 'undefined' && VideoLayout.getPinnedId()) + clickId = VideoLayout.getPinnedId(); + + if (clickId) + VideoLayout.handleVideoThumbClicked(clickId); + } + + _onSharedDocumentVisible(sharedDocumentVisible) { + if (typeof sharedDocumentVisible !== 'undefined') { + // XXX The Command(s) API doesn't preserve the types (of + // attributes, at least) at the time of this writing so take into + // account that what originated as a Boolean may be a String on + // receipt. + sharedDocumentVisible = (sharedDocumentVisible == 'true'); + + if (sharedDocumentVisible + !== this._UI.getSharedDocumentManager().isVisible()) + this._UI.getSharedDocumentManager().toggleEtherpad(); + } + } +} + +export default FollowMe; diff --git a/modules/UI/UI.js b/modules/UI/UI.js index e68113ff2..4e4a9d99f 100644 --- a/modules/UI/UI.js +++ b/modules/UI/UI.js @@ -12,8 +12,8 @@ import PanelToggler from "./side_pannels/SidePanelToggler"; import UIUtil from "./util/UIUtil"; import UIEvents from "../../service/UI/UIEvents"; import CQEvents from '../../service/connectionquality/CQEvents'; -import PreziManager from './prezi/Prezi'; import EtherpadManager from './etherpad/Etherpad'; +import SharedVideoManager from './shared_video/SharedVideo'; import VideoLayout from "./videolayout/VideoLayout"; import FilmStrip from "./videolayout/FilmStrip"; @@ -27,11 +27,15 @@ var messageHandler = UI.messageHandler; var JitsiPopover = require("./util/JitsiPopover"); var Feedback = require("./Feedback"); +import FollowMe from "../FollowMe"; + var eventEmitter = new EventEmitter(); UI.eventEmitter = eventEmitter; -let preziManager; let etherpadManager; +let sharedVideoManager; + +let followMeHandler; /** * Prompt user for nickname. @@ -96,7 +100,6 @@ function setupChat() { */ function setupToolbars() { Toolbar.init(eventEmitter); - Toolbar.setupButtonsFromConfig(); BottomToolbar.setupListeners(eventEmitter); } @@ -230,6 +233,10 @@ UI.initConference = function () { // Add myself to the contact list. ContactList.addContact(id); + //update default button states before showing the toolbar + //if local role changes buttons state will be again updated + UI.updateLocalRole(false); + // Once we've joined the muc show the toolbar ToolbarToggler.showToolbar(); @@ -246,6 +253,12 @@ UI.initConference = function () { if(!interfaceConfig.filmStripOnly) { Feedback.init(); } + + // FollowMe attempts to copy certain aspects of the moderator's UI into the + // other participants' UI. Consequently, it needs (1) read and write access + // to the UI (depending on the moderator role of the local participant) and + // (2) APP.conference as means of communication between the participants. + followMeHandler = new FollowMe(APP.conference, UI); }; UI.mucJoined = function () { @@ -256,9 +269,6 @@ UI.mucJoined = function () { * Setup some UI event listeners. */ function registerListeners() { - UI.addListener(UIEvents.PREZI_CLICKED, function () { - preziManager.handlePreziButtonClicked(); - }); UI.addListener(UIEvents.ETHERPAD_CLICKED, function () { if (etherpadManager) { @@ -266,6 +276,12 @@ function registerListeners() { } }); + UI.addListener(UIEvents.SHARED_VIDEO_CLICKED, function () { + if (sharedVideoManager) { + sharedVideoManager.toggleSharedVideo(); + } + }); + UI.addListener(UIEvents.FULLSCREEN_TOGGLE, toggleFullScreen); UI.addListener(UIEvents.TOGGLE_CHAT, UI.toggleChat); @@ -276,7 +292,15 @@ function registerListeners() { UI.addListener(UIEvents.TOGGLE_CONTACT_LIST, UI.toggleContactList); - UI.addListener(UIEvents.TOGGLE_FILM_STRIP, UI.toggleFilmStrip); + UI.addListener(UIEvents.TOGGLE_FILM_STRIP, function () { + UI.toggleFilmStrip(); + VideoLayout.resizeVideoArea(PanelToggler.isVisible(), true, false); + }); + + UI.addListener(UIEvents.FOLLOW_ME_ENABLED, function (isEnabled) { + if (followMeHandler) + followMeHandler.enableFollowMe(isEnabled); + }); } /** @@ -326,7 +350,7 @@ UI.start = function () { registerListeners(); BottomToolbar.init(); - FilmStrip.init(); + FilmStrip.init(eventEmitter); VideoLayout.init(eventEmitter); if (!interfaceConfig.filmStripOnly) { @@ -337,7 +361,7 @@ UI.start = function () { ContactList.init(eventEmitter); bindEvents(); - preziManager = new PreziManager(eventEmitter); + sharedVideoManager = new SharedVideoManager(eventEmitter); if (!interfaceConfig.filmStripOnly) { $("#videospace").mousemove(function () { @@ -465,10 +489,19 @@ UI.initEtherpad = function (name) { return; } console.log('Etherpad is enabled'); - etherpadManager = new EtherpadManager(config.etherpad_base, name); + etherpadManager + = new EtherpadManager(config.etherpad_base, name, eventEmitter); Toolbar.showEtherpadButton(); }; +/** + * Returns the shared document manager object. + * @return {EtherpadManager} the shared document manager object + */ +UI.getSharedDocumentManager = function () { + return etherpadManager; +}; + /** * Show user on UI. * @param {string} id user id @@ -485,11 +518,11 @@ UI.addUser = function (id, displayName) { config.startAudioMuted > APP.conference.membersCount) UIUtil.playSoundNotification('userJoined'); - // Configure avatar - UI.setUserAvatar(id); - // Add Peer's container VideoLayout.addParticipantContainer(id); + + // Configure avatar + UI.setUserAvatar(id); }; /** @@ -534,7 +567,9 @@ UI.updateLocalRole = function (isModerator) { Toolbar.showSipCallButton(isModerator); Toolbar.showRecordingButton(isModerator); + Toolbar.showSharedVideoButton(isModerator); SettingsMenu.showStartMutedOptions(isModerator); + SettingsMenu.showFollowMeOptions(isModerator); if (isModerator) { messageHandler.notify(null, "notify.me", 'connected', "notify.moderator"); @@ -583,7 +618,8 @@ UI.toggleSmileys = function () { * Toggles film strip. */ UI.toggleFilmStrip = function () { - FilmStrip.toggleFilmStrip(); + var self = FilmStrip; + self.toggleFilmStrip.apply(self, arguments); }; /** @@ -671,10 +707,26 @@ UI.setVideoMuted = function (id, muted) { } }; +/** + * Adds a listener that would be notified on the given type of event. + * + * @param type the type of the event we're listening for + * @param listener a function that would be called when notified + */ UI.addListener = function (type, listener) { eventEmitter.on(type, listener); }; +/** + * Removes the given listener for the given type of event. + * + * @param type the type of the event we're listening for + * @param listener the listener we want to remove + */ +UI.removeListener = function (type, listener) { + eventEmitter.removeListener(type, listener); +}; + UI.clickOnVideo = function (videoNumber) { var remoteVideos = $(".videocontainer:not(#mixedstream)"); if (remoteVideos.length > videoNumber) { @@ -732,6 +784,22 @@ UI.notifyConnectionFailed = function (stropheErrorMsg) { ); }; + +/** + * Notify user that maximum users limit has been reached. + */ +UI.notifyMaxUsersLimitReached = function () { + var title = APP.translation.generateTranslationHTML( + "dialog.error"); + + var message = APP.translation.generateTranslationHTML( + "dialog.maxUsersLimitReached"); + + messageHandler.openDialog( + title, message, true, {}, function (e, v, m, f) { return false; } + ); +}; + /** * Notify user that he was automatically muted when joned the conference. */ @@ -998,26 +1066,6 @@ UI.updateAuthInfo = function (isAuthEnabled, login) { } }; -/** - * Show Prezi from the user. - * @param {string} userId user id - * @param {string} url Prezi url - * @param {number} slide slide to show - */ -UI.showPrezi = function (userId, url, slide) { - preziManager.showPrezi(userId, url, slide); -}; - -/** - * Stop showing Prezi from the user. - * @param {string} userId user id - */ -UI.stopPrezi = function (userId) { - if (preziManager.isSharing(userId)) { - preziManager.removePrezi(userId); - } -}; - UI.onStartMutedChanged = function (startAudioMuted, startVideoMuted) { SettingsMenu.updateStartMutedBox(startAudioMuted, startVideoMuted); }; @@ -1038,6 +1086,14 @@ UI.getLargeVideoID = function () { return VideoLayout.getLargeVideoID(); }; +/** + * Returns the current video shown on large. + * Currently used by tests (torture). + */ +UI.getLargeVideo = function () { + return VideoLayout.getLargeVideo(); +}; + /** * Shows dialog with a link to FF extension. */ @@ -1054,4 +1110,36 @@ UI.updateDevicesAvailability = function (id, devices) { VideoLayout.setDeviceAvailabilityIcons(id, devices); }; +/** + * Show shared video. + * @param {string} id the id of the sender of the command + * @param {string} url video url + * @param {string} attributes +*/ +UI.showSharedVideo = function (id, url, attributes) { + if (sharedVideoManager) + sharedVideoManager.showSharedVideo(id, url, attributes); +}; + +/** + * Update shared video. + * @param {string} id the id of the sender of the command + * @param {string} url video url + * @param {string} attributes + */ +UI.updateSharedVideo = function (id, url, attributes) { + if (sharedVideoManager) + sharedVideoManager.updateSharedVideo(id, url, attributes); +}; + +/** + * Stop showing shared video. + * @param {string} id the id of the sender of the command + * @param {string} attributes + */ +UI.stopSharedVideo = function (id, attributes) { + if (sharedVideoManager) + sharedVideoManager.stopSharedVideo(id); +}; + module.exports = UI; diff --git a/modules/UI/audio_levels/AudioLevels.js b/modules/UI/audio_levels/AudioLevels.js index 7e1bc68dd..68568710d 100644 --- a/modules/UI/audio_levels/AudioLevels.js +++ b/modules/UI/audio_levels/AudioLevels.js @@ -8,13 +8,16 @@ const LOCAL_LEVEL = 'local'; let ASDrawContext = null; let audioLevelCanvasCache = {}; +let dominantSpeakerAudioElement = null; -function initDominantSpeakerAudioLevels() { - let ASRadius = interfaceConfig.DOMINANT_SPEAKER_AVATAR_SIZE / 2; - let ASCenter = (interfaceConfig.DOMINANT_SPEAKER_AVATAR_SIZE + ASRadius) / 2; +function initDominantSpeakerAudioLevels(dominantSpeakerAvatarSize) { + let ASRadius = dominantSpeakerAvatarSize / 2; + let ASCenter = (dominantSpeakerAvatarSize + ASRadius) / 2; // Draw a circle. + ASDrawContext.beginPath(); ASDrawContext.arc(ASCenter, ASCenter, ASRadius, 0, 2 * Math.PI); + ASDrawContext.closePath(); // Add a shadow around the circle ASDrawContext.shadowColor = interfaceConfig.SHADOW_COLOR; @@ -90,14 +93,14 @@ function getShadowLevel (audioLevel) { let shadowLevel = 0; if (audioLevel <= 0.3) { - shadowLevel - = Math.round(interfaceConfig.CANVAS_EXTRA/2*(audioLevel/0.3)); + shadowLevel = Math.round( + interfaceConfig.CANVAS_EXTRA/2*(audioLevel/0.3)); } else if (audioLevel <= 0.6) { - shadowLevel - = Math.round(interfaceConfig.CANVAS_EXTRA/2*((audioLevel - 0.3) / 0.3)); + shadowLevel = Math.round( + interfaceConfig.CANVAS_EXTRA/2*((audioLevel - 0.3) / 0.3)); } else { - shadowLevel - = Math.round(interfaceConfig.CANVAS_EXTRA/2*((audioLevel - 0.6) / 0.4)); + shadowLevel = Math.round( + interfaceConfig.CANVAS_EXTRA/2*((audioLevel - 0.6) / 0.4)); } return shadowLevel; @@ -124,8 +127,18 @@ function getVideoSpanId(id) { const AudioLevels = { init () { - ASDrawContext = $('#dominantSpeakerAudioLevel')[0].getContext('2d'); - initDominantSpeakerAudioLevels(); + dominantSpeakerAudioElement = $('#dominantSpeakerAudioLevel')[0]; + ASDrawContext = dominantSpeakerAudioElement.getContext('2d'); + + let parentContainer = $("#dominantSpeaker"); + let dominantSpeakerWidth = parentContainer.width(); + let dominantSpeakerHeight = parentContainer.height(); + + dominantSpeakerAudioElement.width = dominantSpeakerWidth; + dominantSpeakerAudioElement.height = dominantSpeakerHeight; + + let dominantSpeakerAvatar = $("#dominantSpeakerAvatar"); + initDominantSpeakerAudioLevels(dominantSpeakerAvatar.width()); }, /** @@ -155,8 +168,10 @@ const AudioLevels = { audioLevelCanvas = document.createElement('canvas'); audioLevelCanvas.className = "audiolevel"; - audioLevelCanvas.style.bottom = `-${interfaceConfig.CANVAS_EXTRA/2}px`; - audioLevelCanvas.style.left = `-${interfaceConfig.CANVAS_EXTRA/2}px`; + audioLevelCanvas.style.bottom + = `-${interfaceConfig.CANVAS_EXTRA/2}px`; + audioLevelCanvas.style.left + = `-${interfaceConfig.CANVAS_EXTRA/2}px`; resizeAudioLevelCanvas(audioLevelCanvas, thumbWidth, thumbHeight); videoSpan.appendChild(audioLevelCanvas); @@ -213,7 +228,10 @@ const AudioLevels = { return; } - ASDrawContext.clearRect(0, 0, 300, 300); + ASDrawContext.clearRect(0, 0, + dominantSpeakerAudioElement.width, + dominantSpeakerAudioElement.height); + if (!audioLevel) { return; } diff --git a/modules/UI/avatar/Avatar.js b/modules/UI/avatar/Avatar.js index 31b489d43..ac01d21f5 100644 --- a/modules/UI/avatar/Avatar.js +++ b/modules/UI/avatar/Avatar.js @@ -48,14 +48,21 @@ export default { } avatarId = MD5.hexdigest(avatarId.trim().toLowerCase()); - // Default to using gravatar. - let urlPref = 'https://www.gravatar.com/avatar/'; - let urlSuf = "?d=wavatar&size=100"; - if (random && interfaceConfig.RANDOM_AVATAR_URL_PREFIX) { + let urlPref = null; + let urlSuf = null; + if (!random) { + urlPref = 'https://www.gravatar.com/avatar/'; + urlSuf = "?d=wavatar&size=200"; + } + else if (random && interfaceConfig.RANDOM_AVATAR_URL_PREFIX) { urlPref = interfaceConfig.RANDOM_AVATAR_URL_PREFIX; urlSuf = interfaceConfig.RANDOM_AVATAR_URL_SUFFIX; } + else { + urlPref = 'https://robohash.org/'; + urlSuf = ".png?size=200x200"; + } return urlPref + avatarId + urlSuf; } diff --git a/modules/UI/etherpad/Etherpad.js b/modules/UI/etherpad/Etherpad.js index f5f58f921..58315c9a9 100644 --- a/modules/UI/etherpad/Etherpad.js +++ b/modules/UI/etherpad/Etherpad.js @@ -3,6 +3,7 @@ import VideoLayout from "../videolayout/VideoLayout"; import LargeContainer from '../videolayout/LargeContainer'; import UIUtil from "../util/UIUtil"; +import UIEvents from "../../../service/UI/UIEvents"; import SidePanelToggler from "../side_pannels/SidePanelToggler"; import FilmStrip from '../videolayout/FilmStrip'; @@ -52,12 +53,13 @@ const DEFAULT_WIDTH = 640; */ const DEFAULT_HEIGHT = 480; -const EtherpadContainerType = "etherpad"; +const ETHERPAD_CONTAINER_TYPE = "etherpad"; /** * Container for Etherpad iframe. */ class Etherpad extends LargeContainer { + constructor (domain, name) { super(); @@ -110,9 +112,11 @@ class Etherpad extends LargeContainer { show () { const $iframe = $(this.iframe); const $container = $(this.container); + let self = this; return new Promise(resolve => { $iframe.fadeIn(300, function () { + self.bodyBackground = document.body.style.background; document.body.style.background = '#eeeeee'; $iframe.css({visibility: 'visible'}); $container.css({zIndex: 2}); @@ -124,6 +128,7 @@ class Etherpad extends LargeContainer { hide () { const $iframe = $(this.iframe); const $container = $(this.container); + document.body.style.background = this.bodyBackground; return new Promise(resolve => { $iframe.fadeOut(300, function () { @@ -133,19 +138,27 @@ class Etherpad extends LargeContainer { }); }); } + + /** + * @return {boolean} do not switch on dominant speaker event if on stage. + */ + stayOnStage () { + return true; + } } /** * Manager of the Etherpad frame. */ export default class EtherpadManager { - constructor (domain, name) { + constructor (domain, name, eventEmitter) { if (!domain || !name) { throw new Error("missing domain or name"); } this.domain = domain; this.name = name; + this.eventEmitter = eventEmitter; this.etherpad = null; } @@ -153,13 +166,17 @@ export default class EtherpadManager { return !!this.etherpad; } + isVisible() { + return VideoLayout.isLargeContainerTypeVisible(ETHERPAD_CONTAINER_TYPE); + } + /** * Create new Etherpad frame. */ openEtherpad () { this.etherpad = new Etherpad(this.domain, this.name); VideoLayout.addLargeVideoContainer( - EtherpadContainerType, + ETHERPAD_CONTAINER_TYPE, this.etherpad ); } @@ -173,10 +190,12 @@ export default class EtherpadManager { this.openEtherpad(); } - let isVisible = VideoLayout.isLargeContainerTypeVisible( - EtherpadContainerType - ); + let isVisible = this.isVisible(); - VideoLayout.showLargeVideoContainer(EtherpadContainerType, !isVisible); + VideoLayout.showLargeVideoContainer( + ETHERPAD_CONTAINER_TYPE, !isVisible); + + this.eventEmitter + .emit(UIEvents.TOGGLED_SHARED_DOCUMENT, !isVisible); } } diff --git a/modules/UI/prezi/Prezi.js b/modules/UI/prezi/Prezi.js deleted file mode 100644 index da2b41cb5..000000000 --- a/modules/UI/prezi/Prezi.js +++ /dev/null @@ -1,448 +0,0 @@ -/* global $, APP */ -/* jshint -W101 */ - -import VideoLayout from "../videolayout/VideoLayout"; -import LargeContainer from '../videolayout/LargeContainer'; -import PreziPlayer from './PreziPlayer'; -import UIUtil from '../util/UIUtil'; -import UIEvents from '../../../service/UI/UIEvents'; -import messageHandler from '../util/MessageHandler'; -import ToolbarToggler from "../toolbars/ToolbarToggler"; -import SidePanelToggler from "../side_pannels/SidePanelToggler"; -import FilmStrip from '../videolayout/FilmStrip'; - -/** - * Example of Prezi link. - */ -const defaultPreziLink = "http://prezi.com/wz7vhjycl7e6/my-prezi"; -const alphanumRegex = /^[a-z0-9-_\/&\?=;]+$/i; -/** - * Default aspect ratio for Prezi frame. - */ -const aspectRatio = 16.0 / 9.0; - -/** - * Default Prezi frame width. - */ -const DEFAULT_WIDTH = 640; -/** - * Default Prezi frame height. - */ -const DEFAULT_HEIGHT = 480; - -/** - * Indicates if the given string is an alphanumeric string. - * Note that some special characters are also allowed (-, _ , /, &, ?, =, ;) for the - * purpose of checking URIs. - * @param {string} unsafeText string to check - * @returns {boolean} - */ -function isAlphanumeric(unsafeText) { - return alphanumRegex.test(unsafeText); -} - -/** - * Returns the presentation id from the given url. - * @param {string} url Prezi link - * @returns {string} presentation id - */ -function getPresentationId (url) { - let presId = url.substring(url.indexOf("prezi.com/") + 10); - return presId.substring(0, presId.indexOf('/')); -} - -/** - * Checks if given string is Prezi url. - * @param {string} url string to check. - * @returns {boolean} - */ -function isPreziLink(url) { - if (url.indexOf('http://prezi.com/') !== 0 && url.indexOf('https://prezi.com/') !== 0) { - return false; - } - - let presId = url.substring(url.indexOf("prezi.com/") + 10); - if (!isAlphanumeric(presId) || presId.indexOf('/') < 2) { - return false; - } - - return true; -} - -/** - * Notify user that other user if already sharing Prezi. - */ -function notifyOtherIsSharingPrezi() { - messageHandler.openMessageDialog( - "dialog.sharePreziTitle", - "dialog.sharePreziMsg" - ); -} - -/** - * Ask user if he want to close Prezi he's sharing. - */ -function proposeToClosePrezi() { - return new Promise(function (resolve, reject) { - messageHandler.openTwoButtonDialog( - "dialog.removePreziTitle", - null, - "dialog.removePreziMsg", - null, - false, - "dialog.Remove", - function(e,v,m,f) { - if (v) { - resolve(); - } else { - reject(); - } - } - ); - - }); -} - -/** - * Ask user for Prezi url to share with others. - * Dialog validates client input to allow only Prezi urls. - */ -function requestPreziLink() { - const title = APP.translation.generateTranslationHTML("dialog.sharePreziTitle"); - const cancelButton = APP.translation.generateTranslationHTML("dialog.Cancel"); - const shareButton = APP.translation.generateTranslationHTML("dialog.Share"); - const backButton = APP.translation.generateTranslationHTML("dialog.Back"); - const linkError = APP.translation.generateTranslationHTML("dialog.preziLinkError"); - const i18nOptions = {url: defaultPreziLink}; - const defaultUrl = APP.translation.translateString( - "defaultPreziLink", i18nOptions - ); - - return new Promise(function (resolve, reject) { - let dialog = messageHandler.openDialogWithStates({ - state0: { - html: ` -

${title}

- `, - persistent: false, - buttons: [ - {title: cancelButton, value: false}, - {title: shareButton, value: true} - ], - focus: ':input:first', - defaultButton: 1, - submit: function (e, v, m, f) { - e.preventDefault(); - if (!v) { - reject('cancelled'); - dialog.close(); - return; - } - - let preziUrl = f.preziUrl; - if (!preziUrl) { - return; - } - - let urlValue = encodeURI(UIUtil.escapeHtml(preziUrl)); - - if (!isPreziLink(urlValue)) { - dialog.goToState('state1'); - return false; - } - - resolve(urlValue); - dialog.close(); - } - }, - - state1: { - html: `

${title}

${linkError}`, - persistent: false, - buttons: [ - {title: cancelButton, value: false}, - {title: backButton, value: true} - ], - focus: ':input:first', - defaultButton: 1, - submit: function (e, v, m, f) { - e.preventDefault(); - if (v === 0) { - reject(); - dialog.close(); - } else { - dialog.goToState('state0'); - } - } - } - }); - - }); -} - -export const PreziContainerType = "prezi"; - -/** - * Container for Prezi iframe. - */ -class PreziContainer extends LargeContainer { - - constructor ({preziId, isMy, slide, onSlideChanged}) { - super(); - this.reloadBtn = $('#reloadPresentation'); - - let preziPlayer = new PreziPlayer( - 'presentation', { - preziId, - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - controls: isMy, - debug: true - } - ); - this.preziPlayer = preziPlayer; - this.$iframe = $(preziPlayer.iframe); - - this.$iframe.attr('id', preziId); - - preziPlayer.on(PreziPlayer.EVENT_STATUS, function({value}) { - console.log("prezi status", value); - if (value == PreziPlayer.STATUS_CONTENT_READY && !isMy) { - preziPlayer.flyToStep(slide); - } - }); - - preziPlayer.on(PreziPlayer.EVENT_CURRENT_STEP, function({value}) { - console.log("event value", value); - onSlideChanged(value); - }); - } - - /** - * Change Prezi slide. - * @param {number} slide slide to show - */ - goToSlide (slide) { - if (this.preziPlayer.getCurrentStep() === slide) { - return; - } - - this.preziPlayer.flyToStep(slide); - - let animationStepsArray = this.preziPlayer.getAnimationCountOnSteps(); - if (!animationStepsArray) { - return; - } - - for (var i = 0; i < parseInt(animationStepsArray[slide]); i += 1) { - this.preziPlayer.flyToStep(slide, i); - } - } - - /** - * Show or hide "reload presentation" button. - * @param {boolean} show - */ - showReloadBtn (show) { - this.reloadBtn.css('display', show ? 'inline-block' : 'none'); - } - - show () { - return new Promise(resolve => { - this.$iframe.fadeIn(300, () => { - this.$iframe.css({opacity: 1}); - ToolbarToggler.dockToolbar(true); - resolve(); - }); - }); - } - - hide () { - return new Promise(resolve => { - this.$iframe.fadeOut(300, () => { - this.$iframe.css({opacity: 0}); - this.showReloadBtn(false); - ToolbarToggler.dockToolbar(false); - resolve(); - }); - }); - } - - onHoverIn () { - let rightOffset = window.innerWidth - this.$iframe.offset().left - this.$iframe.width(); - - this.showReloadBtn(true); - this.reloadBtn.css('right', rightOffset); - } - - onHoverOut (event) { - let e = event.toElement || event.relatedTarget; - - if (e && e.id != 'reloadPresentation' && e.id != 'header') { - this.showReloadBtn(false); - } - } - - resize (containerWidth, containerHeight) { - let height = containerHeight - FilmStrip.getFilmStripHeight(); - - let width = containerWidth; - - if (height < width / aspectRatio) { - width = Math.floor(height * aspectRatio); - } - - this.$iframe.width(width).height(height); - } - - /** - * Close Prezi frame. - */ - close () { - this.showReloadBtn(false); - this.preziPlayer.destroy(); - this.$iframe.remove(); - } -} - -/** - * Manager of Prezi frames. - */ -export default class PreziManager { - constructor (emitter) { - this.emitter = emitter; - - this.userId = null; - this.url = null; - this.prezi = null; - - $("#reloadPresentationLink").click(this.reloadPresentation.bind(this)); - } - - get isPresenting () { - return !!this.userId; - } - - get isMyPrezi () { - return this.userId === APP.conference.localId; - } - - /** - * Check if user is currently sharing. - * @param {string} id user id to check for - */ - isSharing (id) { - return this.userId === id; - } - - handlePreziButtonClicked () { - if (!this.isPresenting) { - requestPreziLink().then( - url => this.emitter.emit(UIEvents.SHARE_PREZI, url, 0), - err => console.error('PREZI CANCELED', err) - ); - return; - } - - if (this.isMyPrezi) { - proposeToClosePrezi().then(() => this.emitter.emit(UIEvents.STOP_SHARING_PREZI)); - } else { - notifyOtherIsSharingPrezi(); - } - } - - /** - * Reload current Prezi frame. - */ - reloadPresentation () { - if (!this.prezi) { - return; - } - let iframe = this.prezi.$iframe[0]; - iframe.src = iframe.src; - } - - /** - * Show Prezi. Create new Prezi if there is no Prezi yet. - * @param {string} id owner id - * @param {string} url Prezi url - * @param {number} slide slide to show - */ - showPrezi (id, url, slide) { - if (!this.isPresenting) { - this.createPrezi(id, url, slide); - } - - if (this.userId === id && this.url === url) { - this.prezi.goToSlide(slide); - } else { - console.error(this.userId, id); - console.error(this.url, url); - throw new Error("unexpected presentation change"); - } - } - - /** - * Create new Prezi frame.. - * @param {string} id owner id - * @param {string} url Prezi url - * @param {number} slide slide to show - */ - createPrezi (id, url, slide) { - console.log("presentation added", url); - - this.userId = id; - this.url = url; - - let preziId = getPresentationId(url); - let elementId = `participant_${id}_${preziId}`; - - this.$thumb = $(VideoLayout.addRemoteVideoContainer(elementId)); - VideoLayout.resizeThumbnails(); - this.$thumb.css({ - 'background-image': 'url(../images/avatarprezi.png)' - }).click(() => VideoLayout.showLargeVideoContainer(PreziContainerType, true)); - - this.prezi = new PreziContainer({ - preziId, - isMy: this.isMyPrezi, - slide, - onSlideChanged: newSlide => { - if (this.isMyPrezi) { - this.emitter.emit(UIEvents.SHARE_PREZI, url, newSlide); - } - } - }); - - VideoLayout.addLargeVideoContainer(PreziContainerType, this.prezi); - VideoLayout.showLargeVideoContainer(PreziContainerType, true); - } - - /** - * Close Prezi. - * @param {string} id owner id - */ - removePrezi (id) { - if (this.userId !== id) { - throw new Error(`cannot close presentation from ${this.userId} instead of ${id}`); - } - - this.$thumb.remove(); - this.$thumb = null; - - // wait until Prezi is hidden, then remove it - VideoLayout.showLargeVideoContainer(PreziContainerType, false).then(() => { - console.log("presentation removed", this.url); - - VideoLayout.removeLargeVideoContainer(PreziContainerType); - - this.userId = null; - this.url = null; - this.prezi.close(); - this.prezi = null; - }); - } -} diff --git a/modules/UI/prezi/PreziPlayer.js b/modules/UI/prezi/PreziPlayer.js deleted file mode 100644 index b962057ac..000000000 --- a/modules/UI/prezi/PreziPlayer.js +++ /dev/null @@ -1,290 +0,0 @@ -/* jshint -W101 */ - -var __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; - -PreziPlayer.API_VERSION = 1; -PreziPlayer.CURRENT_STEP = 'currentStep'; -PreziPlayer.CURRENT_ANIMATION_STEP = 'currentAnimationStep'; -PreziPlayer.CURRENT_OBJECT = 'currentObject'; -PreziPlayer.STATUS_LOADING = 'loading'; -PreziPlayer.STATUS_READY = 'ready'; -PreziPlayer.STATUS_CONTENT_READY = 'contentready'; -PreziPlayer.EVENT_CURRENT_STEP = "currentStepChange"; -PreziPlayer.EVENT_CURRENT_ANIMATION_STEP = "currentAnimationStepChange"; -PreziPlayer.EVENT_CURRENT_OBJECT = "currentObjectChange"; -PreziPlayer.EVENT_STATUS = "statusChange"; -PreziPlayer.EVENT_PLAYING = "isAutoPlayingChange"; -PreziPlayer.EVENT_IS_MOVING = "isMovingChange"; -PreziPlayer.domain = "https://prezi.com"; -PreziPlayer.path = "/player/"; -PreziPlayer.players = {}; -PreziPlayer.binded_methods = ['changesHandler']; - -PreziPlayer.createMultiplePlayers = function(optionArray){ - for(var i=0; i 0 && - obj.values.animationCountOnSteps && - obj.values.animationCountOnSteps[step] <= animation_step) { - animation_step = obj.values.animationCountOnSteps[step]; - } - // jump to animation steps by calling flyToNextStep() - function doAnimationSteps() { - if (obj.values.isMoving) { - setTimeout(doAnimationSteps, 100); // wait until the flight ends - return; - } - while (animation_step-- > 0) { - obj.flyToNextStep(); // do the animation steps - } - } - setTimeout(doAnimationSteps, 200); // 200ms is the internal "reporting" time - // jump to the step - return this.sendMessage({ - 'action': 'present', - 'data': ['moveToStep', step] - }); -}; - -PreziPlayer.prototype.toObject = /* toObject is DEPRECATED */ -PreziPlayer.prototype.flyToObject = function(objectId) { - return this.sendMessage({ - 'action': 'present', - 'data': ['moveToObject', objectId] - }); -}; - -PreziPlayer.prototype.play = function(defaultDelay) { - return this.sendMessage({ - 'action': 'present', - 'data': ['startAutoPlay', defaultDelay] - }); -}; - -PreziPlayer.prototype.stop = function() { - return this.sendMessage({ - 'action': 'present', - 'data': ['stopAutoPlay'] - }); -}; - -PreziPlayer.prototype.pause = function(defaultDelay) { - return this.sendMessage({ - 'action': 'present', - 'data': ['pauseAutoPlay', defaultDelay] - }); -}; - -PreziPlayer.prototype.getCurrentStep = function() { - return this.values.currentStep; -}; - -PreziPlayer.prototype.getCurrentAnimationStep = function() { - return this.values.currentAnimationStep; -}; - -PreziPlayer.prototype.getCurrentObject = function() { - return this.values.currentObject; -}; - -PreziPlayer.prototype.getStatus = function() { - return this.values.status; -}; - -PreziPlayer.prototype.isPlaying = function() { - return this.values.isAutoPlaying; -}; - -PreziPlayer.prototype.getStepCount = function() { - return this.values.stepCount; -}; - -PreziPlayer.prototype.getAnimationCountOnSteps = function() { - return this.values.animationCountOnSteps; -}; - -PreziPlayer.prototype.getTitle = function() { - return this.values.title; -}; - -PreziPlayer.prototype.setDimensions = function(dims) { - for (var parameter in dims) { - this.iframe[parameter] = dims[parameter]; - } -}; - -PreziPlayer.prototype.getDimensions = function() { - return { - width: parseInt(this.iframe.width, 10), - height: parseInt(this.iframe.height, 10) - }; -}; - -PreziPlayer.prototype.on = function(event, callback) { - this.callbacks.push({ - event: event, - callback: callback - }); -}; - -PreziPlayer.prototype.off = function(event, callback) { - var j, item; - if (event === undefined) { - this.callbacks = []; - } - j = this.callbacks.length; - while (j--) { - item = this.callbacks[j]; - if (item && item.event === event && (callback === undefined || item.callback === callback)){ - this.callbacks.splice(j, 1); - } - } -}; - -if (window.addEventListener) { - window.addEventListener('message', PreziPlayer.messageReceived, false); -} else { - window.attachEvent('onmessage', PreziPlayer.messageReceived); -} - -window.PreziPlayer = PreziPlayer; - -export default PreziPlayer; diff --git a/modules/UI/shared_video/SharedVideo.js b/modules/UI/shared_video/SharedVideo.js new file mode 100644 index 000000000..f2b578a63 --- /dev/null +++ b/modules/UI/shared_video/SharedVideo.js @@ -0,0 +1,582 @@ +/* global $, APP, YT, onPlayerReady, onPlayerStateChange, onPlayerError */ + +import messageHandler from '../util/MessageHandler'; +import UIUtil from '../util/UIUtil'; +import UIEvents from '../../../service/UI/UIEvents'; + +import VideoLayout from "../videolayout/VideoLayout"; +import LargeContainer from '../videolayout/LargeContainer'; +import SmallVideo from '../videolayout/SmallVideo'; +import FilmStrip from '../videolayout/FilmStrip'; +import ToolbarToggler from "../toolbars/ToolbarToggler"; + +export const SHARED_VIDEO_CONTAINER_TYPE = "sharedvideo"; + +/** + * Example shared video link. + * @type {string} + */ +const defaultSharedVideoLink = "https://www.youtube.com/watch?v=xNXN7CZk8X0"; +const updateInterval = 5000; // milliseconds +/** + * Manager of shared video. + */ +export default class SharedVideoManager { + constructor (emitter) { + this.emitter = emitter; + this.isSharedVideoShown = false; + this.isPlayerAPILoaded = false; + } + + /** + * Starts shared video by asking user for url, or if its already working + * asks whether the user wants to stop sharing the video. + */ + toggleSharedVideo () { + if(!this.isSharedVideoShown) { + requestVideoLink().then( + url => this.emitter.emit( + UIEvents.UPDATE_SHARED_VIDEO, url, 'start'), + err => console.error('SHARED VIDEO CANCELED', err) + ); + return; + } + + if(APP.conference.isLocalId(this.from)) { + showStopVideoPropmpt().then(() => + this.emitter.emit(UIEvents.UPDATE_SHARED_VIDEO, null, 'stop')); + } else { + messageHandler.openMessageDialog( + "dialog.shareVideoTitle", + "dialog.alreadySharedVideoMsg" + ); + } + } + + /** + * Shows the player component and starts the checking function + * that will be sending updates, if we are the one shared the video + * @param id the id of the sender of the command + * @param url the video url + * @param attributes + */ + showSharedVideo (id, url, attributes) { + if (this.isSharedVideoShown) + return; + + // the video url + this.url = url; + + // the owner of the video + this.from = id; + + // This code loads the IFrame Player API code asynchronously. + var tag = document.createElement('script'); + + tag.src = "https://www.youtube.com/iframe_api"; + var firstScriptTag = document.getElementsByTagName('script')[0]; + firstScriptTag.parentNode.insertBefore(tag, firstScriptTag); + + // sometimes we receive errors like player not defined + // or player.pauseVideo is not a function + // we need to operate with player after start playing + // self.player will be defined once it start playing + // and will process any initial attributes if any + this.initialAttributes = null; + + var self = this; + if(self.isPlayerAPILoaded) + window.onYouTubeIframeAPIReady(); + else + window.onYouTubeIframeAPIReady = function() { + self.isPlayerAPILoaded = true; + let showControls = APP.conference.isLocalId(self.from) ? 1 : 0; + new YT.Player('sharedVideoIFrame', { + height: '100%', + width: '100%', + videoId: self.url, + playerVars: { + 'origin': location.origin, + 'fs': '0', + 'autoplay': 0, + 'controls': showControls, + 'rel' : 0 + }, + events: { + 'onReady': onPlayerReady, + 'onStateChange': onPlayerStateChange, + 'onError': onPlayerError + } + }); + }; + + window.onPlayerStateChange = function(event) { + if (event.data == YT.PlayerState.PLAYING) { + self.playerPaused = false; + + self.player = event.target; + + if(self.initialAttributes) + { + self.processAttributes( + self.player, self.initialAttributes, self.playerPaused); + self.initialAttributes = null; + } + + self.updateCheck(); + } else if (event.data == YT.PlayerState.PAUSED) { + self.playerPaused = true; + self.updateCheck(true); + } + }; + + window.onPlayerReady = function(event) { + let player = event.target; + // do not relay on autoplay as it is not sending all of the events + // in onPlayerStateChange + player.playVideo(); + + let thumb = new SharedVideoThumb(self.url); + thumb.setDisplayName(player.getVideoData().title); + VideoLayout.addParticipantContainer(self.url, thumb); + + let iframe = player.getIframe(); + self.sharedVideo = new SharedVideoContainer( + {url, iframe, player}); + + VideoLayout.addLargeVideoContainer( + SHARED_VIDEO_CONTAINER_TYPE, self.sharedVideo); + VideoLayout.handleVideoThumbClicked(self.url); + + self.isSharedVideoShown = true; + + // If we are sending the command and we are starting the player + // we need to continuously send the player current time position + if(APP.conference.isLocalId(self.from)) { + self.intervalId = setInterval( + self.updateCheck.bind(self), + updateInterval); + } + + if(self.player) + self.processAttributes( + self.player, attributes, self.playerPaused); + else { + self.initialAttributes = attributes; + } + }; + + window.onPlayerError = function(event) { + console.error("Error in the player:" + event.data); + }; + } + + /** + * Process attributes, whether player needs to be paused or seek. + * @param player the player to operate over + * @param attributes the attributes with the player state we want + * @param playerPaused current saved state for the player + */ + processAttributes (player, attributes, playerPaused) + { + if(!attributes) + return; + + if (attributes.state == 'playing') { + + this.processTime(player, attributes); + + // lets check the volume + if (attributes.volume !== undefined && + player.getVolume() != attributes.volume) { + player.setVolume(attributes.volume); + console.info("Player change of volume:" + attributes.volume); + } + + if(playerPaused) + player.playVideo(); + + } else if (attributes.state == 'pause') { + // if its not paused, pause it + player.pauseVideo(); + + this.processTime(player, attributes); + } + } + + /** + * Check for time in attributes and if needed seek in current player + * @param player the player to operate over + * @param attributes the attributes with the player state we want + */ + processTime (player, attributes) + { + // check received time and current time + let currentPosition = player.getCurrentTime(); + let diff = Math.abs(attributes.time - currentPosition); + + // if we drift more than the interval for checking + // sync, the interval is in milliseconds + if(diff > updateInterval/1000) { + console.info("Player seekTo:", attributes.time, + " current time is:", currentPosition, " diff:", diff); + player.seekTo(attributes.time); + } + } + + /** + * Checks current state of the player and fire an event with the values. + */ + updateCheck(sendPauseEvent) + { + // ignore update checks if we are not the owner of the video + // or there is still no player defined + if(!APP.conference.isLocalId(this.from) || !this.player) + return; + + let state = this.player.getPlayerState(); + // if its paused and haven't been pause - send paused + if (state === YT.PlayerState.PAUSED && sendPauseEvent) { + this.emitter.emit(UIEvents.UPDATE_SHARED_VIDEO, + this.url, 'pause', this.player.getCurrentTime()); + } + // if its playing and it was paused - send update with time + // if its playing and was playing just send update with time + else if (state === YT.PlayerState.PLAYING) { + this.emitter.emit(UIEvents.UPDATE_SHARED_VIDEO, + this.url, 'playing', + this.player.getCurrentTime(), + this.player.isMuted() ? 0 : this.player.getVolume()); + } + } + + /** + * Updates video, if its not playing and needs starting or + * if its playing and needs to be paysed + * @param id the id of the sender of the command + * @param url the video url + * @param attributes + */ + updateSharedVideo (id, url, attributes) { + // if we are sending the event ignore + if(APP.conference.isLocalId(this.from)) { + return; + } + + if(!this.isSharedVideoShown) { + this.showSharedVideo(id, url, attributes); + return; + } + + if(!this.player) + this.initialAttributes = attributes; + else { + this.processAttributes(this.player, attributes, this.playerPaused); + } + } + + /** + * Stop shared video if it is currently showed. If the user started the + * shared video is the one in the id (called when user + * left and we want to remove video if the user sharing it left). + * @param id the id of the sender of the command + */ + stopSharedVideo (id) { + if (!this.isSharedVideoShown) + return; + + if(this.from !== id) + return; + + if(this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + VideoLayout.removeParticipantContainer(this.url); + + VideoLayout.showLargeVideoContainer(SHARED_VIDEO_CONTAINER_TYPE, false) + .then(() => { + VideoLayout.removeLargeVideoContainer( + SHARED_VIDEO_CONTAINER_TYPE); + + this.player.destroy(); + this.player = null; + }); + + this.url = null; + this.isSharedVideoShown = false; + } +} + +/** + * Container for shared video iframe. + */ +class SharedVideoContainer extends LargeContainer { + + constructor ({url, iframe, player}) { + super(); + + this.$iframe = $(iframe); + this.url = url; + this.player = player; + } + + get $video () { + return this.$iframe; + } + + show () { + let self = this; + return new Promise(resolve => { + this.$iframe.fadeIn(300, () => { + self.bodyBackground = document.body.style.background; + document.body.style.background = 'black'; + this.$iframe.css({opacity: 1}); + resolve(); + }); + }); + } + + hide () { + let self = this; + return new Promise(resolve => { + this.$iframe.fadeOut(300, () => { + document.body.style.background = self.bodyBackground; + this.$iframe.css({opacity: 0}); + resolve(); + }); + }); + } + + onHoverIn () { + ToolbarToggler.showToolbar(); + } + + get id () { + return this.url; + } + + resize (containerWidth, containerHeight) { + let height = containerHeight - FilmStrip.getFilmStripHeight(); + + let width = containerWidth; + + this.$iframe.width(width).height(height); + } + + /** + * @return {boolean} do not switch on dominant speaker event if on stage. + */ + stayOnStage () { + return false; + } +} + +function SharedVideoThumb (url) +{ + this.id = url; + + this.url = url; + this.setVideoType(SHARED_VIDEO_CONTAINER_TYPE); + this.videoSpanId = "sharedVideoContainer"; + this.container = this.createContainer(this.videoSpanId); + this.container.onclick = this.videoClick.bind(this); + + SmallVideo.call(this, VideoLayout); + this.isVideoMuted = true; +} +SharedVideoThumb.prototype = Object.create(SmallVideo.prototype); +SharedVideoThumb.prototype.constructor = SharedVideoThumb; + +/** + * hide display name + */ + +SharedVideoThumb.prototype.setDeviceAvailabilityIcons = function () {}; + +SharedVideoThumb.prototype.avatarChanged = function () {}; + +SharedVideoThumb.prototype.createContainer = function (spanId) { + var container = document.createElement('span'); + container.id = spanId; + container.className = 'videocontainer'; + + // add the avatar + var avatar = document.createElement('img'); + avatar.id = 'avatar_' + this.id; + avatar.className = 'sharedVideoAvatar'; + avatar.src = "https://img.youtube.com/vi/" + this.url + "/0.jpg"; + container.appendChild(avatar); + + var remotes = document.getElementById('remoteVideos'); + return remotes.appendChild(container); +}; + +/** + * The thumb click handler. + */ +SharedVideoThumb.prototype.videoClick = function () { + VideoLayout.handleVideoThumbClicked(this.url); +}; + +/** + * Removes RemoteVideo from the page. + */ +SharedVideoThumb.prototype.remove = function () { + console.log("Remove shared video thumb", this.id); + + // Make sure that the large video is updated if are removing its + // corresponding small video. + this.VideoLayout.updateAfterThumbRemoved(this.id); + + // Remove whole container + if (this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } +}; + +/** + * Sets the display name for the thumb. + */ +SharedVideoThumb.prototype.setDisplayName = function(displayName) { + if (!this.container) { + console.warn( "Unable to set displayName - " + this.videoSpanId + + " does not exist"); + return; + } + + var nameSpan = $('#' + this.videoSpanId + '>span.displayname'); + + // If we already have a display name for this video. + if (nameSpan.length > 0) { + if (displayName && displayName.length > 0) { + $('#' + this.videoSpanId + '_name').text(displayName); + } + } else { + nameSpan = document.createElement('span'); + nameSpan.className = 'displayname'; + $('#' + this.videoSpanId)[0].appendChild(nameSpan); + + if (displayName && displayName.length > 0) + $(nameSpan).text(displayName); + nameSpan.id = this.videoSpanId + '_name'; + } + +}; + +/** + * Checks if given string is youtube url. + * @param {string} url string to check. + * @returns {boolean} + */ +function getYoutubeLink(url) { + let p = /^(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})(?:\S+)?$/;//jshint ignore:line + return (url.match(p)) ? RegExp.$1 : false; +} + +/** + * Ask user if he want to close shared video. + */ +function showStopVideoPropmpt() { + return new Promise(function (resolve, reject) { + messageHandler.openTwoButtonDialog( + "dialog.removeSharedVideoTitle", + null, + "dialog.removeSharedVideoMsg", + null, + false, + "dialog.Remove", + function(e,v,m,f) { + if (v) { + resolve(); + } else { + reject(); + } + } + ); + + }); +} + +/** + * Ask user for shared video url to share with others. + * Dialog validates client input to allow only youtube urls. + */ +function requestVideoLink() { + let i18n = APP.translation; + const title = i18n.generateTranslationHTML("dialog.shareVideoTitle"); + const cancelButton = i18n.generateTranslationHTML("dialog.Cancel"); + const shareButton = i18n.generateTranslationHTML("dialog.Share"); + const backButton = i18n.generateTranslationHTML("dialog.Back"); + const linkError + = i18n.generateTranslationHTML("dialog.shareVideoLinkError"); + const i18nOptions = {url: defaultSharedVideoLink}; + const defaultUrl = i18n.translateString("defaultLink", i18nOptions); + + return new Promise(function (resolve, reject) { + let dialog = messageHandler.openDialogWithStates({ + state0: { + html: ` +

${title}

+ `, + persistent: false, + buttons: [ + {title: cancelButton, value: false}, + {title: shareButton, value: true} + ], + focus: ':input:first', + defaultButton: 1, + submit: function (e, v, m, f) { + e.preventDefault(); + if (!v) { + reject('cancelled'); + dialog.close(); + return; + } + + let sharedVideoUrl = f.sharedVideoUrl; + if (!sharedVideoUrl) { + return; + } + + let urlValue = encodeURI(UIUtil.escapeHtml(sharedVideoUrl)); + let yVideoId = getYoutubeLink(urlValue); + if (!yVideoId) { + dialog.goToState('state1'); + return false; + } + + resolve(yVideoId); + dialog.close(); + } + }, + + state1: { + html: `

${title}

${linkError}`, + persistent: false, + buttons: [ + {title: cancelButton, value: false}, + {title: backButton, value: true} + ], + focus: ':input:first', + defaultButton: 1, + submit: function (e, v, m, f) { + e.preventDefault(); + if (v === 0) { + reject(); + dialog.close(); + } else { + dialog.goToState('state0'); + } + } + } + }); + + }); +} + diff --git a/modules/UI/side_pannels/SidePanelToggler.js b/modules/UI/side_pannels/SidePanelToggler.js index 9336d65b1..8d4d6f47b 100644 --- a/modules/UI/side_pannels/SidePanelToggler.js +++ b/modules/UI/side_pannels/SidePanelToggler.js @@ -93,7 +93,7 @@ function toggle (object, selector, onOpenComplete, function resizeVideoArea(isSidePanelVisible, completeFunction) { VideoLayout.resizeVideoArea(!isSidePanelVisible, false, - true, + false, completeFunction); } diff --git a/modules/UI/side_pannels/chat/Chat.js b/modules/UI/side_pannels/chat/Chat.js index 0264350f3..00fe47dc7 100644 --- a/modules/UI/side_pannels/chat/Chat.js +++ b/modules/UI/side_pannels/chat/Chat.js @@ -35,7 +35,7 @@ function setVisualNotification(show) { var leftIndent = (UIUtil.getTextWidth(chatButtonElement) - UIUtil.getTextWidth(unreadMsgElement)) / 2; var topIndent = (UIUtil.getTextHeight(chatButtonElement) - - UIUtil.getTextHeight(unreadMsgElement)) / 2 - 3; + UIUtil.getTextHeight(unreadMsgElement)) / 2 - 5; unreadMsgElement.setAttribute( 'style', diff --git a/modules/UI/side_pannels/settings/SettingsMenu.js b/modules/UI/side_pannels/settings/SettingsMenu.js index b2baecd0a..266f10e78 100644 --- a/modules/UI/side_pannels/settings/SettingsMenu.js +++ b/modules/UI/side_pannels/settings/SettingsMenu.js @@ -89,6 +89,14 @@ export default { ); }); + // FOLLOW ME + $("#followMeOptions").change(function () { + let isFollowMeEnabled = $("#followMeCheckBox").is(":checked"); + emitter.emit( + UIEvents.FOLLOW_ME_ENABLED, + isFollowMeEnabled + ); + }); // LANGUAGES BOX let languagesBox = $("#languages_selectbox"); @@ -135,6 +143,19 @@ export default { $("#startVideoMuted").attr("checked", startVideoMuted); }, + /** + * Shows/hides the follow me options in the settings dialog. + * + * @param {boolean} show {true} to show those options, {false} to hide them + */ + showFollowMeOptions (show) { + if (show) { + $("#followMeOptions").css("display", "block"); + } else { + $("#followMeOptions").css("display", "none"); + } + }, + /** * Check if settings menu is visible or not. * @returns {boolean} diff --git a/modules/UI/toolbars/Toolbar.js b/modules/UI/toolbars/Toolbar.js index e2b79b5bf..687e91783 100644 --- a/modules/UI/toolbars/Toolbar.js +++ b/modules/UI/toolbars/Toolbar.js @@ -122,14 +122,14 @@ const buttonHandlers = { AnalyticsAdapter.sendEvent('toolbar.chat.toggled'); emitter.emit(UIEvents.TOGGLE_CHAT); }, - "toolbar_button_prezi": function () { - AnalyticsAdapter.sendEvent('toolbar.prezi.clicked'); - emitter.emit(UIEvents.PREZI_CLICKED); - }, "toolbar_button_etherpad": function () { AnalyticsAdapter.sendEvent('toolbar.etherpad.clicked'); emitter.emit(UIEvents.ETHERPAD_CLICKED); }, + "toolbar_button_sharedvideo": function () { + AnalyticsAdapter.sendEvent('toolbar.sharedvideo.clicked'); + emitter.emit(UIEvents.SHARED_VIDEO_CLICKED); + }, "toolbar_button_desktopsharing": function () { if (APP.conference.isSharingScreen) { AnalyticsAdapter.sendEvent('toolbar.screen.disabled'); @@ -188,7 +188,6 @@ const defaultToolbarButtons = { 'security': '#toolbar_button_security', 'invite': '#toolbar_button_link', 'chat': '#toolbar_button_chat', - 'prezi': '#toolbar_button_prezi', 'etherpad': '#toolbar_button_etherpad', 'fullscreen': '#toolbar_button_fullScreen', 'settings': '#toolbar_button_settings', @@ -246,15 +245,6 @@ const Toolbar = { } }, - /** - * Disables and enables some of the buttons. - */ - setupButtonsFromConfig () { - if (!UIUtil.isButtonEnabled('prezi')) { - $("#toolbar_button_prezi").css({display: "none"}); - } - }, - /** * Unlocks the lock button state. */ @@ -298,6 +288,16 @@ const Toolbar = { } }, + // Shows or hides the 'shared video' button. + showSharedVideoButton () { + if (UIUtil.isButtonEnabled('sharedvideo') + && config.disableThirdPartyRequests !== true) { + $('#toolbar_button_sharedvideo').css({display: "inline-block"}); + } else { + $('#toolbar_button_sharedvideo').css({display: "none"}); + } + }, + // checks whether recording is enabled and whether we have params // to start automatically recording checkAutoRecord () { diff --git a/modules/UI/util/UIUtil.js b/modules/UI/util/UIUtil.js index b31589e7b..f34c2544b 100644 --- a/modules/UI/util/UIUtil.js +++ b/modules/UI/util/UIUtil.js @@ -124,9 +124,7 @@ isButtonEnabled: function (name) { var isEnabled = interfaceConfig.TOOLBAR_BUTTONS.indexOf(name) !== -1; - if (name === 'prezi') { - return isEnabled && !config.disablePrezi; - } else if (name === 'recording') { + if (name === 'recording') { return isEnabled && config.enableRecording; } return isEnabled; diff --git a/modules/UI/videolayout/ConnectionIndicator.js b/modules/UI/videolayout/ConnectionIndicator.js index 642dc9754..589f15ffe 100644 --- a/modules/UI/videolayout/ConnectionIndicator.js +++ b/modules/UI/videolayout/ConnectionIndicator.js @@ -1,6 +1,7 @@ -/* global APP, $ */ +/* global APP, $, config */ /* jshint -W101 */ import JitsiPopover from "../util/JitsiPopover"; +import VideoLayout from "./VideoLayout"; /** * Constructs new connection indicator. @@ -14,6 +15,7 @@ function ConnectionIndicator(videoContainer, id) { this.bitrate = null; this.showMoreValue = false; this.resolution = null; + this.isResolutionHD = null; this.transport = []; this.popover = null; this.id = id; @@ -292,7 +294,6 @@ ConnectionIndicator.prototype.remove = function() { */ ConnectionIndicator.prototype.updateConnectionQuality = function (percent, object) { - if (percent === null) { this.connectionIndicatorContainer.style.display = "none"; this.popover.forceHide(); @@ -316,6 +317,10 @@ ConnectionIndicator.prototype.updateConnectionQuality = ConnectionIndicator.connectionQualityValues[quality]; } } + if (object.isResolutionHD) { + this.isResolutionHD = object.isResolutionHD; + } + this.updateResolutionIndicator(); this.updatePopoverData(); }; @@ -325,6 +330,7 @@ ConnectionIndicator.prototype.updateConnectionQuality = */ ConnectionIndicator.prototype.updateResolution = function (resolution) { this.resolution = resolution; + this.updateResolutionIndicator(); this.updatePopoverData(); }; @@ -354,4 +360,29 @@ ConnectionIndicator.prototype.hideIndicator = function () { this.popover.forceHide(); }; +/** + * Updates the resolution indicator. + */ +ConnectionIndicator.prototype.updateResolutionIndicator = function () { + + if (this.id !== null + && VideoLayout.isCurrentlyOnLarge(this.id)) { + + let showResolutionLabel = false; + + if (this.isResolutionHD !== null) + showResolutionLabel = this.isResolutionHD; + else if (this.resolution !== null) { + let resolutions = this.resolution || {}; + Object.keys(resolutions).map(function (ssrc) { + let {width, height} = resolutions[ssrc]; + if (height >= config.minHDHeight) + showResolutionLabel = true; + }); + } + + VideoLayout.updateResolutionLabel(showResolutionLabel); + } +}; + export default ConnectionIndicator; diff --git a/modules/UI/videolayout/FilmStrip.js b/modules/UI/videolayout/FilmStrip.js index 2e0a8ce50..7bdb9245b 100644 --- a/modules/UI/videolayout/FilmStrip.js +++ b/modules/UI/videolayout/FilmStrip.js @@ -1,16 +1,44 @@ /* global $, APP, interfaceConfig, config*/ +import UIEvents from "../../../service/UI/UIEvents"; import UIUtil from "../util/UIUtil"; -const thumbAspectRatio = 16.0 / 9.0; +const thumbAspectRatio = 1 / 1; const FilmStrip = { - init () { + /** + * + * @param eventEmitter the {EventEmitter} through which {FilmStrip} is to + * emit/fire {UIEvents} (such as {UIEvents.TOGGLED_FILM_STRIP}). + */ + init (eventEmitter) { this.filmStrip = $('#remoteVideos'); + this.eventEmitter = eventEmitter; }, - toggleFilmStrip () { + /** + * Toggles the visibility of the film strip. + * + * @param visible optional {Boolean} which specifies the desired visibility + * of the film strip. If not specified, the visibility will be flipped + * (i.e. toggled); otherwise, the visibility will be set to the specified + * value. + */ + toggleFilmStrip (visible) { + if (typeof visible === 'boolean' + && this.isFilmStripVisible() == visible) { + return; + } + this.filmStrip.toggleClass("hidden"); + + // Emit/fire UIEvents.TOGGLED_FILM_STRIP. + var eventEmitter = this.eventEmitter; + if (eventEmitter) { + eventEmitter.emit( + UIEvents.TOGGLED_FILM_STRIP, + this.isFilmStripVisible()); + } }, isFilmStripVisible () { @@ -44,11 +72,7 @@ const FilmStrip = { * that we want to take into account when calculating the film strip width. */ calculateThumbnailSize (isSideBarVisible) { - // Calculate the available height, which is the inner window height - // minus 39px for the header minus 2px for the delimiter lines on the - // top and bottom of the large video, minus the 36px space inside the - // remoteVideos container used for highlighting shadow. - let availableHeight = 100; + let availableHeight = interfaceConfig.FILM_STRIP_MAX_HEIGHT; let numvids = this.getThumbs(true).length; @@ -80,17 +104,17 @@ const FilmStrip = { let maxHeight // If the MAX_HEIGHT property hasn't been specified // we have the static value. - = Math.min( interfaceConfig.FILM_STRIP_MAX_HEIGHT || 160, + = Math.min( interfaceConfig.FILM_STRIP_MAX_HEIGHT || 120, availableHeight); availableHeight - = Math.min( maxHeight, - availableWidth / thumbAspectRatio, - window.innerHeight - 18); + = Math.min( maxHeight, window.innerHeight - 18); - if (availableHeight < availableWidth / thumbAspectRatio) { - availableWidth = Math.floor(availableHeight * thumbAspectRatio); + if (availableHeight < availableWidth) { + availableWidth = availableHeight; } + else + availableHeight = availableWidth; return { thumbWidth: availableWidth, diff --git a/modules/UI/videolayout/LargeContainer.js b/modules/UI/videolayout/LargeContainer.js index 319ccef62..0f0dd242d 100644 --- a/modules/UI/videolayout/LargeContainer.js +++ b/modules/UI/videolayout/LargeContainer.js @@ -38,4 +38,27 @@ export default class LargeContainer { */ onHoverOut (e) { } + + /** + * Update video stream. + * @param {JitsiTrack?} stream new stream + * @param {string} videoType video type + */ + setStream (stream, videoType) { + } + + /** + * Show or hide user avatar. + * @param {boolean} show + */ + showAvatar (show) { + } + + /** + * Whether current container needs to be switched on dominant speaker event + * when the container is on stage. + * @return {boolean} + */ + stayOnStage () { + } } diff --git a/modules/UI/videolayout/LargeVideo.js b/modules/UI/videolayout/LargeVideo.js index 9f27bb8b2..dc4ed19cf 100644 --- a/modules/UI/videolayout/LargeVideo.js +++ b/modules/UI/videolayout/LargeVideo.js @@ -8,9 +8,10 @@ import FilmStrip from './FilmStrip'; import Avatar from "../avatar/Avatar"; import {createDeferred} from '../../util/helpers'; -const avatarSize = interfaceConfig.DOMINANT_SPEAKER_AVATAR_SIZE; const FADE_DURATION_MS = 300; +export const VIDEO_CONTAINER_TYPE = "camera"; + /** * Get stream id. * @param {JitsiTrack?} stream @@ -150,8 +151,6 @@ function getDesktopVideoPosition(videoWidth, return { horizontalIndent, verticalIndent }; } -export const VideoContainerType = "video"; - /** * Container for user video. */ @@ -175,6 +174,8 @@ class VideoContainer extends LargeContainer { this.$avatar = $('#dominantSpeaker'); this.$wrapper = $('#largeVideoWrapper'); + this.avatarHeight = $("#dominantSpeakerAvatar").height(); + // This does not work with Temasys plugin - has to be a property to be // copied between new elements //this.$video.on('play', onPlay); @@ -245,7 +246,7 @@ class VideoContainer extends LargeContainer { containerWidth, containerHeight); // update avatar position - let top = containerHeight / 2 - avatarSize / 4 * 3; + let top = containerHeight / 2 - this.avatarHeight / 4 * 3; this.$avatar.css('top', top); @@ -332,6 +333,10 @@ class VideoContainer extends LargeContainer { } hide () { + // as the container is hidden/replaced by another container + // hide its avatar + this.showAvatar(false); + // its already hidden if (!this.isVisible) { return Promise.resolve(); @@ -345,6 +350,13 @@ class VideoContainer extends LargeContainer { }); }); } + + /** + * @return {boolean} switch on dominant speaker event if on stage. + */ + stayOnStage () { + return false; + } } /** @@ -354,9 +366,13 @@ export default class LargeVideoManager { constructor () { this.containers = {}; - this.state = VideoContainerType; - this.videoContainer = new VideoContainer(() => this.resizeContainer(VideoContainerType)); - this.addContainer(VideoContainerType, this.videoContainer); + this.state = VIDEO_CONTAINER_TYPE; + this.videoContainer = new VideoContainer( + () => this.resizeContainer(VIDEO_CONTAINER_TYPE)); + this.addContainer(VIDEO_CONTAINER_TYPE, this.videoContainer); + + // use the same video container to handle and desktop tracks + this.addContainer("desktop", this.videoContainer); this.width = 0; this.height = 0; @@ -368,22 +384,26 @@ export default class LargeVideoManager { }); if (interfaceConfig.SHOW_JITSI_WATERMARK) { - let leftWatermarkDiv = this.$container.find("div.watermark.leftwatermark"); + let leftWatermarkDiv + = this.$container.find("div.watermark.leftwatermark"); leftWatermarkDiv.css({display: 'block'}); - leftWatermarkDiv.parent().attr('href', interfaceConfig.JITSI_WATERMARK_LINK); + leftWatermarkDiv.parent().attr( + 'href', interfaceConfig.JITSI_WATERMARK_LINK); } if (interfaceConfig.SHOW_BRAND_WATERMARK) { - let rightWatermarkDiv = this.$container.find("div.watermark.rightwatermark"); + let rightWatermarkDiv + = this.$container.find("div.watermark.rightwatermark"); rightWatermarkDiv.css({ display: 'block', backgroundImage: 'url(images/rightwatermark.png)' }); - rightWatermarkDiv.parent().attr('href', interfaceConfig.BRAND_WATERMARK_LINK); + rightWatermarkDiv.parent().attr( + 'href', interfaceConfig.BRAND_WATERMARK_LINK); } if (interfaceConfig.SHOW_POWERED_BY) { @@ -413,7 +433,8 @@ export default class LargeVideoManager { } get id () { - return this.videoContainer.id; + let container = this.getContainer(this.state); + return container.id; } scheduleLargeVideoUpdate () { @@ -430,16 +451,22 @@ export default class LargeVideoManager { this.newStreamData = null; console.info("hover in %s", id); - this.state = VideoContainerType; - this.videoContainer.setStream(stream, videoType); + this.state = videoType; + let container = this.getContainer(this.state); + container.setStream(stream, videoType); // change the avatar url on large this.updateAvatar(Avatar.getAvatarUrl(id)); - let isVideoMuted = stream ? stream.isMuted() : true; + // 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; + if (videoType == VIDEO_CONTAINER_TYPE) + isVideoMuted = stream ? stream.isMuted() : true; // show the avatar on large if needed - this.videoContainer.showAvatar(isVideoMuted); + container.showAvatar(isVideoMuted); let promise; @@ -449,7 +476,7 @@ export default class LargeVideoManager { this.showWatermark(true); promise = Promise.resolve(); } else { - promise = this.videoContainer.show(); + promise = container.show(); } // resolve updateLargeVideo promise after everything is done @@ -457,7 +484,8 @@ export default class LargeVideoManager { return promise; }).then(() => { - // after everything is done check again if there are any pending new streams. + // after everything is done check again if there are any pending + // new streams. this.updateInProcess = false; this.scheduleLargeVideoUpdate(); }); @@ -529,7 +557,8 @@ export default class LargeVideoManager { * @param enable true to enable, false to disable */ enableVideoProblemFilter (enable) { - this.videoContainer.$video.toggleClass("videoProblemFilter", enable); + let container = this.getContainer(this.state); + container.$video.toggleClass("videoProblemFilter", enable); } /** @@ -600,7 +629,7 @@ export default class LargeVideoManager { } let oldContainer = this.containers[this.state]; - if (this.state === VideoContainerType) { + if (this.state === VIDEO_CONTAINER_TYPE) { this.showWatermark(false); } oldContainer.hide(); @@ -609,7 +638,7 @@ export default class LargeVideoManager { let container = this.getContainer(type); return container.show().then(() => { - if (type === VideoContainerType) { + if (type === VIDEO_CONTAINER_TYPE) { this.showWatermark(true); } }); diff --git a/modules/UI/videolayout/LocalVideo.js b/modules/UI/videolayout/LocalVideo.js index c108e3252..09eeaa7f2 100644 --- a/modules/UI/videolayout/LocalVideo.js +++ b/modules/UI/videolayout/LocalVideo.js @@ -13,7 +13,6 @@ function LocalVideo(VideoLayout, emitter) { this.videoSpanId = "localVideoContainer"; this.container = $("#localVideoContainer").get(0); this.bindHoverHandler(); - this.VideoLayout = VideoLayout; this.flipX = true; this.isLocal = true; this.emitter = emitter; @@ -22,7 +21,7 @@ function LocalVideo(VideoLayout, emitter) { return APP.conference.localId; } }); - SmallVideo.call(this); + SmallVideo.call(this, VideoLayout); } LocalVideo.prototype = Object.create(SmallVideo.prototype); @@ -159,7 +158,7 @@ LocalVideo.prototype.changeVideo = function (stream) { if (event.stopPropagation) { event.stopPropagation(); } - this.VideoLayout.handleVideoThumbClicked(true, this.id); + this.VideoLayout.handleVideoThumbClicked(this.id); }; let localVideoContainerSelector = $('#localVideoContainer'); @@ -192,7 +191,10 @@ LocalVideo.prototype.changeVideo = function (stream) { let endedHandler = () => { localVideoContainer.removeChild(localVideo); - this.VideoLayout.updateRemovedVideo(this.id); + // when removing only the video element and we are on stage + // update the stage + if(this.VideoLayout.isCurrentlyOnLarge(this.id)) + this.VideoLayout.updateLargeVideo(this.id); stream.off(TrackEvents.LOCAL_TRACK_STOPPED, endedHandler); }; stream.on(TrackEvents.LOCAL_TRACK_STOPPED, endedHandler); diff --git a/modules/UI/videolayout/RemoteVideo.js b/modules/UI/videolayout/RemoteVideo.js index d9f4dae10..7747ea822 100644 --- a/modules/UI/videolayout/RemoteVideo.js +++ b/modules/UI/videolayout/RemoteVideo.js @@ -11,14 +11,13 @@ function RemoteVideo(id, VideoLayout, emitter) { this.id = id; this.emitter = emitter; this.videoSpanId = `participant_${id}`; - this.VideoLayout = VideoLayout; + SmallVideo.call(this, VideoLayout); this.addRemoteVideoContainer(); this.connectionIndicator = new ConnectionIndicator(this, id); this.setDisplayName(); this.bindHoverHandler(); this.flipX = false; this.isLocal = false; - SmallVideo.call(this); } RemoteVideo.prototype = Object.create(SmallVideo.prototype); @@ -157,8 +156,10 @@ RemoteVideo.prototype.removeRemoteStreamElement = function (stream) { console.info((isVideo ? "Video" : "Audio") + " removed " + this.id, select); - if (isVideo) - this.VideoLayout.updateRemovedVideo(this.id); + // when removing only the video element and we are on stage + // update the stage + if (isVideo && this.VideoLayout.isCurrentlyOnLarge(this.id)) + this.VideoLayout.updateLargeVideo(this.id); }; /** @@ -169,7 +170,7 @@ RemoteVideo.prototype.remove = function () { this.removeConnectionIndicator(); // Make sure that the large video is updated if are removing its // corresponding small video. - this.VideoLayout.updateRemovedVideo(this.id); + this.VideoLayout.updateAfterThumbRemoved(this.id); // Remove whole container if (this.container.parentNode) { this.container.parentNode.removeChild(this.container); @@ -221,7 +222,7 @@ RemoteVideo.prototype.addRemoteStreamElement = function (stream) { // ignore click if it was done in popup menu if ($(source).parents('.popupmenu').length === 0) { - this.VideoLayout.handleVideoThumbClicked(false, this.id); + this.VideoLayout.handleVideoThumbClicked(this.id); } // On IE we need to populate this handler on video diff --git a/modules/UI/videolayout/SmallVideo.js b/modules/UI/videolayout/SmallVideo.js index e55eafec6..6a878e4d1 100644 --- a/modules/UI/videolayout/SmallVideo.js +++ b/modules/UI/videolayout/SmallVideo.js @@ -5,12 +5,13 @@ import UIUtil from "../util/UIUtil"; const RTCUIHelper = JitsiMeetJS.util.RTCUIHelper; -function SmallVideo() { +function SmallVideo(VideoLayout) { this.isMuted = false; this.hasAvatar = false; this.isVideoMuted = false; this.videoStream = null; this.audioStream = null; + this.VideoLayout = VideoLayout; } function setVisibility(selector, show) { @@ -19,6 +20,14 @@ function setVisibility(selector, show) { } } +/** + * Returns the identifier of this small video. + * + * @returns the identifier of this small video + */ +SmallVideo.prototype.getId = function () { + return this.id; +}; /* Indicates if this small video is currently visible. * @@ -362,14 +371,7 @@ SmallVideo.prototype.updateView = function () { } setVisibility(avatar, showAvatar); - var showDisplayName = !showVideo && !showAvatar; - - if (showDisplayName) { - this.showDisplayName(this.VideoLayout.isLargeVideoVisible()); - } - else { - this.showDisplayName(false); - } + this.showDisplayName(!showVideo && !showAvatar); }; SmallVideo.prototype.avatarChanged = function (avatarUrl) { diff --git a/modules/UI/videolayout/VideoLayout.js b/modules/UI/videolayout/VideoLayout.js index 730a55dc3..772e05f37 100644 --- a/modules/UI/videolayout/VideoLayout.js +++ b/modules/UI/videolayout/VideoLayout.js @@ -9,15 +9,14 @@ import UIEvents from "../../../service/UI/UIEvents"; import UIUtil from "../util/UIUtil"; import RemoteVideo from "./RemoteVideo"; -import LargeVideoManager, {VideoContainerType} from "./LargeVideo"; -import {PreziContainerType} from '../prezi/Prezi'; +import LargeVideoManager, {VIDEO_CONTAINER_TYPE} from "./LargeVideo"; +import {SHARED_VIDEO_CONTAINER_TYPE} from '../shared_video/SharedVideo'; import LocalVideo from "./LocalVideo"; import PanelToggler from "../side_pannels/SidePanelToggler"; const RTCUIUtil = JitsiMeetJS.util.RTCUIHelper; var remoteVideos = {}; -var remoteVideoTypes = {}; var localVideoThumbnail = null; var currentDominantSpeaker = null; @@ -32,7 +31,7 @@ var eventEmitter = null; * Currently focused video jid * @type {String} */ -var focusedVideoResourceJid = null; +var pinnedId = null; /** * On contact list item clicked. @@ -50,7 +49,7 @@ function onContactClicked (id) { if (remoteVideo.hasVideoStarted()) { // We have a video src, great! Let's update the large video // now. - VideoLayout.handleVideoThumbClicked(false, id); + VideoLayout.handleVideoThumbClicked(id); } else { // If we don't have a video src for jid, there's absolutely @@ -64,7 +63,7 @@ function onContactClicked (id) { // picked up later by the lastN changed event handler. lastNPickupId = id; - eventEmitter.emit(UIEvents.PINNED_ENDPOINT, id); + eventEmitter.emit(UIEvents.PINNED_ENDPOINT, remoteVideo, true); } } } @@ -94,6 +93,11 @@ var VideoLayout = { init (emitter) { eventEmitter = emitter; localVideoThumbnail = new LocalVideo(VideoLayout, emitter); + // sets default video type of local video + localVideoThumbnail.setVideoType(VIDEO_CONTAINER_TYPE); + // if we do not resize the thumbs here, if there is no video device + // the local video thumb maybe one pixel + this.resizeThumbnails(false, true, false); emitter.addListener(UIEvents.CONTACT_CLICKED, onContactClicked); this.lastNCount = config.channelLastN; @@ -195,23 +199,22 @@ var VideoLayout = { /** * Checks if removed video is currently displayed and tries to display * another one instead. + * Uses focusedID if any or dominantSpeakerID if any, + * otherwise elects new video, in this order. */ - updateRemovedVideo (id) { + updateAfterThumbRemoved (id) { if (!this.isCurrentlyOnLarge(id)) { return; } let newId; - // We'll show user's avatar if he is the dominant speaker or if - // his video thumbnail is pinned - if (remoteVideos[id] && (id === focusedVideoResourceJid - || id === currentDominantSpeaker)) { - newId = id; - } else { - // Otherwise select last visible video + if (pinnedId) + newId = pinnedId; + else if (currentDominantSpeaker) + newId = currentDominantSpeaker; + else // Otherwise select last visible video newId = this.electLastVisibleVideo(); - } this.updateLargeVideo(newId); }, @@ -278,26 +281,39 @@ var VideoLayout = { /** * Return the type of the remote video. * @param id the id for the remote video - * @returns the video type video or screen. + * @returns {String} the video type video or screen. */ getRemoteVideoType (id) { - return remoteVideoTypes[id]; + let smallVideo = VideoLayout.getSmallVideo(id); + return smallVideo ? smallVideo.getVideoType() : null; }, - handleVideoThumbClicked (noPinnedEndpointChangedEvent, - resourceJid) { - if(focusedVideoResourceJid) { - var oldSmallVideo - = VideoLayout.getSmallVideo(focusedVideoResourceJid); + isPinned (id) { + return (pinnedId) ? (id === pinnedId) : false; + }, + + getPinnedId () { + return pinnedId; + }, + + /** + * Handles the click on a video thumbnail. + * + * @param id the identifier of the video thumbnail + */ + handleVideoThumbClicked (id) { + if(pinnedId) { + var oldSmallVideo = VideoLayout.getSmallVideo(pinnedId); if (oldSmallVideo && !interfaceConfig.filmStripOnly) oldSmallVideo.focus(false); } - var smallVideo = VideoLayout.getSmallVideo(resourceJid); - // Unlock current focused. - if (focusedVideoResourceJid === resourceJid) + var smallVideo = VideoLayout.getSmallVideo(id); + + // Unpin if currently pinned. + if (pinnedId === id) { - focusedVideoResourceJid = null; + pinnedId = null; // Enable the currently set dominant speaker. if (currentDominantSpeaker) { if(smallVideo && smallVideo.hasVideo()) { @@ -305,44 +321,45 @@ var VideoLayout = { } } - if (!noPinnedEndpointChangedEvent) { - eventEmitter.emit(UIEvents.PINNED_ENDPOINT); - } + eventEmitter.emit(UIEvents.PINNED_ENDPOINT, smallVideo, false); + return; } // Lock new video - focusedVideoResourceJid = resourceJid; + pinnedId = id; // Update focused/pinned interface. - if (resourceJid) { + if (id) { if (smallVideo && !interfaceConfig.filmStripOnly) smallVideo.focus(true); - if (!noPinnedEndpointChangedEvent) { - eventEmitter.emit(UIEvents.PINNED_ENDPOINT, resourceJid); - } + eventEmitter.emit(UIEvents.PINNED_ENDPOINT, smallVideo, true); } - this.updateLargeVideo(resourceJid); + this.updateLargeVideo(id); }, - /** - * Checks if container for participant identified by given id exists - * in the document and creates it eventually. - * - * @return Returns true if the peer container exists, - * false - otherwise + * Creates a remote video for participant for the given id. + * @param id the id of the participant to add + * @param {SmallVideo} smallVideo optional small video instance to add as a + * remote video, if undefined RemoteVideo will be created */ - addParticipantContainer (id) { - let remoteVideo = new RemoteVideo(id, VideoLayout, eventEmitter); + addParticipantContainer (id, smallVideo) { + let remoteVideo; + if(smallVideo) + remoteVideo = smallVideo; + else + remoteVideo = new RemoteVideo(id, VideoLayout, eventEmitter); remoteVideos[id] = remoteVideo; - let videoType = remoteVideoTypes[id]; - if (videoType) { - remoteVideo.setVideoType(videoType); + let videoType = VideoLayout.getRemoteVideoType(id); + if (!videoType) { + // make video type the default one (camera) + videoType = VIDEO_CONTAINER_TYPE; } + remoteVideo.setVideoType(videoType); // In case this is not currently in the last n we don't show it. if (localLastNCount && localLastNCount > 0 && @@ -361,13 +378,13 @@ var VideoLayout = { false, false, false, function() {$(videoelem).show();}); // Update the large video to the last added video only if there's no - // current dominant, focused speaker or prezi playing or update it to + // current dominant, focused speaker or update it to // the current dominant speaker. - if ((!focusedVideoResourceJid && + if ((!pinnedId && !currentDominantSpeaker && - !this.isLargeContainerTypeVisible(PreziContainerType)) || - focusedVideoResourceJid === resourceJid || - (resourceJid && + this.isLargeContainerTypeVisible(VIDEO_CONTAINER_TYPE)) || + pinnedId === resourceJid || + (!pinnedId && resourceJid && currentDominantSpeaker === resourceJid)) { this.updateLargeVideo(resourceJid, true); } @@ -522,7 +539,9 @@ var VideoLayout = { // since we don't want to switch to local video. // Update the large video if the video source is already available, // otherwise wait for the "videoactive.jingle" event. - if (!focusedVideoResourceJid && remoteVideo.hasVideoStarted()) { + if (!pinnedId + && remoteVideo.hasVideoStarted() + && !this.getCurrentlyOnLargeContainer().stayOnStage()) { this.updateLargeVideo(id); } }, @@ -639,11 +658,7 @@ var VideoLayout = { // Clean up the lastN pickup id. lastNPickupId = null; - // Don't fire the events again, they've already - // been fired in the contact list click handler. - VideoLayout.handleVideoThumbClicked( - false, - resourceJid); + VideoLayout.handleVideoThumbClicked(resourceJid); updateLargeVideo = false; } @@ -730,9 +745,9 @@ var VideoLayout = { removeParticipantContainer (id) { // Unlock large video - if (focusedVideoResourceJid === id) { + if (pinnedId === id) { console.info("Focused video owner has left the conference"); - focusedVideoResourceJid = null; + pinnedId = null; } if (currentDominantSpeaker === id) { @@ -754,12 +769,11 @@ var VideoLayout = { }, onVideoTypeChanged (id, newVideoType) { - if (remoteVideoTypes[id] === newVideoType) { + if (VideoLayout.getRemoteVideoType(id) === newVideoType) { return; } console.info("Peer video type changed: ", id, newVideoType); - remoteVideoTypes[id] = newVideoType; var smallVideo; if (APP.conference.isLocalId(id)) { @@ -773,8 +787,8 @@ var VideoLayout = { } else { return; } - smallVideo.setVideoType(newVideoType); + if (this.isCurrentlyOnLarge(id)) { this.updateLargeVideo(id, true); } @@ -793,10 +807,6 @@ var VideoLayout = { } }, - addRemoteVideoContainer (id) { - return RemoteVideo.createContainer(id); - }, - /** * Resizes the video area. * @@ -804,10 +814,11 @@ var VideoLayout = { * @param forceUpdate indicates that hidden thumbnails will be shown * @param completeFunction a function to be called when the video area is * resized. - */resizeVideoArea (isSideBarVisible, - forceUpdate = false, - animate = false, - completeFunction = null) { + */ + resizeVideoArea (isSideBarVisible, + forceUpdate = false, + animate = false, + completeFunction = null) { if (largeVideo) { largeVideo.updateContainerSize(isSideBarVisible); @@ -888,7 +899,15 @@ var VideoLayout = { }, isLargeVideoVisible () { - return this.isLargeContainerTypeVisible(VideoContainerType); + return this.isLargeContainerTypeVisible(VIDEO_CONTAINER_TYPE); + }, + + /** + * @return {LargeContainer} the currently displayed container on large + * video. + */ + getCurrentlyOnLargeContainer () { + return largeVideo.getContainer(largeVideo.state); }, isCurrentlyOnLarge (id) { @@ -903,7 +922,8 @@ var VideoLayout = { let currentId = largeVideo.id; if (!isOnLarge || forceUpdate) { - if (id !== currentId) { + let videoType = this.getRemoteVideoType(id); + if (id !== currentId && videoType === VIDEO_CONTAINER_TYPE) { eventEmitter.emit(UIEvents.SELECTED_ENDPOINT, id); } if (currentId) { @@ -912,7 +932,6 @@ var VideoLayout = { let smallVideo = this.getSmallVideo(id); - let videoType = this.getRemoteVideoType(id); largeVideo.updateLargeVideo( id, smallVideo.videoStream, @@ -952,8 +971,27 @@ var VideoLayout = { return Promise.resolve(); } - // if !show then use default type - large video - return largeVideo.showContainer(show ? type : VideoContainerType); + let currentId = largeVideo.id; + if(currentId) { + var oldSmallVideo = this.getSmallVideo(currentId); + } + + let containerTypeToShow = type; + // if we are hiding a container and there is focusedVideo + // (pinned remote video) use its video type, + // if not then use default type - large video + if (!show) { + if(pinnedId) + containerTypeToShow = this.getRemoteVideoType(pinnedId); + else + containerTypeToShow = VIDEO_CONTAINER_TYPE; + } + + return largeVideo.showContainer(containerTypeToShow) + .then(() => { + if(oldSmallVideo) + oldSmallVideo && oldSmallVideo.updateView(); + }); }, isLargeContainerTypeVisible (type) { @@ -962,10 +1000,31 @@ var VideoLayout = { /** * Returns the id of the current video shown on large. - * Currently used by tests (troture). + * Currently used by tests (torture). */ getLargeVideoID () { return largeVideo.id; + }, + + /** + * Returns the the current video shown on large. + * Currently used by tests (torture). + */ + getLargeVideo () { + return largeVideo; + }, + + /** + * Updates the resolution label, indicating to the user that the large + * video stream is currently HD. + */ + updateResolutionLabel(isResolutionHD) { + let videoResolutionLabel = $("#videoResolutionLabel"); + + if (isResolutionHD && !videoResolutionLabel.is(":visible")) + videoResolutionLabel.css({display: "block"}); + else if (!isResolutionHD && videoResolutionLabel.is(":visible")) + videoResolutionLabel.css({display: "none"}); } }; diff --git a/service/UI/UIEvents.js b/service/UI/UIEvents.js index b915b3793..5c091481c 100644 --- a/service/UI/UIEvents.js +++ b/service/UI/UIEvents.js @@ -20,11 +20,14 @@ export default { START_MUTED_CHANGED: "UI.start_muted_changed", AUDIO_MUTED: "UI.audio_muted", VIDEO_MUTED: "UI.video_muted", - PREZI_CLICKED: "UI.prezi_clicked", - SHARE_PREZI: "UI.share_prezi", - PREZI_SLIDE_CHANGED: "UI.prezi_slide_changed", - STOP_SHARING_PREZI: "UI.stop_sharing_prezi", ETHERPAD_CLICKED: "UI.etherpad_clicked", + SHARED_VIDEO_CLICKED: "UI.start_shared_video", + /** + * Updates shared video with params: url, state, time(optional) + * Where url is the video link, state is stop/start/pause and time is the + * current video playing time. + */ + UPDATE_SHARED_VIDEO: "UI.update_shared_video", ROOM_LOCK_CLICKED: "UI.room_lock_clicked", USER_INVITED: "UI.user_invited", USER_KICKED: "UI.user_kicked", @@ -34,14 +37,39 @@ export default { TOGGLE_CHAT: "UI.toggle_chat", TOGGLE_SETTINGS: "UI.toggle_settings", TOGGLE_CONTACT_LIST: "UI.toggle_contact_list", + /** + * Notifies that a command to toggle the film strip has been issued. The + * event may optionally specify a {Boolean} (primitive) value to assign to + * the visibility of the film strip (i.e. the event may act as a setter). + * The very toggling of the film strip may or may not occurred at the time + * of the receipt of the event depending on the position of the receiving + * event listener in relation to the event listener which carries out the + * command to toggle the film strip. + * + * @see {TOGGLED_FILM_STRIP} + */ TOGGLE_FILM_STRIP: "UI.toggle_film_strip", + /** + * Notifies that the film strip was (actually) toggled. The event supplies + * a {Boolean} (primitive) value indicating the visibility of the film + * strip after the toggling (at the time of the event emission). + * + * @see {TOGGLE_FILM_STRIP} + */ + TOGGLED_FILM_STRIP: "UI.toggled_film_strip", TOGGLE_SCREENSHARING: "UI.toggle_screensharing", + TOGGLED_SHARED_DOCUMENT: "UI.toggled_shared_document", CONTACT_CLICKED: "UI.contact_clicked", HANGUP: "UI.hangup", LOGOUT: "UI.logout", RECORDING_TOGGLE: "UI.recording_toggle", SIP_DIAL: "UI.sip_dial", - SUBEJCT_CHANGED: "UI.subject_changed", + SUBJECT_CHANGED: "UI.subject_changed", VIDEO_DEVICE_CHANGED: "UI.video_device_changed", - AUDIO_DEVICE_CHANGED: "UI.audio_device_changed" + AUDIO_DEVICE_CHANGED: "UI.audio_device_changed", + /** + * Notifies interested listeners that the follow-me feature is enabled or + * disabled. + */ + FOLLOW_ME_ENABLED: "UI.follow_me_enabled" };