Merge remote-tracking branch 'upstream/master'

This commit is contained in:
luciash d' being 2016-01-05 14:55:10 +01:00
commit 5a8819cd7b
45 changed files with 871 additions and 424 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ node_modules
deploy-local.sh
libs/app.bundle.*
all.css
.remote-sync.json

View File

@ -69,4 +69,5 @@ var config = {
/*noticeMessage: 'Service update is scheduled for 16th March 2015. ' +
'During that time service will not be available. ' +
'Apologise for inconvenience.'*/
disableThirdPartyRequests: false
};

View File

@ -44,6 +44,8 @@
vertical-align: middle;
font-size: 22pt;
border-radius: 20px;
max-height: 30px;
max-width: 30px;
}
#contactlist .clickable {

View File

@ -248,6 +248,7 @@ form {
}
div.feedbackButton {
display: none;
position: absolute;
background-color: rgba(0,0,0,.50);
border-radius: 50%;

View File

@ -11,8 +11,8 @@ body {
#wrap{
display: block;
position: absolute;
width:900px;
height: 365px;
width:500px;
height: 565px;
overflow:hidden;
text-align: center;
margin: auto;
@ -29,7 +29,7 @@ body {
#text{
display:inline-block;
font-size: 28px;
width: 568px;
/* width: 568px; */
vertical-align:middle;
padding-top: 25px;
}
@ -51,18 +51,23 @@ a {
.browser_wrapper
{
width: 138px;
height: 188px;
/* height: 188px; */
vertical-align: middle;
color: #929391;
font-size: 20px;
float: left;
margin-left: 15px;
margin-top: 5px;
}
.browser_text
{
height: 2em;
}
.supported_browsers
{
margin: 0px auto 0px auto;
width: 660px;
/* width: 660px; */
}
.clear
@ -97,14 +102,14 @@ a {
}
#chromium_logo
{
width: 85px;
height: 79px;
width: 77px;
height: 78px;
background-image: url('/images/chromium.png');
}
#firefox_logo
{
width: 73px;
height: 79px;
width: 86px;
height: 80px;
background-image: url('/images/firefox.png');
}
@ -114,5 +119,18 @@ a {
height: 78px;
background-image: url('/images/opera.png');
}
#safari_logo
{
width: 78px;
height: 79px;
background-image: url('/images/safari.png');
}
#ie_logo
{
width: 80px;
height: 78px;
background-image: url('/images/ie.png');
}

View File

@ -319,6 +319,27 @@
z-index: 3;
}
.videocontainer>span.dominantspeakerindicator {
bottom: 0px;
left: 0px;
width: 25px;
height: 25px;
z-index: 3;
text-align: center;
border-radius: 50%;
background: #0cf;
margin: 5px;
display: inline-block;
position: absolute;
color: #FFFFFF;
font-size: 11pt;
border: 0px;
}
#speakerindicatoricon {
padding-top: 5px;
}
#reloadPresentation {
display: none;
position: absolute;

2
debian/control vendored
View File

@ -35,6 +35,6 @@ Description: Prosody configuration for Jitsi Meet
Package: jitsi-meet-tokens
Architecture: all
Depends: ${misc:Depends}, prosody | prosody-trunk, jitsi-meet-prosody
Depends: ${misc:Depends}, prosody-trunk (>= 1nightly603), libssl-dev, luarocks, jitsi-meet-prosody
Description: Prosody token authentication plugin for Jitsi Meet

View File

@ -62,13 +62,23 @@ case "$1" in
sed -i 's/--plugin_paths/plugin_paths/g' $PROSODY_HOST_CONFIG
sed -i 's/authentication = "anonymous"/authentication = "token"/g' $PROSODY_HOST_CONFIG
sed -i 's/ --allow_unencrypted_plain_auth/ allow_unencrypted_plain_auth/g' $PROSODY_HOST_CONFIG
sed -i "s/ --app_id=example_app_id/ app_id=$APP_ID/g" $PROSODY_HOST_CONFIG
sed -i "s/ --app_secret=example_app_secret/ app_secret=$APP_SECRET/g" $PROSODY_HOST_CONFIG
sed -i "s/ --app_id=\"example_app_id\"/ app_id=\"$APP_ID\"/g" $PROSODY_HOST_CONFIG
sed -i "s/ --app_secret=\"example_app_secret\"/ app_secret=\"$APP_SECRET\"/g" $PROSODY_HOST_CONFIG
sed -i 's/ --modules_enabled = { "token_verification" }/ modules_enabled = { "token_verification" }/g' $PROSODY_HOST_CONFIG
if [ -x "/etc/init.d/prosody" ]; then
invoke-rc.d prosody reload
# Install luajwt
if ! luarocks install luajwt; then
echo "Failed to install luajwt - try installing it manually"
fi
if [ -x "/etc/init.d/prosody" ]; then
invoke-rc.d prosody restart
fi
echo "This package requires BOSH Prosody module to be patched !"
echo "Use the following command, after this package has been installed and"
echo "after every prosody-trunk upgrade:"
echo "sudo patch -N /usr/lib/prosody/modules/mod_bosh.lua /usr/share/jitsi-meet/prosody-plugins/mod_bosh.lua.patch"
else
echo "Failed apply auto-config to $PROSODY_HOST_CONFIG which most likely comes from not supported version of jitsi-meet"
fi

View File

@ -39,13 +39,12 @@ case "$1" in
# Revert prosody config
sed -i 's/plugin_paths/--plugin_paths/g' $PROSODY_HOST_CONFIG
sed -i 's/authentication = "token"/authentication = "anonymous"/g' $PROSODY_HOST_CONFIG
sed -i 's/ allow_unencrypted_plain_auth/ --allow_unencrypted_plain_auth/g' $PROSODY_HOST_CONFIG
sed -i "s/ app_id=$APP_ID/ --app_id=example_app_id/g" $PROSODY_HOST_CONFIG
sed -i "s/ app_secret=$APP_SECRET/ --app_secret=example_app_secret/g" $PROSODY_HOST_CONFIG
sed -i "s/ app_id=\"$APP_ID\"/ --app_id=\"example_app_id\"/g" $PROSODY_HOST_CONFIG
sed -i "s/ app_secret=\"$APP_SECRET\"/ --app_secret=\"example_app_secret\"/g" $PROSODY_HOST_CONFIG
sed -i 's/ modules_enabled = { "token_verification" }/ --modules_enabled = { "token_verification" }/g' $PROSODY_HOST_CONFIG
if [ -x "/etc/init.d/prosody" ]; then
invoke-rc.d prosody reload
invoke-rc.d prosody restart
fi
fi

View File

@ -4,11 +4,10 @@
VirtualHost "jitmeet.example.com"
-- enabled = false -- Remove this line to enable this host
authentication = "anonymous"
-- Three properties below get uncommented by jitsi-meet-tokens package config
-- Properties below are modified by jitsi-meet-tokens package config
-- and authentication above is switched to "token"
--allow_unencrypted_plain_auth = true;
--app_id=example_app_id
--app_secret=example_app_secret
--app_id="example_app_id"
--app_secret="example_app_secret"
-- Assign this host a certificate for TLS, otherwise it would use the one
-- set in the global section (if any).
-- Note that old-style SSL on port 5223 only supports one certificate, and will always

BIN
images/ie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
images/safari.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -15,11 +15,9 @@
<link rel="stylesheet" href="css/bootstrap.min.css"/>
<link rel="stylesheet" href="css/all.css"/>
<script>console.log("(TIME) index.html loaded:\t", window.performance.now());</script>
<script src="https://api.callstats.io/static/callstats.min.js"></script>
<script src="config.js?v=15"></script><!-- adapt to your needs, i.e. set hosts and bosh path -->
<script src="interface_config.js?v=6"></script>
<script src="libs/app.bundle.min.js?v=139"></script>
<script src="analytics.js?v=1"></script><!-- google analytics plugin -->
<!--
Link used for inline installation of chrome desktop streaming extension,
is updated automatically from the code with the value defined in config.js -->
@ -225,7 +223,7 @@
</div>
<div id="settingsmenu" class="right-panel">
<div class="icon-settings" data-i18n="settings.title"></div>
<img id="avatar" src="https://www.gravatar.com/avatar/87291c37c25be69a072a4514931b1749?d=wavatar&size=30"/>
<img id="avatar" src="images/avatar2.png"/>
<div class="arrow-up"></div>
<input type="text" id="setDisplayName" data-i18n="[placeholder]settings.name" placeholder="Name">
<input type="text" id="setEmail" placeholder="E-Mail">
@ -246,5 +244,18 @@
<a id="feedbackButton" data-container="body" data-toggle="popover" data-placement="right" data-i18n="[data-content]feedback"><i class="fa fa-heart"></i></a>
</div>
</div>
<script type="text/javascript">
if (!config.disableThirdPartyRequests) {
[
'https://api.callstats.io/static/callstats.min.js',
'analytics.js?v=1'
].forEach(function(extSrc) {
var extScript = document.createElement('script');
extScript.src = extSrc;
extScript.async = false;
document.head.appendChild(extScript);
});
}
</script>
</body>
</html>

View File

@ -5,7 +5,6 @@ var interfaceConfig = {
INITIAL_TOOLBAR_TIMEOUT: 20000,
TOOLBAR_TIMEOUT: 4000,
DEFAULT_REMOTE_DISPLAY_NAME: "Fellow Jitster",
DEFAULT_DOMINANT_SPEAKER_DISPLAY_NAME: "speaker",
DEFAULT_LOCAL_DISPLAY_NAME: "me",
SHOW_JITSI_WATERMARK: true,
JITSI_WATERMARK_LINK: "https://jitsi.org",
@ -28,5 +27,7 @@ var interfaceConfig = {
/**
* Whether to only show the filmstrip (and hide the toolbar).
*/
filmStripOnly: false
filmStripOnly: false,
RANDOM_AVATAR_URL_PREFIX: false,
RANDOM_AVATAR_URL_SUFFIX: false
};

View File

@ -300,18 +300,18 @@ var RTC = {
* @param handler the handler
*/
addMediaStreamInactiveHandler: function (mediaStream, handler) {
if (mediaStream.addEventListener) {
// chrome
if(typeof mediaStream.active !== "undefined")
mediaStream.inactive = handler;
else
mediaStream.onended = handler;
} else {
if(RTCBrowserType.isTemasysPluginUsed()) {
// themasys
mediaStream.attachEvent('ended', function () {
handler(mediaStream);
});
}
else {
if(typeof mediaStream.active !== "undefined")
mediaStream.oninactive = handler;
else
mediaStream.onended = handler;
}
},
/**
* Removes onended/inactive handler.
@ -319,16 +319,16 @@ var RTC = {
* @param handler the handler to remove.
*/
removeMediaStreamInactiveHandler: function (mediaStream, handler) {
if (mediaStream.removeEventListener) {
// chrome
if(typeof mediaStream.active !== "undefined")
mediaStream.inactive = null;
else
mediaStream.onended = null;
} else {
if(RTCBrowserType.isTemasysPluginUsed()) {
// themasys
mediaStream.detachEvent('ended', handler);
}
else {
if(typeof mediaStream.active !== "undefined")
mediaStream.oninactive = null;
else
mediaStream.onended = null;
}
}
};

View File

@ -146,7 +146,9 @@ function getConstraints(um, resolution, bandwidth, fps, desktopStream) {
// this later can be a problem with some of the tests
if(RTCBrowserType.isFirefox() && config.firefox_fake_device)
{
constraints.audio = true;
// seems to be fixed now, removing this experimental fix, as having
// multiple audio tracks brake the tests
//constraints.audio = true;
constraints.fake = true;
}

View File

@ -1,4 +1,4 @@
/* global $, interfaceConfig */
/* global $, config, interfaceConfig */
/*
* Created by Yana Stamcheva on 2/10/15.
@ -73,6 +73,28 @@ var Feedback = {
* The feedback score. -1 indicates no score has been given for now.
*/
feedbackScore: -1,
/**
* Initialise the Feedback functionality.
*/
init: function () {
// CallStats is the way we send feedback, so we don't have to initialise
// if callstats isn't enabled.
if (!callStats.isEnabled())
return;
$("div.feedbackButton").css("display", "block");
$("#feedbackButton").click(function (event) {
Feedback.openFeedbackWindow();
});
},
/**
* Indicates if the feedback functionality is enabled.
*
* @return true if the feedback functionality is enabled, false otherwise.
*/
isEnabled: function() {
return callStats.isEnabled();
},
/**
* Opens the feedback window.
*/
@ -120,7 +142,7 @@ var Feedback = {
var states = {
overall_feedback: {
html: constructOverallFeedbackHtml(),
persistent: true,
persistent: false,
buttons: {},
closeText: '',
focus: "div[id='stars']",
@ -161,7 +183,7 @@ var Feedback = {
var feedbackDialog
= APP.UI.messageHandler.openDialogWithStates(
states,
{ persistent: true,
{ persistent: false,
buttons: {},
closeText: '',
loaded: onLoadFunction,

View File

@ -107,7 +107,7 @@ function setupChat() {
function setupToolbars() {
Toolbar.init(UI);
Toolbar.setupButtonsFromConfig();
BottomToolbar.init();
BottomToolbar.init(eventEmitter);
}
function streamHandler(stream, isMuted) {
@ -325,8 +325,16 @@ function registerListeners() {
"dialog.connectError", pres);
});
APP.xmpp.addListener(XMPPEvents.ROOM_CONNECT_ERROR, function (pres) {
UI.messageHandler.openReportDialog(null,
"dialog.connectError", pres);
if (config.token &&
$(pres).find(
'>error[type="cancel"]' +
'>not-allowed[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]'
).length) {
messageHandler.showError("dialog.error", "dialog.tokenAuthFailed");
} else {
UI.messageHandler.openReportDialog(null,
"dialog.connectError", pres);
}
});
APP.xmpp.addListener(XMPPEvents.READY_TO_JOIN, function () {
@ -343,6 +351,10 @@ function registerListeners() {
AudioLevels.init();
});
UI.addListener(UIEvents.FILM_STRIP_TOGGLED, function (isToggled) {
VideoLayout.onFilmStripToggled(isToggled);
});
if (!interfaceConfig.filmStripOnly) {
APP.xmpp.addListener(XMPPEvents.MESSAGE_RECEIVED, updateChatConversation);
APP.xmpp.addListener(XMPPEvents.CHAT_ERROR_RECEIVED, chatAddError);
@ -425,15 +437,12 @@ UI.start = function (init) {
$("#downloadlog").click(function (event) {
dump(event.target);
});
$("#feedbackButton").click(function (event) {
Feedback.openFeedbackWindow();
});
Feedback.init();
}
else
{
$("#header").css("display", "none");
$("#bottomToolbar").css("display", "none");
$("#feedbackButton").css("display", "none");
$("#downloadlog").css("display", "none");
$("#remoteVideos").css("padding", "0px 0px 18px 0px");
$("#remoteVideos").css("right", "0px");
@ -524,6 +533,8 @@ function onMucJoined(jid, info) {
VideoLayout.mucJoined();
Toolbar.checkAutoEnableDesktopSharing();
}
function initEtherpad(name) {
@ -697,6 +708,15 @@ UI.getLargeVideoResource = function () {
return VideoLayout.getLargeVideoResource();
};
/**
* Return the type of the remote video.
* @param jid the jid for the remote video
* @returns the video type video or screen.
*/
UI.getRemoteVideoType = function (jid) {
return VideoLayout.getRemoteVideoType(jid);
};
UI.getRoomNode = function () {
if (roomNode)
return roomNode;
@ -884,11 +904,11 @@ UI.setVideoMuteButtonsState = function (mute) {
}
};
UI.userAvatarChanged = function (resourceJid, thumbUrl, contactListUrl) {
VideoLayout.userAvatarChanged(resourceJid, thumbUrl);
ContactList.userAvatarChanged(resourceJid, contactListUrl);
UI.userAvatarChanged = function (resourceJid, avatarUrl) {
VideoLayout.userAvatarChanged(resourceJid, avatarUrl);
ContactList.userAvatarChanged(resourceJid, avatarUrl);
if(resourceJid === APP.xmpp.myResource())
SettingsMenu.changeAvatar(thumbUrl);
SettingsMenu.changeAvatar(avatarUrl);
};
UI.setVideoMute = setVideoMute;

View File

@ -1,4 +1,4 @@
/* global Strophe, APP, MD5 */
/* global Strophe, APP, MD5, config, interfaceConfig */
var Settings = require("../../settings/Settings");
var users = {};
@ -18,51 +18,51 @@ var Avatar = {
}
users[jid] = id;
}
var thumbUrl = this.getThumbUrl(jid);
var contactListUrl = this.getContactListUrl(jid);
var avatarUrl = this.getAvatarUrl(jid);
var resourceJid = Strophe.getResourceFromJid(jid);
APP.UI.userAvatarChanged(resourceJid, thumbUrl, contactListUrl);
APP.UI.userAvatarChanged(resourceJid, avatarUrl);
},
/**
* Returns image URL for the avatar to be displayed on large video area
* where current active speaker is presented.
* Returns the URL of the image for the avatar of a particular user,
* identified by its jid
* @param jid
* @param jid full MUC jid of the user for whom we want to obtain avatar URL
*/
getActiveSpeakerUrl: function (jid) {
return this.getGravatarUrl(jid, 100);
},
/**
* Returns image URL for the avatar to be displayed on small video thumbnail
* @param jid full MUC jid of the user for whom we want to obtain avatar URL
*/
getThumbUrl: function (jid) {
return this.getGravatarUrl(jid, 100);
},
/**
* Returns the URL for the avatar to be displayed as contactlist item
* @param jid full MUC jid of the user for whom we want to obtain avatar URL
*/
getContactListUrl: function (jid) {
return this.getGravatarUrl(jid, 30);
},
getGravatarUrl: function (jid, size) {
if (!jid) {
console.error("Get gravatar - jid is undefined");
return null;
}
var id = users[jid];
if (!id) {
console.warn(
"No avatar stored yet for " + jid + " - using JID as ID");
id = jid;
}
return 'https://www.gravatar.com/avatar/' +
MD5.hexdigest(id.trim().toLowerCase()) +
"?d=wavatar&size=" + (size || "30");
}
getAvatarUrl: function (jid) {
if (config.disableThirdPartyRequests) {
return 'images/avatar2.png';
} else {
if (!jid) {
console.error("Get avatar - jid is undefined");
return null;
}
var id = users[jid];
// If the ID looks like an email, we'll use gravatar.
// Otherwise, it's a random avatar, and we'll use the configured
// URL.
var random = !id || id.indexOf('@') < 0;
if (!id) {
console.warn(
"No avatar stored yet for " + jid + " - using JID as ID");
id = jid;
}
id = MD5.hexdigest(id.trim().toLowerCase());
// Default to using gravatar.
var urlPref = 'https://www.gravatar.com/avatar/';
var urlSuf = "?d=wavatar&size=100";
if (random && interfaceConfig.RANDOM_AVATAR_URL_PREFIX) {
urlPref = interfaceConfig.RANDOM_AVATAR_URL_PREFIX;
urlSuf = interfaceConfig.RANDOM_AVATAR_URL_SUFFIX;
}
return urlPref + id + urlSuf;
}
}
};
module.exports = Avatar;
module.exports = Avatar;

View File

@ -32,7 +32,7 @@ function updateNumberOfParticipants(delta) {
function createAvatar(jid) {
var avatar = document.createElement('img');
avatar.className = "icon-avatar avatar";
avatar.src = Avatar.getContactListUrl(jid);
avatar.src = Avatar.getAvatarUrl(jid);
return avatar;
}
@ -181,11 +181,11 @@ var ContactList = {
contactName.html(displayName);
},
userAvatarChanged: function (resourceJid, contactListUrl) {
userAvatarChanged: function (resourceJid, avatarUrl) {
// set the avatar in the contact list
var contact = $('#' + resourceJid + '>img');
if (contact && contact.length > 0) {
contact.get(0).src = contactListUrl;
contact.get(0).src = avatarUrl;
}
}

View File

@ -2,6 +2,9 @@
var PanelToggler = require("../side_pannels/SidePanelToggler");
var UIUtil = require("../util/UIUtil");
var AnalyticsAdapter = require("../../statistics/AnalyticsAdapter");
var UIEvents = require("../../../service/UI/UIEvents");
var eventEmitter = null;
var buttonHandlers = {
"bottom_toolbar_contact_list": function () {
@ -27,7 +30,8 @@ var defaultBottomToolbarButtons = {
var BottomToolbar = (function (my) {
my.init = function () {
my.init = function (emitter) {
eventEmitter = emitter;
UIUtil.hideDisabledButtons(defaultBottomToolbarButtons);
for(var k in buttonHandlers)
@ -45,6 +49,9 @@ var BottomToolbar = (function (my) {
my.toggleFilmStrip = function() {
var filmstrip = $("#remoteVideos");
filmstrip.toggleClass("hidden");
eventEmitter.emit( UIEvents.FILM_STRIP_TOGGLED,
filmstrip.hasClass("hidden"));
};
$(document).bind("remotevideo.resized", function (event, width, height) {

View File

@ -151,12 +151,28 @@ function hangup() {
}
};
if (Feedback.feedbackScore > 0) {
Feedback.openFeedbackWindow();
conferenceDispose();
if (Feedback.isEnabled())
{
// If the user has already entered feedback, we'll show the window and
// immidiately start the conference dispose timeout.
if (Feedback.feedbackScore > 0) {
Feedback.openFeedbackWindow();
conferenceDispose();
}
// Otherwise we'll wait for user's feedback.
else
Feedback.openFeedbackWindow(conferenceDispose);
}
else {
conferenceDispose();
// If the feedback functionality isn't enabled we show a thank you
// dialog.
APP.UI.messageHandler.openMessageDialog(null, null, null,
APP.translation.translateString("dialog.thankYou",
{appName:interfaceConfig.APP_NAME}));
}
else
Feedback.openFeedbackWindow(conferenceDispose);
}
/**
@ -380,7 +396,7 @@ var Toolbar = (function (my) {
* Disables and enables some of the buttons.
*/
my.setupButtonsFromConfig = function () {
if (UIUtil.isButtonEnabled('prezi')) {
if (!UIUtil.isButtonEnabled('prezi')) {
$("#toolbar_button_prezi").css({display: "none"});
}
};
@ -632,13 +648,23 @@ var Toolbar = (function (my) {
}
};
// checks whether recording is enabled and whether we have params to start automatically recording
// checks whether recording is enabled and whether we have params
// to start automatically recording
my.checkAutoRecord = function () {
if (UIUtil.isButtonEnabled('recording') && config.autoRecord) {
toggleRecording(config.autoRecordToken);
}
};
// checks whether desktop sharing is enabled and whether
// we have params to start automatically sharing
my.checkAutoEnableDesktopSharing = function () {
if (UIUtil.isButtonEnabled('desktop')
&& config.autoEnableDesktopSharing) {
APP.desktopsharing.toggleScreenSharing();
}
};
// Shows or hides SIP calls button
my.showSipCallButton = function (show) {
if (APP.xmpp.isSipGatewayEnabled() && UIUtil.isButtonEnabled('sip') && show) {

View File

@ -90,6 +90,11 @@ ConnectionIndicator.prototype.generateText = function () {
if(this.resolution && this.jid) {
var keys = Object.keys(this.resolution);
for(var ssrc in this.resolution) {
// skip resolutions for ssrc that don't have this info
// like receive-only ssrc for FF
if(this.resolution[ssrc]
&& this.resolution[ssrc].height != -1
&& this.resolution[ssrc].width != -1)
resolutionValue = this.resolution[ssrc];
}
}

View File

@ -115,7 +115,10 @@ function getDesktopVideoSize(videoWidth,
var availableWidth = Math.max(videoWidth, videoSpaceWidth);
var availableHeight = Math.max(videoHeight, videoSpaceHeight);
videoSpaceHeight -= $('#remoteVideos').outerHeight();
var filmstrip = $("#remoteVideos");
if (!filmstrip.hasClass("hidden"))
videoSpaceHeight -= filmstrip.outerHeight();
if (availableWidth / aspectRatio >= videoSpaceHeight)
{
@ -239,7 +242,7 @@ function getCameraVideoSize(videoWidth,
function updateActiveSpeakerAvatarSrc() {
var avatar = $("#activeSpeakerAvatar")[0];
var jid = currentSmallVideo.peerJid;
var url = Avatar.getActiveSpeakerUrl(jid);
var url = Avatar.getAvatarUrl(jid);
if (avatar.src === url)
return;
if (jid) {
@ -268,13 +271,7 @@ function changeVideo(isVisible) {
largeVideoElement.style.transform = flipX ? "scaleX(-1)" : "none";
var isDesktop = currentSmallVideo.getVideoType() === 'screen';
// Change the way we'll be measuring and positioning large video
getVideoSize = isDesktop ? getDesktopVideoSize : getCameraVideoSize;
getVideoPosition = isDesktop ? getDesktopVideoPosition :
getCameraVideoPosition;
LargeVideo.updateVideoSizeAndPosition(currentSmallVideo.getVideoType());
// Only if the large video is currently visible.
if (isVisible) {
@ -451,10 +448,8 @@ var LargeVideo = {
return;
if (LargeVideo.isCurrentlyOnLarge(resourceJid))
{
var isDesktop = newVideoType === 'screen';
getVideoSize = isDesktop ? getDesktopVideoSize : getCameraVideoSize;
getVideoPosition = isDesktop ? getDesktopVideoPosition
: getCameraVideoPosition;
LargeVideo.updateVideoSizeAndPosition(newVideoType);
this.position(null, null, null, null, true);
}
},
@ -496,17 +491,23 @@ var LargeVideo = {
},
/**
* Resizes the large html elements.
* @param animate boolean property that indicates whether the resize should be animated or not.
* @param isChatVisible boolean property that indicates whether the chat area is displayed or not.
* If that parameter is null the method will check the chat pannel visibility.
* @param completeFunction a function to be called when the video space is resized
* @returns {*[]} array with the current width and height values of the largeVideo html element.
*
* @param animate boolean property that indicates whether the resize should
* be animated or not.
* @param isSideBarVisible boolean property that indicates whether the chat
* area is displayed or not.
* If that parameter is null the method will check the chat panel
* visibility.
* @param completeFunction a function to be called when the video space is
* resized
* @returns {*[]} array with the current width and height values of the
* largeVideo html element.
*/
resize: function (animate, isVisible, completeFunction) {
resize: function (animate, isSideBarVisible, completeFunction) {
if(!isEnabled)
return;
var availableHeight = window.innerHeight;
var availableWidth = UIUtil.getAvailableVideoWidth(isVisible);
var availableWidth = UIUtil.getAvailableVideoWidth(isSideBarVisible);
if (availableWidth < 0 || availableHeight < 0) return;
@ -514,7 +515,8 @@ var LargeVideo = {
var top = availableHeight / 2 - avatarSize / 4 * 3;
$('#activeSpeaker').css('top', top);
this.VideoLayout.resizeVideoSpace(animate, isVisible, completeFunction);
this.VideoLayout
.resizeVideoSpace(animate, isSideBarVisible, completeFunction);
if(animate) {
$('#largeVideoContainer').animate({
width: availableWidth,
@ -530,12 +532,36 @@ var LargeVideo = {
}
return [availableWidth, availableHeight];
},
resizeVideoAreaAnimated: function (isVisible, completeFunction) {
/**
* Resizes the large video.
*
* @param isSideBarVisible indicating if the side bar is visible
* @param completeFunction the callback function to be executed after the
* resize
*/
resizeVideoAreaAnimated: function (isSideBarVisible, completeFunction) {
if(!isEnabled)
return;
var size = this.resize(true, isVisible, completeFunction);
var size = this.resize(true, isSideBarVisible, completeFunction);
this.position(null, null, size[0], size[1], true);
},
/**
* Updates the video size and position.
*
* @param videoType the video type indicating if the stream is of type
* desktop or web cam
*/
updateVideoSizeAndPosition: function (videoType) {
if (!videoType)
videoType = currentSmallVideo.getVideoType();
var isDesktop = videoType === 'screen';
// Change the way we'll be measuring and positioning large video
getVideoSize = isDesktop ? getDesktopVideoSize : getCameraVideoSize;
getVideoPosition = isDesktop ? getDesktopVideoPosition :
getCameraVideoPosition;
},
getResourceJid: function () {
return currentSmallVideo ? currentSmallVideo.getResourceJid() : null;
},

View File

@ -334,6 +334,41 @@ RemoteVideo.prototype.updateRemoteVideoMenu = function (isMuted) {
}
};
/**
* Updates the Indicator for dominant speaker.
*
* @param isSpeaker indicates the current indicator state
*/
RemoteVideo.prototype.updateDominantSpeakerIndicator = function (isSpeaker) {
if (!this.container) {
console.warn( "Unable to set dominant speaker indicator - "
+ this.videoSpanId + " does not exist");
return;
}
var indicatorSpan
= $('#' + this.videoSpanId + '>span.dominantspeakerindicator');
// If we do not have an indicator for this video.
if (indicatorSpan.length <= 0) {
indicatorSpan = document.createElement('span');
indicatorSpan.innerHTML
= "<i id='speakerindicatoricon' class='fa fa-bullhorn'></i>";
indicatorSpan.className = 'dominantspeakerindicator';
$('#' + this.videoSpanId)[0].appendChild(indicatorSpan);
// adds a tooltip
UIUtils.setTooltip(indicatorSpan, "speaker", "left");
APP.translation.translateElement($(indicatorSpan));
}
$(indicatorSpan).css("visibility", isSpeaker ? "visible" : "hidden");
};
/**
* Sets the display name for the given video span id.
*/

View File

@ -320,9 +320,26 @@ SmallVideo.prototype.selectVideoElement = function () {
if (!RTCBrowserType.isTemasysPluginUsed()) {
return $('#' + this.videoSpanId).find(videoElem);
} else {
return $('#' + this.videoSpanId +
(this.isLocal ? '>>' : '>') +
videoElem + '>param[value="video"]').parent();
var matching = $('#' + this.videoSpanId +
(this.isLocal ? '>>' : '>') +
videoElem + '>param[value="video"]');
if (matching.length < 2) {
return matching.parent();
}
// there are 2 video objects from FF
// object with id which ends with '_default' (like 'remoteVideo_default')
// doesn't contain video, so we ignore it
for (var i = 0; i < matching.length; i += 1) {
var el = matching[i].parentNode;
// check id suffix
if (el.id.substr(-8) !== '_default') {
return $(el);
}
}
return $([]);
}
};
@ -352,7 +369,7 @@ SmallVideo.prototype.showAvatar = function (show) {
if (!this.hasAvatar) {
if (this.peerJid) {
// Init avatar
this.avatarChanged(Avatar.getThumbUrl(this.peerJid));
this.avatarChanged(Avatar.getAvatarUrl(this.peerJid));
} else {
console.error("Unable to init avatar - no peerjid", this);
return;
@ -406,4 +423,4 @@ SmallVideo.prototype.avatarChanged = function (thumbUrl) {
}
};
module.exports = SmallVideo;
module.exports = SmallVideo;

View File

@ -191,6 +191,15 @@ var VideoLayout = (function (my) {
return LargeVideo.getResourceJid();
};
/**
* Return the type of the remote video.
* @param jid the jid for the remote video
* @returns the video type video or screen.
*/
my.getRemoteVideoType = function (jid) {
return remoteVideoTypes[jid];
};
/**
* Called when large video update is finished
* @param currentSmallVideo small video currently displayed on large video
@ -214,7 +223,8 @@ var VideoLayout = (function (my) {
my.handleVideoThumbClicked = function(noPinnedEndpointChangedEvent,
resourceJid) {
if(focusedVideoResourceJid) {
var oldSmallVideo = VideoLayout.getSmallVideo(focusedVideoResourceJid);
var oldSmallVideo
= VideoLayout.getSmallVideo(focusedVideoResourceJid);
if (oldSmallVideo && !interfaceConfig.filmStripOnly)
oldSmallVideo.focus(false);
}
@ -400,7 +410,8 @@ var VideoLayout = (function (my) {
if(animate) {
$('#remoteVideos').animate({
height: height + 2 // adds 2 px because of small video 1px border
// adds 2 px because of small video 1px border
height: height + 2
},
{
queue: false,
@ -425,7 +436,8 @@ var VideoLayout = (function (my) {
} else {
// size videos so that while keeping AR and max height, we have a
// nice fit
$('#remoteVideos').height(height + 2);// adds 2 px because of small video 1px border
// adds 2 px because of small video 1px border
$('#remoteVideos').height(height + 2);
$('#remoteVideos>span').width(width);
$('#remoteVideos>span').height(height);
@ -439,10 +451,10 @@ var VideoLayout = (function (my) {
* @param videoSpaceWidth the width of the video space
*/
my.calculateThumbnailSize = function (videoSpaceWidth) {
// 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.
// 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.
var availableHeight = 100;
var numvids = $('#remoteVideos>span:visible').length;
@ -458,7 +470,11 @@ var VideoLayout = (function (my) {
var availableWidth = availableWinWidth / numvids;
var aspectRatio = 16.0 / 9.0;
var maxHeight = Math.min(160, availableHeight);
availableHeight = Math.min(maxHeight, availableWidth / aspectRatio, window.innerHeight - 18);
availableHeight
= Math.min( maxHeight,
availableWidth / aspectRatio,
window.innerHeight - 18);
if (availableHeight < availableWidth / aspectRatio) {
availableWidth = Math.floor(availableHeight * aspectRatio);
}
@ -598,16 +614,14 @@ var VideoLayout = (function (my) {
var members = APP.xmpp.getMembers();
// Update the current dominant speaker.
if (resourceJid !== currentDominantSpeaker) {
var currentJID = APP.xmpp.findJidFromResource(currentDominantSpeaker);
var newJID = APP.xmpp.findJidFromResource(resourceJid);
if (currentDominantSpeaker && (!members || !members[currentJID] ||
!members[currentJID].displayName) && remoteVideo) {
remoteVideo.setDisplayName(null);
}
if (resourceJid && (!members || !members[newJID] ||
!members[newJID].displayName) && remoteVideo) {
remoteVideo.setDisplayName(null,
interfaceConfig.DEFAULT_DOMINANT_SPEAKER_DISPLAY_NAME);
if (remoteVideo) {
remoteVideo.updateDominantSpeakerIndicator(true);
// let's remove the indications from the remote video if any
var oldSpeakerRemoteVideo
= remoteVideos[currentDominantSpeaker];
if (oldSpeakerRemoteVideo) {
oldSpeakerRemoteVideo.updateDominantSpeakerIndicator(false);
}
}
currentDominantSpeaker = resourceJid;
} else {
@ -683,6 +697,16 @@ var VideoLayout = (function (my) {
$('#remoteVideos>span').each(function( index, element ) {
var resourceJid = VideoLayout.getPeerContainerResourceJid(element);
// We do not want to process any logic for our own(local) video
// because the local participant is never in the lastN set.
// The code of this function might detect that the local participant
// has been dropped out of the lastN set and will update the large
// video
// Detected from avatar tests, where lastN event override
// local video pinning
if(resourceJid == APP.xmpp.myResource())
return;
var isReceived = true;
if (resourceJid &&
lastNEndpoints.indexOf(resourceJid) < 0 &&
@ -882,6 +906,17 @@ var VideoLayout = (function (my) {
};
/**
* Updates the video size and position when the film strip is toggled.
*
* @param isToggled indicates if the film strip is toggled or not. True
* would mean that the film strip is hidden, false would mean it's shown
*/
my.onFilmStripToggled = function(isToggled) {
LargeVideo.updateVideoSizeAndPosition();
LargeVideo.position(null, null, null, null, true);
};
my.showMore = function (jid) {
if (jid === 'local') {
localVideoThumbnail.connectionIndicator.showMore();
@ -914,21 +949,27 @@ var VideoLayout = (function (my) {
};
/**
* Resizes the video area
* Resizes the video area.
*
* @param isSideBarVisible indicates if the side bar is currently visible
* @param callback a function to be called when the video space is
* resized.
*/
my.resizeVideoArea = function(isVisible, callback) {
LargeVideo.resizeVideoAreaAnimated(isVisible, callback);
my.resizeVideoArea = function(isSideBarVisible, callback) {
LargeVideo.resizeVideoAreaAnimated(isSideBarVisible, callback);
VideoLayout.resizeThumbnails(true);
};
/**
* Resizes the #videospace html element
* @param animate boolean property that indicates whether the resize should be animated or not.
* @param isChatVisible boolean property that indicates whether the chat area is displayed or not.
* If that parameter is null the method will check the chat pannel visibility.
* @param completeFunction a function to be called when the video space is resized
* @param animate boolean property that indicates whether the resize should
* be animated or not.
* @param isChatVisible boolean property that indicates whether the chat
* area is displayed or not.
* If that parameter is null the method will check the chat panel
* visibility.
* @param completeFunction a function to be called when the video space
* is resized.
*/
my.resizeVideoSpace = function (animate, isChatVisible, completeFunction) {
var availableHeight = window.innerHeight;
@ -964,14 +1005,14 @@ var VideoLayout = (function (my) {
}
};
my.userAvatarChanged = function(resourceJid, thumbUrl) {
my.userAvatarChanged = function(resourceJid, avatarUrl) {
var smallVideo = VideoLayout.getSmallVideo(resourceJid);
if(smallVideo)
smallVideo.avatarChanged(thumbUrl);
smallVideo.avatarChanged(avatarUrl);
else
console.warn(
"Missed avatar update - no small video yet for " + resourceJid);
LargeVideo.updateAvatar(resourceJid, thumbUrl);
LargeVideo.updateAvatar(resourceJid, avatarUrl);
};
my.createEtherpadIframe = function(src, onloadHandler)
@ -998,7 +1039,8 @@ var VideoLayout = (function (my) {
LargeVideo.enableVideoProblemFilter(true);
var reconnectingKey = "connection.RECONNECTING";
$('#videoConnectionMessage').attr("data-i18n", reconnectingKey);
$('#videoConnectionMessage').text(APP.translation.translateString(reconnectingKey));
$('#videoConnectionMessage')
.text(APP.translation.translateString(reconnectingKey));
$('#videoConnectionMessage').css({display: "block"});
};

View File

@ -5,20 +5,35 @@ var jsSHA = require('jssha');
var io = require('socket.io-client');
var callStats = null;
// getUserMedia calls happen before CallStats init
// so if there are any getUserMedia errors, we store them in this array
/**
* @const
* @see http://www.callstats.io/api/#enumeration-of-wrtcfuncnames
*/
var wrtcFuncNames = {
createOffer: "createOffer",
createAnswer: "createAnswer",
setLocalDescription: "setLocalDescription",
setRemoteDescription: "setRemoteDescription",
addIceCandidate: "addIceCandidate",
getUserMedia: "getUserMedia"
};
// some errors may happen before CallStats init
// in this case we accumulate them in this array
// and send them to callstats on init
var pendingUserMediaErrors = [];
var pendingErrors = [];
function initCallback (err, msg) {
console.log("Initializing Status: err="+err+" msg="+msg);
console.log("CallStats Status: err=" + err + " msg=" + msg);
}
var callStatsIntegrationEnabled = config.callStatsID && config.callStatsSecret;
var CallStats = {
init: function (jingleSession) {
if(!config.callStatsID || !config.callStatsSecret || callStats !== null)
if(!callStatsIntegrationEnabled || callStats !== null) {
return;
}
callStats = new callstats($, io, jsSHA);
@ -44,12 +59,24 @@ var CallStats = {
this.confID,
this.pcCallback.bind(this));
// notify callstats about getUserMedia failures if there were any
if (pendingUserMediaErrors.length) {
pendingUserMediaErrors.forEach(this.sendGetUserMediaFailed, this);
pendingUserMediaErrors.length = 0;
// notify callstats about failures if there were any
if (pendingErrors.length) {
pendingErrors.forEach(function (error) {
this._reportError(error.type, error.error, error.pc);
}, this);
pendingErrors.length = 0;
}
},
/**
* Returns true if the callstats integration is enabled, otherwise returns
* false.
*
* @returns true if the callstats integration is enabled, otherwise returns
* false.
*/
isEnabled: function() {
return callStatsIntegrationEnabled;
},
pcCallback: function (err, msg) {
if (!callStats) {
return;
@ -108,6 +135,26 @@ var CallStats = {
callStats.sendUserFeedback(
this.confID, feedbackJSON);
},
/**
* Reports an error to callstats.
*
* @param type the type of the error, which will be one of the wrtcFuncNames
* @param e the error
* @param pc the peerconnection
* @private
*/
_reportError: function (type, e, pc) {
if (callStats) {
callStats.reportError(pc, this.confID, type, e);
} else if (callStatsIntegrationEnabled) {
pendingErrors.push({
type: type,
error: e,
pc: pc
});
}
// else just ignore it
},
/**
* Notifies CallStats that getUserMedia failed.
@ -115,78 +162,57 @@ var CallStats = {
* @param {Error} e error to send
*/
sendGetUserMediaFailed: function (e) {
if(!callStats) {
pendingUserMediaErrors.push(e);
return;
}
callStats.reportError(this.peerconnection, this.confID,
callStats.webRTCFunctions.getUserMedia, e);
this._reportError(wrtcFuncNames.getUserMedia, e, null);
},
/**
* Notifies CallStats that peer connection failed to create offer.
*
* @param {Error} e error to send
* @param {RTCPeerConnection} pc connection on which failure occured.
*/
sendCreateOfferFailed: function (e) {
if(!callStats) {
return;
}
callStats.reportError(this.peerconnection, this.confID,
callStats.webRTCFunctions.createOffer, e);
sendCreateOfferFailed: function (e, pc) {
this._reportError(wrtcFuncNames.createOffer, e, pc);
},
/**
* Notifies CallStats that peer connection failed to create answer.
*
* @param {Error} e error to send
* @param {RTCPeerConnection} pc connection on which failure occured.
*/
sendCreateAnswerFailed: function (e) {
if(!callStats) {
return;
}
callStats.reportError(this.peerconnection, this.confID,
callStats.webRTCFunctions.createAnswer, e);
sendCreateAnswerFailed: function (e, pc) {
this._reportError(wrtcFuncNames.createAnswer, e, pc);
},
/**
* Notifies CallStats that peer connection failed to set local description.
*
* @param {Error} e error to send
* @param {RTCPeerConnection} pc connection on which failure occured.
*/
sendSetLocalDescFailed: function (e) {
if(!callStats) {
return;
}
callStats.reportError(this.peerconnection, this.confID,
callStats.webRTCFunctions.setLocalDescription, e);
sendSetLocalDescFailed: function (e, pc) {
this._reportError(wrtcFuncNames.setLocalDescription, e, pc);
},
/**
* Notifies CallStats that peer connection failed to set remote description.
*
* @param {Error} e error to send
* @param {RTCPeerConnection} pc connection on which failure occured.
*/
sendSetRemoteDescFailed: function (e) {
if(!callStats) {
return;
}
callStats.reportError(
this.peerconnection, this.confID,
callStats.webRTCFunctions.setRemoteDescription, e);
sendSetRemoteDescFailed: function (e, pc) {
this._reportError(wrtcFuncNames.setRemoteDescription, e, pc);
},
/**
* Notifies CallStats that peer connection failed to add ICE candidate.
*
* @param {Error} e error to send
* @param {RTCPeerConnection} pc connection on which failure occured.
*/
sendAddIceCandidateFailed: function (e) {
if(!callStats) {
return;
}
callStats.reportError(this.peerconnection, this.confID,
callStats.webRTCFunctions.addIceCandidate, e);
sendAddIceCandidateFailed: function (e, pc) {
this._reportError(wrtcFuncNames.addIceCandidate, e, pc);
}
};
module.exports = CallStats;

View File

@ -113,25 +113,46 @@ var statistics = {
APP.RTC.addListener(RTCEvents.GET_USER_MEDIA_FAILED, function (e) {
CallStats.sendGetUserMediaFailed(e);
});
APP.xmpp.addListener(RTCEvents.CREATE_OFFER_FAILED, function (e) {
CallStats.sendCreateOfferFailed(e);
APP.xmpp.addListener(RTCEvents.CREATE_OFFER_FAILED, function (e, pc) {
CallStats.sendCreateOfferFailed(e, pc);
});
APP.xmpp.addListener(RTCEvents.CREATE_ANSWER_FAILED, function (e) {
CallStats.sendCreateAnswerFailed(e);
APP.xmpp.addListener(RTCEvents.CREATE_ANSWER_FAILED, function (e, pc) {
CallStats.sendCreateAnswerFailed(e, pc);
});
APP.xmpp.addListener(
RTCEvents.SET_LOCAL_DESCRIPTION_FAILED,
function (e) {
CallStats.sendSetLocalDescFailed(e);
});
function (e, pc) {
CallStats.sendSetLocalDescFailed(e, pc);
}
);
APP.xmpp.addListener(
RTCEvents.SET_REMOTE_DESCRIPTION_FAILED,
function (e) {
CallStats.sendSetRemoteDescFailed(e);
});
APP.xmpp.addListener(RTCEvents.ADD_ICE_CANDIDATE_FAILED, function (e) {
CallStats.sendAddIceCandidateFailed(e);
});
function (e, pc) {
CallStats.sendSetRemoteDescFailed(e, pc);
}
);
APP.xmpp.addListener(
RTCEvents.ADD_ICE_CANDIDATE_FAILED,
function (e, pc) {
CallStats.sendAddIceCandidateFailed(e, pc);
}
);
},
/**
* Obtains audio level reported in the stats for specified peer.
* @param peerJid full MUC jid of the user for whom we want to obtain last
* audio level.
* @param ssrc the SSRC of audio stream for which we want to obtain audio
* level.
* @returns {*} a float form 0 to 1 that represents current audio level or
* <tt>null</tt> if for any reason the value is not available
* at this time.
*/
getPeerSSRCAudioLevel: function (peerJid, ssrc) {
var peerStats = rtpStats.jid2stats[peerJid];
return peerStats ? peerStats.ssrc2AudioLevel[ssrc] : null;
}
};

View File

@ -16,7 +16,7 @@ var pluralNouns = [
"Priests", "Rats", "Reptiles", "Reptilians", "Rhinos", "Seagulls", "Sheep",
"Siblings", "Snakes", "Spaghetti", "Spiders", "Squid", "Squirrels",
"Stars", "Students", "Teachers", "Tigers", "Tomatoes", "Trees", "Vampires",
"Vegetables", "Viruses", "Vulcans", "Warewolves", "Weasels", "Whales",
"Vegetables", "Viruses", "Vulcans", "Weasels", "Werewolves", "Whales",
"Witches", "Wizards", "Wolves", "Workers", "Worms", "Zebras"
];
//var places = [

View File

@ -40,7 +40,11 @@ function JingleSessionPC(me, sid, connection, service, eventEmitter) {
this.switchstreams = false;
this.wait = true;
this.localStreamsSSRC = null;
/**
* A map that stores SSRCs of local streams
* @type {{}} maps media type('audio' or 'video') to SSRC number
*/
this.localStreamsSSRC = {};
this.ssrcOwners = {};
this.ssrcVideoTypes = {};
this.eventEmitter = eventEmitter;
@ -256,8 +260,7 @@ JingleSessionPC.prototype.accept = function () {
// FIXME why do we generate session-accept in 3 different places ?
prsdp.toJingle(
accept,
this.initiator == this.me ? 'initiator' : 'responder',
this.localStreamsSSRC);
this.initiator == this.me ? 'initiator' : 'responder');
var sdp = this.peerconnection.localDescription.sdp;
while (SDPUtil.find_line(sdp, 'a=inactive')) {
// FIXME: change any inactive to sendrecv or whatever they were originally
@ -484,8 +487,7 @@ JingleSessionPC.prototype.createdOffer = function (sdp) {
sid: this.sid});
self.localSDP.toJingle(
init,
this.initiator == this.me ? 'initiator' : 'responder',
this.localStreamsSSRC);
this.initiator == this.me ? 'initiator' : 'responder');
SSRCReplacement.processSessionInit(init);
@ -555,6 +557,15 @@ JingleSessionPC.prototype.getSsrcOwner = function (ssrc) {
return this.ssrcOwners[ssrc];
};
/**
* Returns the SSRC of local audio stream.
* @param mediaType 'audio' or 'video' media type
* @returns {*} the SSRC number of local audio or video stream.
*/
JingleSessionPC.prototype.getLocalSSRC = function (mediaType) {
return this.localStreamsSSRC[mediaType];
};
JingleSessionPC.prototype.setRemoteDescription = function (elem, desctype) {
this.remoteSDP = new SDP('');
if (config.webrtcIceTcpDisable) {
@ -720,7 +731,7 @@ JingleSessionPC.prototype.addIceCandidate = function (elem) {
self.peerconnection.addIceCandidate(candidate);
} catch (e) {
console.error('addIceCandidate failed', e.toString(), line);
self.eventEmitter.emit(RTCEvents.ADD_ICE_CANDIDATE_FAILED, err);
self.eventEmitter.emit(RTCEvents.ADD_ICE_CANDIDATE_FAILED, err, self.peerconnection);
}
});
});
@ -1427,13 +1438,22 @@ JingleSessionPC.prototype.setLocalDescription = function () {
'ssrc': ssrc.id,
'type': media.type
});
});
}
else if(self.localStreamsSSRC && self.localStreamsSSRC[media.type])
{
newssrcs.push({
'ssrc': self.localStreamsSSRC[media.type],
'type': media.type
// In FF we have multiple local SSRC per media type, 1 that is
// sending and some that are receive only. The
// localStramsSSRC['audio'] needs to be set to the one that is
// sending! We find it by checking for an msid. Note that
// self.localStreamsSSRC is primarily used by the tests atm.
var isSending = media.ssrcs.some(function (ssrc$1) {
return ssrc$1.id == ssrc.id && ssrc$1.attribute == 'msid';
});
if (!isSending) {
return;
}
// FIXME allows for only one SSRC per media type
self.localStreamsSSRC[media.type] = ssrc.id;
});
}

View File

@ -130,7 +130,9 @@ var storeLocalVideoSSRC = function (jingleIq) {
function generateRecvonlySSRC() {
//
localRecvOnlySSRC =
Math.random().toString(10).substring(2, 11);
localVideoSSRC ?
localVideoSSRC : Math.random().toString(10).substring(2, 11);
localRecvOnlyCName =
Math.random().toString(36).substring(2);
console.info(
@ -175,44 +177,45 @@ var LocalSSRCReplacement = {
// IF we have local video SSRC stored make sure it is replaced
// with old SSRC
if (localVideoSSRC) {
var newSdp = new SDP(localDescription.sdp);
if (newSdp.media[1].indexOf("a=ssrc:") !== -1 &&
!newSdp.containsSSRC(localVideoSSRC)) {
// Get new video SSRC
var map = newSdp.getMediaSsrcMap();
var videoPart = map[1];
var videoSSRCs = videoPart.ssrcs;
var newSSRC = Object.keys(videoSSRCs)[0];
var sdp = new SDP(localDescription.sdp);
if (sdp.media.length < 2)
return;
console.info(
"Replacing new video SSRC: " + newSSRC +
" with " + localVideoSSRC);
if (localVideoSSRC && sdp.media[1].indexOf("a=ssrc:") !== -1 &&
!sdp.containsSSRC(localVideoSSRC)) {
localDescription.sdp =
newSdp.raw.replace(
new RegExp('a=ssrc:' + newSSRC, 'g'),
'a=ssrc:' + localVideoSSRC);
}
} else {
console.info("Does not contain: "
+ localVideoSSRC + "---" + sdp.media[1]);
// Get new video SSRC
var map = sdp.getMediaSsrcMap();
var videoPart = map[1];
var videoSSRCs = videoPart.ssrcs;
var newSSRC = Object.keys(videoSSRCs)[0];
console.info(
"Replacing new video SSRC: " + newSSRC +
" with " + localVideoSSRC);
localDescription.sdp =
sdp.raw.replace(
new RegExp('a=ssrc:' + newSSRC, 'g'),
'a=ssrc:' + localVideoSSRC);
}
else if (sdp.media[1].indexOf('a=ssrc:') === -1 &&
sdp.media[1].indexOf('a=recvonly') !== -1) {
// Make sure we have any SSRC for recvonly video stream
var sdp = new SDP(localDescription.sdp);
if (sdp.media[1] && sdp.media[1].indexOf('a=ssrc:') === -1 &&
sdp.media[1].indexOf('a=recvonly') !== -1) {
if (!localRecvOnlySSRC) {
generateRecvonlySSRC();
}
console.info('No SSRC in video recvonly stream' +
' - adding SSRC: ' + localRecvOnlySSRC);
sdp.media[1] += 'a=ssrc:' + localRecvOnlySSRC +
' cname:' + localRecvOnlyCName + '\r\n';
localDescription.sdp = sdp.session + sdp.media.join('');
if (!localRecvOnlySSRC) {
generateRecvonlySSRC();
}
console.info('No SSRC in video recvonly stream' +
' - adding SSRC: ' + localRecvOnlySSRC);
sdp.media[1] += 'a=ssrc:' + localRecvOnlySSRC +
' cname:' + localRecvOnlyCName + '\r\n';
localDescription.sdp = sdp.session + sdp.media.join('');
}
return localDescription;
},

View File

@ -57,6 +57,7 @@ SDP.prototype.getMediaSsrcMap = function() {
});
tmp = SDPUtil.find_lines(self.media[mediaindex], 'a=ssrc-group:');
tmp.forEach(function(line){
var idx = line.indexOf(' ');
var semantics = line.substr(0, idx).substr(13);
var ssrcs = line.substr(14 + semantics.length).split(' ');
if (ssrcs.length) {
@ -74,16 +75,18 @@ SDP.prototype.getMediaSsrcMap = function() {
* @param ssrc the ssrc to check.
* @returns {boolean} <tt>true</tt> if this SDP contains given SSRC.
*/
SDP.prototype.containsSSRC = function(ssrc) {
SDP.prototype.containsSSRC = function (ssrc) {
// FIXME this code is really strange - improve it if you can
var medias = this.getMediaSsrcMap();
Object.keys(medias).forEach(function(mediaindex){
var media = medias[mediaindex];
//console.log("Check", channel, ssrc);
if(Object.keys(media.ssrcs).indexOf(ssrc) != -1){
return true;
var result = false;
Object.keys(medias).forEach(function (mediaindex) {
if (result)
return;
if (medias[mediaindex].ssrcs[ssrc]) {
result = true;
}
});
return false;
return result;
};
// remove iSAC and CN from SDP
@ -138,7 +141,7 @@ SDP.prototype.removeMediaLines = function(mediaindex, prefix) {
};
// add content's to a jingle element
SDP.prototype.toJingle = function (elem, thecreator, ssrcs) {
SDP.prototype.toJingle = function (elem, thecreator) {
// console.log("SSRC" + ssrcs["audio"] + " - " + ssrcs["video"]);
var i, j, k, mline, ssrc, rtpmap, tmp, lines;
// new bundle plan
@ -165,11 +168,7 @@ SDP.prototype.toJingle = function (elem, thecreator, ssrcs) {
if (SDPUtil.find_line(this.media[i], 'a=ssrc:')) {
ssrc = SDPUtil.find_line(this.media[i], 'a=ssrc:').substring(7).split(' ')[0]; // take the first
} else {
if(ssrcs && ssrcs[mline.media]) {
ssrc = ssrcs[mline.media];
} else {
ssrc = false;
}
ssrc = false;
}
elem.c('content', {creator: thecreator, name: mline.media});

View File

@ -1,4 +1,6 @@
/* jshint -W101 */
var RTCBrowserType = require("../RTC/RTCBrowserType");
var SDPUtil = {
filter_special_chars: function (text) {
return text.replace(/[\\\/\{,\}\+]/g, "");
@ -311,7 +313,14 @@ var SDPUtil = {
line += ' ';
line += cand.getAttribute('component');
line += ' ';
line += cand.getAttribute('protocol'); //.toUpperCase(); // chrome M23 doesn't like this
var protocol = cand.getAttribute('protocol');
// use tcp candidates for FF
if (RTCBrowserType.isFirefox() && protocol.toLowerCase() == 'ssltcp') {
protocol = 'tcp';
}
line += protocol; //.toUpperCase(); // chrome M23 doesn't like this
line += ' ';
line += cand.getAttribute('priority');
line += ' ';
@ -338,7 +347,7 @@ var SDPUtil = {
}
break;
}
if (cand.getAttribute('protocol').toLowerCase() == 'tcp') {
if (protocol.toLowerCase() == 'tcp') {
line += 'tcptype';
line += ' ';
line += cand.getAttribute('tcptype');

View File

@ -218,6 +218,8 @@ if (TraceablePeerConnection.prototype.__defineGetter__ !== undefined) {
function() {
var desc = this.peerconnection.localDescription;
// FIXME this should probably be after the Unified Plan -> Plan B
// transformation.
desc = SSRCReplacement.mungeLocalVideoSSRC(desc);
this.trace('getLocalDescription::preTransform', dumpSDP(desc));
@ -293,7 +295,7 @@ TraceablePeerConnection.prototype.setLocalDescription
},
function (err) {
self.trace('setLocalDescriptionOnFailure', err);
self.eventEmitter.emit(RTCEvents.SET_LOCAL_DESCRIPTION_FAILED, err);
self.eventEmitter.emit(RTCEvents.SET_LOCAL_DESCRIPTION_FAILED, err, self.peerconnection);
failureCallback(err);
}
);
@ -329,7 +331,7 @@ TraceablePeerConnection.prototype.setRemoteDescription
},
function (err) {
self.trace('setRemoteDescriptionOnFailure', err);
self.eventEmitter.emit(RTCEvents.SET_REMOTE_DESCRIPTION_FAILED, err);
self.eventEmitter.emit(RTCEvents.SET_REMOTE_DESCRIPTION_FAILED, err, self.peerconnection);
failureCallback(err);
}
);
@ -366,6 +368,7 @@ TraceablePeerConnection.prototype.createOffer
}
offer = SSRCReplacement.mungeLocalVideoSSRC(offer);
self.trace('createOfferOnSuccess::mungeLocalVideoSSRC', dumpSDP(offer));
if (config.enableSimulcast && self.simulcast.isSupported()) {
offer = self.simulcast.mungeLocalDescription(offer);
@ -375,7 +378,7 @@ TraceablePeerConnection.prototype.createOffer
},
function(err) {
self.trace('createOfferOnFailure', err);
self.eventEmitter.emit(RTCEvents.CREATE_OFFER_FAILED, err);
self.eventEmitter.emit(RTCEvents.CREATE_OFFER_FAILED, err, self.peerconnection);
failureCallback(err);
},
constraints
@ -397,6 +400,7 @@ TraceablePeerConnection.prototype.createAnswer
// munge local video SSRC
answer = SSRCReplacement.mungeLocalVideoSSRC(answer);
self.trace('createAnswerOnSuccess::mungeLocalVideoSSRC', dumpSDP(answer));
if (config.enableSimulcast && self.simulcast.isSupported()) {
answer = self.simulcast.mungeLocalDescription(answer);
@ -406,7 +410,7 @@ TraceablePeerConnection.prototype.createAnswer
},
function(err) {
self.trace('createAnswerOnFailure', err);
self.eventEmitter.emit(RTCEvents.CREATE_ANSWER_FAILED, err);
self.eventEmitter.emit(RTCEvents.CREATE_ANSWER_FAILED, err, self.peerconnection);
failureCallback(err);
},
constraints

View File

@ -149,6 +149,14 @@ var Moderator = {
{ name: 'bridge', value: config.hosts.bridge})
.up();
}
if (config.enforcedBridge) {
elem.c(
'property',
{ name: 'enforcedBridge', value: config.enforcedBridge})
.up();
}
// Tell the focus we have Jigasi configured
if (config.hosts.call_control !== undefined) {
elem.c(

View File

@ -562,10 +562,6 @@ module.exports = function(XMPP, eventEmitter) {
delete this.presMap["startMuted"];
}
if (config.token) {
pres.c('token', { xmlns: 'http://jitsi.org/jitmeet/auth-token'}).t(config.token).up();
}
pres.up();
this.connection.send(pres);
},

View File

@ -308,21 +308,16 @@ var XMPP = {
configDomain = config.hosts.domain;
}
var jid = configDomain || window.location.hostname;
var password = null;
if (config.token) {
password = config.token;
if (config.id) {
jid = config.id + "@" + jid;
} else {
jid = generateUserName() + "@" + jid;
}
}
connect(jid, password);
connect(jid);
},
createConnection: function () {
var bosh = config.bosh || '/http-bind';
// adds the room name used to the bosh connection
return new Strophe.Connection(bosh + '?ROOM=' + APP.UI.getRoomNode());
bosh += '?room=' + APP.UI.getRoomNode();
if (config.token) {
bosh += "&token=" + config.token;
}
return new Strophe.Connection(bosh);
},
getStatusString: function (status) {
return Strophe.getStatusString(status);
@ -596,6 +591,19 @@ var XMPP = {
return null;
return connection.jingle.activecall.getSsrcOwner(ssrc);
},
/**
* Gets the SSRC of local media stream.
* @param mediaType the media type that tells whether we want to get
* the SSRC of local audio or video stream.
* @returns {*} the SSRC number for local media stream or <tt>null</tt> if
* not available.
*/
getLocalSSRC: function (mediaType) {
if (!this.isConferenceInProgress()) {
return null;
}
return connection.jingle.activecall.getLocalSSRC(mediaType);
},
// Returns true iff we have joined the MUC.
isMUCJoined: function () {
return connection === null ? false : connection.emuc.joined;

View File

@ -27,9 +27,9 @@
"jssha": "1.5.0",
"pako": "*",
"retry": "0.6.1",
"sdp-interop": "0.1.10",
"sdp-simulcast": "0.1.0",
"sdp-transform": "1.4.1",
"sdp-interop": "0.1.11",
"sdp-simulcast": "0.1.3",
"sdp-transform": "1.5.*",
"socket.io-client": "1.3.6",
"strophe": "^1.2.2",
"strophejs-plugins": "^0.0.6",

View File

@ -1,42 +1,46 @@
-- Token authentication
-- Copyright (C) 2015 Atlassian
local usermanager = require "core.usermanager";
local generate_uuid = require "util.uuid".generate;
local new_sasl = require "util.sasl".new;
local log = module._log;
local host = module.host;
local sasl = require "util.sasl";
local formdecode = require "util.http".formdecode;
local token_util = module:require "token/util";
-- define auth provider
local provider = {};
--do
-- local list;
-- for mechanism in pairs(new_sasl(module.host):mechanisms()) do
-- list = (not(list) and mechanism) or (list..", "..mechanism);
-- end
-- if not list then
-- module:log("error", "No mechanisms");
-- else
-- module:log("error", "Mechanisms: %s", list);
-- end
--end
local host = module.host;
local appId = module:get_option_string("app_id");
local appSecret = module:get_option_string("app_secret");
local tokenLifetime = module:get_option_number("token_lifetime");
local allowEmptyToken = module:get_option_boolean("allow_empty_token");
if allowEmptyToken == true then
module:log("warn", "WARNING - empty tokens allowed");
end
if appId == nil then
module:log("error", "'app_id' must not be empty");
return;
end
if appSecret == nil then
module:log("error", "'app_secret' must not be empty");
return;
end
-- Extract 'token' param from BOSH URL when session is created
module:hook("bosh-session", function(event)
local session, request = event.session, event.request;
local query = request.url.query;
if query ~= nil then
session.auth_token = query and formdecode(query).token or nil;
end
end)
function provider.test_password(username, password)
local result, msg = token_util.verify_password(password, appId, appSecret, tokenLifetime);
if result == true then
return true;
else
log("error", "Token auth failed for user %s, reason: %s",username, msg);
return nil, msg;
end
return nil, "Password based auth not supported";
end
function provider.get_password(username)
@ -51,10 +55,6 @@ function provider.user_exists(username)
return nil;
end
function provider.users()
return next, hosts[module.host].sessions, nil;
end
function provider.create_user(username, password)
return nil;
end
@ -63,13 +63,59 @@ function provider.delete_user(username)
return nil;
end
function provider.get_sasl_handler()
local testpass_authentication_profile = {
plain_test = function(sasl, username, password, realm)
return usermanager.test_password(username, realm, password), true;
function provider.get_sasl_handler(session)
-- JWT token extracted from BOSH URL
local token = session.auth_token;
local function get_username_from_token(self, message)
if token == nil then
if allowEmptyToken == true then
return true;
else
return false, "not-allowed", "token required";
end
end
};
return new_sasl(host, testpass_authentication_profile);
-- here we check if 'room' claim exists
local room, roomErr = token_util.get_room_name(token, appSecret);
if room == nil then
return false, "not-allowed", roomErr;
end
-- now verify the whole token
local result, msg
= token_util.verify_token(token, appId, appSecret, room);
if result == true then
-- Binds room name to the session which is later checked on MUC join
session.jitsi_meet_room = room;
return true
else
return false, "not-allowed", msg
end
end
return new_sasl(host, { anonymous = get_username_from_token });
end
module:provides("auth", provider);
local function anonymous(self, message)
local username = generate_uuid();
-- This calls the handler created in 'provider.get_sasl_handler(session)'
local result, err, msg = self.profile.anonymous(self, username, self.realm);
self.username = username;
if result == true then
return "success"
else
return "failure", err, msg
end
end
sasl.registerMechanism("ANONYMOUS", {"anonymous"}, anonymous);

View File

@ -0,0 +1,12 @@
--- /usr/lib/prosody/modules/mod_bosh.lua 2015-12-16 14:28:34.000000000 -0600
+++ /usr/lib/prosody/modules/mod_bosh.lua 2015-12-22 10:45:59.818197967 -0600
@@ -294,6 +294,9 @@
session.log("debug", "BOSH session created for request from %s", session.ip);
log("info", "New BOSH session, assigned it sid '%s'", sid);
+
+ hosts[session.host].events.fire_event(
+ "bosh-session", { session = session, request = request });
-- Send creation response
local creating_session = true;

View File

@ -4,7 +4,6 @@
local log = module._log;
local host = module.host;
local st = require "util.stanza";
local token_util = module:require("token/util");
local is_admin = require "core.usermanager".is_admin;
@ -16,37 +15,70 @@ end
local parentCtx = module:context(parentHostName);
if parentCtx == nil then
log("error", "Failed to start - unable to get parent context for host: %s", tostring(parentHostName));
log("error",
"Failed to start - unable to get parent context for host: %s",
tostring(parentHostName));
return;
end
local appId = parentCtx:get_option_string("app_id");
local appSecret = parentCtx:get_option_string("app_secret");
local tokenLifetime = parentCtx:get_option_string("token_lifetime");
local allowEmptyToken = parentCtx:get_option_boolean("allow_empty_token");
log("debug", "%s - starting MUC token verifier app_id: %s app_secret: %s token-lifetime: %s",
tostring(host), tostring(appId), tostring(appSecret), tostring(tokenLifetime));
log("debug",
"%s - starting MUC token verifier app_id: %s app_secret: %s allow empty: %s",
tostring(host), tostring(appId), tostring(appSecret),
tostring(allowEmptyToken));
local function verify_user(session, stanza)
log("debug", "Session token: %s, session room: %s",
tostring(session.auth_token),
tostring(session.jitsi_meet_room));
if allowEmptyToken and session.auth_token == nil then
module:log(
"debug",
"Skipped room token verification - empty tokens are allowed");
return nil;
end
local function handle_pre_create(event)
local origin, stanza = event.origin, event.stanza;
local token = stanza:get_child("token", "http://jitsi.org/jitmeet/auth-token");
-- token not required for admin users
local user_jid = stanza.attr.from;
local user_jid = stanza.attr.from;
if is_admin(user_jid) then
log("debug", "Token not required from admin user: %s", user_jid);
return nil;
end
log("debug", "Will verify token for user: %s ", user_jid);
if token ~= nil then
token = token[1];
local room = string.match(stanza.attr.to, "^(%w+)@");
log("debug", "Will verify token for user: %s, room: %s ", user_jid, room);
if room == nil then
log("error",
"Unable to get name of the MUC room ? to: %s", stanza.attr.to);
return nil;
end
local result, msg = token_util.verify_password(token, appId, appSecret, tokenLifetime);
if result ~= true then
log("debug", "Token verification failed: %s", msg);
origin.send(st.error_reply(stanza, "cancel", "not-allowed", msg));
local token = session.auth_token;
local auth_room = session.jitsi_meet_room;
if room ~= auth_room then
log("error", "Token %s not allowed to join: %s",
tostring(token), tostring(auth_room));
session.send(
st.error_reply(
stanza, "cancel", "not-allowed", "Room and token mismatched"));
return true;
end
log("debug", "allowed: %s to enter/create room: %s", user_jid, room);
end
module:hook("muc-room-pre-create", handle_pre_create);
module:hook("muc-room-pre-create", function(event)
local origin, stanza = event.origin, event.stanza;
log("debug", "pre create: %s %s", tostring(origin), tostring(stanza));
return verify_user(origin, stanza);
end);
module:hook("muc-occupant-pre-join", function(event)
local origin, room, stanza = event.origin, event.room, event.stanza;
log("debug", "pre join: %s %s", tostring(room), tostring(stanza));
return verify_user(origin, stanza);
end);

View File

@ -1,76 +1,51 @@
-- Token authentication
-- Copyright (C) 2015 Atlassian
local hashes = require "util.hashes";
local jwt = require "luajwt";
local _M = {};
local function calc_hash(password, appId, appSecret)
local hash, room, ts = string.match(password, "(%w+)_(%w+)_(%d+)");
if hash ~= nil and room ~= nil and ts ~= nil then
log("debug", "Hash: '%s' room: '%s', ts: '%s'", hash, room, ts);
local toHash = room .. ts .. appId .. appSecret;
log("debug", "to be hashed: '%s'", toHash);
local hash = hashes.sha256(toHash, true);
log("debug", "hash: '%s'", hash);
return hash;
local function _get_room_name(token, appSecret)
local claims, err = jwt.decode(token, appSecret);
if claims ~= nil then
return claims["room"];
else
log("error", "Invalid password format: '%s'", password);
return nil;
return nil, err;
end
end
local function extract_hash(password)
local hash, room, ts = string.match(password, "(%w+)_(%w+)_(%d+)");
return hash;
end
local function _verify_token(token, appId, appSecret, roomName)
local function extract_ts(password)
local hash, room, ts = string.match(password, "(%w+)_(%w+)_(%d+)");
return ts;
end
local function get_utc_timestamp()
return os.time(os.date("!*t")) * 1000;
end
local function verify_timestamp(ts, tokenLifetime)
return get_utc_timestamp() - ts <= tokenLifetime;
end
local function verify_password_impl(password, appId, appSecret, tokenLifetime)
if password == nil then
return nil, "password is missing";
end
if tokenLifetime == nil then
tokenLifetime = 24 * 60 * 60 * 1000;
local claims, err = jwt.decode(token, appSecret, true);
if claims == nil then
return nil, err;
end
local ts = extract_ts(password);
if ts == nil then
return nil, "timestamp not found in the password";
local issClaim = claims["iss"];
if issClaim == nil then
return nil, "Issuer field is missing";
end
local os_ts = get_utc_timestamp();
log("debug", "System TS: '%s' user TS: %s", tostring(os_ts), tostring(ts));
local isValid = verify_timestamp(ts, tokenLifetime);
if not isValid then
return nil, "token expired";
if issClaim ~= appId then
return nil, "Invalid application ID('iss' claim)";
end
local realHash = calc_hash(password, appId, appSecret);
local givenhash = extract_hash(password);
log("debug", "Compare '%s' to '%s'", tostring(realHash), tostring(givenhash));
if realHash == givenhash then
return true;
else
return nil, "invalid hash";
local roomClaim = claims["room"];
if roomClaim == nil then
return nil, "Room field is missing";
end
if roomName ~= nil and roomName ~= roomClaim then
return nil, "Invalid room name('room' claim)";
end
return true;
end
function _M.verify_password(password, appId, appSecret, tokenLifetime)
return verify_password_impl(password, appId, appSecret, tokenLifetime);
function _M.verify_token(token, appId, appSecret, roomName)
return _verify_token(token, appId, appSecret, roomName);
end
return _M;
function _M.get_room_name(token, appSecret)
return _get_room_name(token, appSecret);
end
return _M;

View File

@ -2,6 +2,11 @@ var UIEvents = {
NICKNAME_CHANGED: "UI.nickname_changed",
SELECTED_ENDPOINT: "UI.selected_endpoint",
PINNED_ENDPOINT: "UI.pinned_endpoint",
LARGEVIDEO_INIT: "UI.largevideo_init"
LARGEVIDEO_INIT: "UI.largevideo_init",
/**
* Notifies interested parties when the film strip (remote video's panel)
* is hidden (toggled) or shown (un-toggled).
*/
FILM_STRIP_TOGGLED: "UI.filmstrip_toggled"
};
module.exports = UIEvents;

View File

@ -12,33 +12,50 @@
<div class="supported_browsers">
<div class="browser_wrapper">
Chrome
Chrome 44+
<div class="browser">
<div class="logo" id="chrome_logo"></div>
<a href="http://google.com/chrome"><div class="button">DOWNLOAD</div></a>
</div>
</div>
<div class="browser_wrapper">
Chromium
Chromium 44+
<div class="browser">
<div class="logo" id="chromium_logo"></div>
<a href="http://www.chromium.org/"><div class="button">DOWNLOAD</div></a>
</div>
</div>
<div class="browser_wrapper">
Opera
Opera 32+
<div class="browser">
<div class="logo" id="opera_logo"></div>
<a href="http://www.opera.com"><div class="button">DOWNLOAD</div></a>
</div>
</div>
<div class="browser_wrapper">
Firefox
<div class="browser_text">
Firefox and Iceweasel 40+</div>
<div class="browser">
<div class="logo" id="firefox_logo"></div>
<a href="http://www.getfirefox.com/"><div class="button">DOWNLOAD</div></a>
</div>
</div>
<div class="browser_wrapper">
<div class="browser_text">
IE <br /> <span style="font-size: small">(Temasys 0.8.854+)</span></div>
<div class="browser">
<div class="logo" id="ie_logo"></div>
<a href="https://temasys.atlassian.net/wiki/display/TWPP/WebRTC+Plugins"><div class="button">DOWNLOAD</div></a>
</div>
</div>
<div class="browser_wrapper">
<div class="browser_text">
Safari <br /> <span style="font-size: small">(Temasys 0.8.854+)</span></div>
<div class="browser">
<div class="logo" id="safari_logo"></div>
<a href="https://temasys.atlassian.net/wiki/display/TWPP/WebRTC+Plugins"><div class="button">DOWNLOAD</div></a>
</div>
</div>
</div>
<div class="clear"></div>
</div>