From d663ee40bbbab6c5293793d773aa2c667af98ca5 Mon Sep 17 00:00:00 2001 From: turint Date: Thu, 5 Jun 2014 13:22:44 +0300 Subject: [PATCH 001/107] Added favicon.ico for 1) better jitsi brand recognition and 2) getting rid of warnings in web server log files --- favicon.ico | Bin 0 -> 3638 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 favicon.ico diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..bb34caf1ffdb68de313a75db9e9f8ff87ece3ec4 GIT binary patch literal 3638 zcmeH}cTiMU6vn^hxw^_K3kXPW3Id{{f;4HatDr1`h@g}Q#NGu~4c1r^8buG*{qAyEe1&2W{mWmX2{q( ziO3pR2keOKkrm^JoP&H46URedJP*0?(~)<`gJ>r5 zzLUrk`G;pC|A-IK92EQ@kuM64N;DUR34SO@n1{k2{ZVvmK8lV9q2y!;5si{lp(s8T zj?&YiC^;RD;?oOJdL|r&XO^KXaS=)rm!SOYQk0%uPP780=T@SOsN%vZlwXWMg;Y_f z_&Jg&3YC`ysJI-BswiTvFuYUp2jcr8RQFU`CM0aCQeJ=(NllGw|DHgRU@ugUpKo5`L)&|zHne~EfcAEY+Iu{g{$zg#{)-OKG(C4VxpWz$oVx@x?Wd#~5**T* znl(w&jKvxs@K0vCk)^ec{=~ujKv~9PjJX{lkG<|#Sv-Hp3@vwk1x-V3Z&|`u!E zynv<8Q=a!Rq-pPAj^jML#B|`G33Ix}w8aSN^Zwf&OE_ylNdu{yG?BbX7b#l|k-F7T%HOo@CP>?1hSVKnaDS&c(sx-RZMPLN z_Dmp5wn6${J7ny$Mdp5cWX4QEHeqt+K^c#;VqKB*4Ph~1aPGJ6$d$5}kGu~4O1PV! z5P*_nekeR1jN%i)C^|tC#?pkbVT7#6<*x}(Y0uz%@AJOhO%qBq+Au=*agu|!q;10LU?O0s&DUx@b-Q@xU&bf zcfZ1ey9ZEn?*JYo#i2Smp0GAf%G=81BdAL|gu3*@s3qL3`Slp;GmfGz^CaqXenNfj zc|6KLkA{MacvN@^jYU_{RD1<>MYqvdd;^b5iOPRLbHyDr36t?yn2KgmI+{co5Z7iy zT$hjM^@R}Em*ROt30j&e3A06b)m)2L;(ELkKf>$h4QPGw7;jphqP682-nO<7hPU9| zAFX)**ITr;z3b<9Z!dg*UakWI*;wt+@uQb%qE0V{EWM0kM)Xx+4utpNFU2r;^x<>i zPZ<+GDyruqL*@!&>N5{R4#$AEvWJqPniKsQ0CR)}6O=<_w+iMcBO@=iyN|Qm9A9nd zciYN96GON!Qj#=I(Z?Dr>vi%=unerQz=A(S+F6g0YS36MSD?oVWcVxk29aj~sKvHU zknE;&@WJD*3DOuR`CtYntBXo?-UFlZ7REH^IOm#Np)7p&AJ_j$2F z={V2eYv}|oH)X7nD-bLj%5hd_f??!DQ9Rq3d<8#we=TLEry3gOsK>VAYcQO=7}Ml? zy#^NAfR_DqYgJ&d-7Jikzdwqt!j(R~WRK%iys0trzQ7sQd<_bd{j~sBh1m>?Py7fy zsBrzlEtxKsgIp+IO#8$aM7j-zf-C7?_`E<*ea7NXeSsiy0s_<-JgX3MC0KX21p-gT zR8uEeB)60UmP3#aQ@ Date: Sun, 22 Jun 2014 16:12:56 +0200 Subject: [PATCH 002/107] dont call SDPUtil.find_line without the media section --- app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index c7e7a0424..7543c9310 100644 --- a/app.js +++ b/app.js @@ -530,8 +530,8 @@ $(document).bind('setLocalDescription.jingle', function (event, sid) { directions[type] = ( SDPUtil.find_line(media, 'a=sendrecv') || SDPUtil.find_line(media, 'a=recvonly') || - SDPUtil.find_line('a=sendonly') || - SDPUtil.find_line('a=inactive') || + SDPUtil.find_line(media, 'a=sendonly') || + SDPUtil.find_line(media, 'a=inactive') || 'a=sendrecv').substr(2); } }); From 1c1c3f8f5b42cdcb2abefef9e0e32f97400f1d03 Mon Sep 17 00:00:00 2001 From: yanas Date: Tue, 24 Jun 2014 14:59:14 +0300 Subject: [PATCH 003/107] Fixes active speaker relevant issues. Moves all UI related code in the videolayout.js --- app.js | 7 +++- data_channels.js | 23 +++---------- videolayout.js | 84 +++++++++++++++++++++++++++++++++++++----------- 3 files changed, 76 insertions(+), 38 deletions(-) diff --git a/app.js b/app.js index 7543c9310..5120421c0 100644 --- a/app.js +++ b/app.js @@ -319,7 +319,10 @@ $(document).bind('remotestreamadded.jingle', function (event, data, sid) { // If the video has been "pinned" by the user we want to keep the // display name on place. - if (focusedVideoSrc !== videoSrc) + if (focusedVideoSrc && focusedVideoSrc !== videoSrc + || (!focusedVideoSrc + && container.id + !== VideoLayout.getActiveSpeakerContainerId())) VideoLayout.showDisplayName(container.id, false); } ); @@ -436,6 +439,8 @@ function statsUpdated(statsCollector) { console.info(jid + " audio level: " + peerStats.ssrc2AudioLevel[ssrc] + " of ssrc: " + ssrc); +// VideoLayout.updateAudioLevel( Strophe.getResourceFromJid(jid), +// peerStats.ssrc2AudioLevel[ssrc]); }); }); } diff --git a/data_channels.js b/data_channels.js index aba083a46..7a1ae59e3 100644 --- a/data_channels.js +++ b/data_channels.js @@ -30,27 +30,14 @@ function onDataChannel(event) console.info("Got Data Channel Message:", msgData, dataChannel); // Active speaker event - if (msgData.indexOf('activeSpeaker') === 0 && !focusedVideoSrc) + if (msgData.indexOf('activeSpeaker') === 0) { // Endpoint ID from the bridge - var endpointId = msgData.split(":")[1]; - console.info("New active speaker: " + endpointId); + var resourceJid = msgData.split(":")[1]; - var container = document.getElementById( - 'participant_' + endpointId); - - // Local video will not have container found, but that's ok - // since we don't want to switch to local video - - if (container) - { - var video = container.getElementsByTagName("video"); - if (video.length) - { - VideoLayout.updateLargeVideo(video[0].src); - VideoLayout.enableActiveSpeaker(endpointId, true); - } - } + console.info( + "Data channel new active speaker event: " + resourceJid); + $(document).trigger('activespeakerchanged', [resourceJid]); } }; diff --git a/videolayout.js b/videolayout.js index 634801067..881a0dba1 100644 --- a/videolayout.js +++ b/videolayout.js @@ -138,8 +138,7 @@ var VideoLayout = (function (my) { my.handleVideoThumbClicked = function(videoSrc) { // Restore style for previously focused video var focusJid = getJidFromVideoSrc(focusedVideoSrc); - var oldContainer = - getParticipantContainer(focusJid); + var oldContainer = getParticipantContainer(focusJid); if (oldContainer) { oldContainer.removeClass("videoContainerFocused"); @@ -147,12 +146,22 @@ var VideoLayout = (function (my) { Strophe.getResourceFromJid(focusJid), false); } - // Unlock + // Unlock current focused. if (focusedVideoSrc === videoSrc) { focusedVideoSrc = null; + // Enable the currently set active speaker. + if (currentActiveSpeaker) { + VideoLayout.enableActiveSpeaker(currentActiveSpeaker, true); + } + return; } + // Remove style for current active speaker if we're going to lock + // another video. + else if (currentActiveSpeaker) { + VideoLayout.enableActiveSpeaker(currentActiveSpeaker, false); + } // Lock new video focusedVideoSrc = videoSrc; @@ -526,7 +535,12 @@ var VideoLayout = (function (my) { * disabled */ my.enableActiveSpeaker = function(resourceJid, isEnable) { - console.log("Enable active speaker", resourceJid, isEnable); + var displayName = resourceJid; + var nameSpan = $('#participant_' + resourceJid + '>span.displayname'); + if (nameSpan.length > 0) + displayName = nameSpan.text(); + + console.log("Enable active speaker", displayName, isEnable); var videoSpanId = null; if (resourceJid @@ -542,21 +556,6 @@ var VideoLayout = (function (my) { return; } - // If there's an active speaker (automatically) selected we have to - // disable this state and update the current active speaker. - if (isEnable) { - if (currentActiveSpeaker) { - var oldSpeaker = currentActiveSpeaker; - setTimeout(function () { - VideoLayout.enableActiveSpeaker(oldSpeaker, false); - }, 200); - } - - currentActiveSpeaker = resourceJid; - } - else if (resourceJid === currentActiveSpeaker) - currentActiveSpeaker = null; - var video = $('#' + videoSpanId + '>video'); if (video && video.length > 0) { @@ -733,6 +732,13 @@ var VideoLayout = (function (my) { } }; + /** + * Returns the current active speaker. + */ + my.getActiveSpeakerContainerId = function () { + return 'participant_' + currentActiveSpeaker; + }; + /** * Adds the remote video menu element for the given jid in the * given parentElement. @@ -841,5 +847,45 @@ var VideoLayout = (function (my) { VideoLayout.showVideoIndicator(videoSpanId, isMuted); }); + /** + * On active speaker changed event. + */ + $(document).bind('activespeakerchanged', function (event, resourceJid) { + + // Disable style for previous active speaker. + if (currentActiveSpeaker + && currentActiveSpeaker !== resourceJid + && !focusedVideoSrc) { + var oldContainer = document.getElementById( + 'participant_' + currentActiveSpeaker); + + if (oldContainer) { + VideoLayout.enableActiveSpeaker(currentActiveSpeaker, false); + } + } + + // Obtain container for new active speaker. + var container = document.getElementById( + 'participant_' + resourceJid); + + // Update the current active speaker. + if (resourceJid !== currentActiveSpeaker) + currentActiveSpeaker = resourceJid; + else + return; + + // Local video will not have container found, but that's ok + // since we don't want to switch to local video. + if (container && !focusedVideoSrc) + { + var video = container.getElementsByTagName("video"); + if (video.length) + { + VideoLayout.updateLargeVideo(video[0].src); + VideoLayout.enableActiveSpeaker(resourceJid, true); + } + } + }); + return my; }(VideoLayout || {})); \ No newline at end of file From ccfa620f461f2043a09fda45a544463a944058b7 Mon Sep 17 00:00:00 2001 From: yanas Date: Tue, 24 Jun 2014 15:02:20 +0300 Subject: [PATCH 004/107] Updates js versions. --- index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 69bc395f4..8d3670008 100644 --- a/index.html +++ b/index.html @@ -26,8 +26,8 @@ - - + + @@ -38,7 +38,7 @@ - + From 873cfebe8472886e146b5852f295c3ad607e0189 Mon Sep 17 00:00:00 2001 From: hristoterezov Date: Thu, 26 Jun 2014 16:58:43 +0200 Subject: [PATCH 005/107] Adds audio level statistics for the local stream. --- app.js | 57 +++++++++++++++++++++++++++-- index.html | 1 + local_stats.js | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 local_stats.js diff --git a/app.js b/app.js index 5120421c0..e671642fc 100644 --- a/app.js +++ b/app.js @@ -14,6 +14,12 @@ var ssrc2jid = {}; */ var statsCollector = null; +/** + * The stats collector for the local stream. + * @type {LocalStatsCollector} + */ +var localStatsCollector = null; + /** * Indicates whether ssrc is camera video or desktop stream. * FIXME: remove those maps @@ -123,6 +129,8 @@ function audioStreamReady(stream) { VideoLayout.changeLocalAudio(stream); + startLocalRtpStatsCollector(stream); + if (RTC.browser !== 'firefox') { getUserMediaWithConstraints(['video'], videoStreamReady, @@ -445,19 +453,55 @@ function statsUpdated(statsCollector) }); } +/** + * Callback called by {@link LocalStatsCollector} in intervals supplied to it's + * constructor. + * @param statsCollector {@link LocalStatsCollector} source of the event. + */ +function localStatsUpdated(statsCollector) +{ + console.info("Local audio level: " + statsCollector.audioLevel); +} + /** * Starts the {@link StatsCollector} if the feature is enabled in config.js. */ function startRtpStatsCollector() { - if (config.enableRtpStats) + if (config.enableRtpStats && !statsCollector) { statsCollector = new StatsCollector( getConferenceHandler().peerconnection, 200, statsUpdated); + stopLocalRtpStatsCollector(); + statsCollector.start(); } } +/** + * Starts the {@link LocalStatsCollector} if the feature is enabled in config.js + * @param stream the stream that will be used for collecting statistics. + */ +function startLocalRtpStatsCollector(stream) +{ + if(config.enableRtpStats) + { + localStatsCollector = new LocalStatsCollector(stream, 200, localStatsUpdated); + localStatsCollector.start(); + } +} + +/** + * Stops the {@link LocalStatsCollector}. + */ +function stopLocalRtpStatsCollector() +{ + if(localStatsCollector) + { + localStatsCollector.stop(); + localStatsCollector = null; + } +} $(document).bind('callincoming.jingle', function (event, sid) { var sess = connection.jingle.sessions[sid]; @@ -965,10 +1009,10 @@ $(window).bind('beforeunload', function () { } }); } - disposeConference(); + disposeConference(true); }); -function disposeConference() { +function disposeConference(onUnload) { var handler = getConferenceHandler(); if (handler && handler.peerconnection) { // FIXME: probably removing streams is not required and close() should be enough @@ -985,6 +1029,13 @@ function disposeConference() { statsCollector.stop(); statsCollector = null; } + if(!onUnload) { + startLocalRtpStatsCollector(connection.jingle.localAudio); + } + else + { + stopLocalRtpStatsCollector(); + } focus = null; activecall = null; } diff --git a/index.html b/index.html index 8d3670008..a99546eb9 100644 --- a/index.html +++ b/index.html @@ -38,6 +38,7 @@ + diff --git a/local_stats.js b/local_stats.js new file mode 100644 index 000000000..2802fe455 --- /dev/null +++ b/local_stats.js @@ -0,0 +1,97 @@ +/** + * Provides statistics for the local stream. + */ +var LocalStatsCollector = (function() { + /** + * Size of the webaudio analizer buffer. + * @type {number} + */ + var WEBAUDIO_ANALIZER_FFT_SIZE = 512; + + /** + * Value of the webaudio analizer smoothing time parameter. + * @type {number} + */ + var WEBAUDIO_ANALIZER_SMOOTING_TIME = 0.1; + + /** + * LocalStatsCollector calculates statistics for the local stream. + * + * @param stream the local stream + * @param interval stats refresh interval given in ms. + * @param {function(LocalStatsCollector)} updateCallback the callback called on stats + * update. + * @constructor + */ + function LocalStatsCollectorProto(stream, interval, updateCallback) { + window.AudioContext = window.AudioContext || window.webkitAudioContext; + this.stream = stream; + this.intervalId = null; + this.intervalMilis = interval; + this.updateCallback = updateCallback; + this.audioLevel = 0; + } + + + /** + * Starts the collecting the statistics. + */ + LocalStatsCollectorProto.prototype.start = function () { + if (!window.AudioContext) + return; + + var context = new AudioContext(); + var analyser = context.createAnalyser(); + analyser.smoothingTimeConstant = WEBAUDIO_ANALIZER_SMOOTING_TIME; + analyser.fftSize = WEBAUDIO_ANALIZER_FFT_SIZE; + + + var source = context.createMediaStreamSource(this.stream); + source.connect(analyser); + + + var self = this; + + this.intervalId = setInterval( + function () { + var array = new Uint8Array(analyser.frequencyBinCount); + analyser.getByteFrequencyData(array); + self.audioLevel = FrequencyDataToAudioLevel(array); + self.updateCallback(self); + }, + this.intervalMilis + ); + + } + + /** + * Stops collecting the statistics. + */ + LocalStatsCollectorProto.prototype.stop = function () { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + + /** + * Converts frequency data array to audio level. + * @param array the frequency data array. + * @returns {number} the audio level + */ + var FrequencyDataToAudioLevel = function (array) { + var maxVolume = 0; + + var length = array.length; + + for (var i = 0; i < length; i++) { + if (maxVolume < array[i]) + maxVolume = array[i]; + } + + return maxVolume / 255; + } + + return LocalStatsCollectorProto; +})(); \ No newline at end of file From f193d0d51d527f14436eb7849f290c5fce3c99a1 Mon Sep 17 00:00:00 2001 From: Yana Stamcheva Date: Fri, 27 Jun 2014 18:27:24 +0200 Subject: [PATCH 006/107] Fixes problem with local speaker removing the active speaker selection. Fixes problem with tooltip appearing behind video. --- css/videolayout_default.css | 2 +- videolayout.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/css/videolayout_default.css b/css/videolayout_default.css index 2e8195272..5c04ef009 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -32,6 +32,7 @@ background-size: contain; border-radius:8px; border: 2px solid #212425; + margin-right: 3px; } #remoteVideos .videocontainer:hover, @@ -50,7 +51,6 @@ -webkit-animation-iteration-count: 1; -webkit-box-shadow: 0 0 18px #388396; border: 2px solid #388396; - z-index: 3; } #localVideoWrapper { diff --git a/videolayout.js b/videolayout.js index 881a0dba1..fc6036682 100644 --- a/videolayout.js +++ b/videolayout.js @@ -816,6 +816,9 @@ var VideoLayout = (function (my) { popupmenuElement.appendChild(ejectMenuItem); } + /** + * On audio muted event. + */ $(document).bind('audiomuted.muc', function (event, jid, isMuted) { var videoSpanId = null; if (jid === connection.emuc.myroomjid) { @@ -834,6 +837,9 @@ var VideoLayout = (function (my) { VideoLayout.showAudioIndicator(videoSpanId, isMuted); }); + /** + * On video muted event. + */ $(document).bind('videomuted.muc', function (event, jid, isMuted) { var videoSpanId = null; if (jid === connection.emuc.myroomjid) { @@ -851,6 +857,10 @@ var VideoLayout = (function (my) { * On active speaker changed event. */ $(document).bind('activespeakerchanged', function (event, resourceJid) { + // We ignore local user events. + if (resourceJid + === Strophe.getResourceFromJid(connection.emuc.myroomjid)) + return; // Disable style for previous active speaker. if (currentActiveSpeaker @@ -888,4 +898,4 @@ var VideoLayout = (function (my) { }); return my; -}(VideoLayout || {})); \ No newline at end of file +}(VideoLayout || {})); From 3f6c048426dabfa2c1b88e8728e1b284b1ad27dd Mon Sep 17 00:00:00 2001 From: Philipp Hancke Date: Tue, 1 Jul 2014 09:06:23 +0200 Subject: [PATCH 007/107] chrome canary and firefox dont include a= --- libs/strophe/strophe.jingle.sdp.util.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/strophe/strophe.jingle.sdp.util.js b/libs/strophe/strophe.jingle.sdp.util.js index df7fed6e2..e76ddb1ed 100644 --- a/libs/strophe/strophe.jingle.sdp.util.js +++ b/libs/strophe/strophe.jingle.sdp.util.js @@ -283,7 +283,9 @@ SDPUtil = { candidateToJingle: function (line) { // a=candidate:2979166662 1 udp 2113937151 192.168.2.100 57698 typ host generation 0 // - if (line.substring(0, 12) != 'a=candidate:') { + if (line.indexOf('candidate:') == 0) { + line = 'a=' + line; + } else if (line.substring(0, 12) != 'a=candidate:') { console.log('parseCandidate called with a line that is not a candidate line'); console.log(line); return null; From 662287bf4ac7f2de04f9884f5f808397db1bfdd7 Mon Sep 17 00:00:00 2001 From: yanas Date: Tue, 1 Jul 2014 13:31:13 +0300 Subject: [PATCH 008/107] Active speaker user interface improvements and fixes. --- app.js | 48 +++++------ css/videolayout_default.css | 3 +- videolayout.js | 167 +++++++++++++++++++++++------------- 3 files changed, 134 insertions(+), 84 deletions(-) diff --git a/app.js b/app.js index e671642fc..c2e5f8b41 100644 --- a/app.js +++ b/app.js @@ -309,10 +309,14 @@ $(document).bind('remotestreamadded.jingle', function (event, data, sid) { VideoLayout.checkChangeLargeVideo(vid.src); }; - // Add click handler - sel.click(function () { - VideoLayout.handleVideoThumbClicked(vid.src); - }); + // Add click handler. + container.onclick = function (event) { + VideoLayout.handleVideoThumbClicked( + $('#' + container.id + '>video').get(0).src); + event.preventDefault(); + return false; + }; + // Add hover handler $(container).hover( function() { @@ -327,10 +331,8 @@ $(document).bind('remotestreamadded.jingle', function (event, data, sid) { // If the video has been "pinned" by the user we want to keep the // display name on place. - if (focusedVideoSrc && focusedVideoSrc !== videoSrc - || (!focusedVideoSrc - && container.id - !== VideoLayout.getActiveSpeakerContainerId())) + if (!VideoLayout.isLargeVideoVisible() + || videoSrc !== $('#largeVideo').attr('src')) VideoLayout.showDisplayName(container.id, false); } ); @@ -445,10 +447,8 @@ function statsUpdated(statsCollector) var peerStats = statsCollector.jid2stats[jid]; Object.keys(peerStats.ssrc2AudioLevel).forEach(function (ssrc) { - console.info(jid + " audio level: " + - peerStats.ssrc2AudioLevel[ssrc] + " of ssrc: " + ssrc); -// VideoLayout.updateAudioLevel( Strophe.getResourceFromJid(jid), -// peerStats.ssrc2AudioLevel[ssrc]); +// console.info(jid + " audio level: " + +// peerStats.ssrc2AudioLevel[ssrc] + " of ssrc: " + ssrc); }); }); } @@ -460,7 +460,7 @@ function statsUpdated(statsCollector) */ function localStatsUpdated(statsCollector) { - console.info("Local audio level: " + statsCollector.audioLevel); +// console.info("Local audio level: " + statsCollector.audioLevel); } /** @@ -545,7 +545,9 @@ $(document).bind('callactive.jingle', function (event, videoelem, sid) { videoelem.show(); VideoLayout.resizeThumbnails(); - if (!focusedVideoSrc) + // Update the large video to the last added video only if there's no + // current active or focused speaker. + if (!focusedVideoSrc && !VideoLayout.getActiveSpeakerResourceJid()) VideoLayout.updateLargeVideo(videoelem.attr('src'), 1); VideoLayout.showFocusIndicator(); @@ -740,16 +742,14 @@ $(document).bind('presence.muc', function (event, jid, info, pres) { } }); - if (info.displayName) { - if (jid === connection.emuc.myroomjid) { - VideoLayout.setDisplayName('localVideoContainer', - info.displayName + ' (me)'); - } else { - VideoLayout.ensurePeerContainerExists(jid); - VideoLayout.setDisplayName( - 'participant_' + Strophe.getResourceFromJid(jid), - info.displayName); - } + if (jid === connection.emuc.myroomjid) { + VideoLayout.setDisplayName('localVideoContainer', + info.displayName); + } else { + VideoLayout.ensurePeerContainerExists(jid); + VideoLayout.setDisplayName( + 'participant_' + Strophe.getResourceFromJid(jid), + info.displayName); } }); diff --git a/css/videolayout_default.css b/css/videolayout_default.css index 5c04ef009..e37cf6c62 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -95,8 +95,7 @@ } .activespeaker { - -webkit-filter: grayscale(1); - filter: grayscale(1); + background: #000 !important; } #etherpad, diff --git a/videolayout.js b/videolayout.js index fc6036682..0f81aa255 100644 --- a/videolayout.js +++ b/videolayout.js @@ -27,17 +27,23 @@ var VideoLayout = (function (my) { localVideoContainer.appendChild(localVideo); var localVideoSelector = $('#' + localVideo.id); - // Add click handler + // Add click handler to both video and video wrapper elements in case + // there's no video. localVideoSelector.click(function () { VideoLayout.handleVideoThumbClicked(localVideo.src); }); + $('#localVideoContainer').click(function () { + VideoLayout.handleVideoThumbClicked(localVideo.src); + }); + // Add hover handler $('#localVideoContainer').hover( function() { VideoLayout.showDisplayName('localVideoContainer', true); }, function() { - if (focusedVideoSrc !== localVideo.src) + if (!VideoLayout.isLargeVideoVisible() + || localVideo.src !== $('#largeVideo').attr('src')) VideoLayout.showDisplayName('localVideoContainer', false); } ); @@ -103,6 +109,8 @@ var VideoLayout = (function (my) { var isVisible = $('#largeVideo').is(':visible'); $('#largeVideo').fadeOut(300, function () { + var oldSrc = $(this).attr('src'); + $(this).attr('src', newSrc); // Screen stream is already rotated @@ -129,8 +137,25 @@ var VideoLayout = (function (my) { ? getDesktopVideoPosition : getCameraVideoPosition; - if (isVisible) + if (isVisible) { + // Only if the large video is currently visible. + // Disable previous active speaker video. + var oldJid = getJidFromVideoSrc(oldSrc); + if (oldJid) { + var oldResourceJid = Strophe.getResourceFromJid(oldJid); + VideoLayout.enableActiveSpeaker(oldResourceJid, false); + } + + // Enable new active speaker in the remote videos section. + var userJid = getJidFromVideoSrc(newSrc); + if (userJid) + { + var resourceJid = Strophe.getResourceFromJid(userJid); + VideoLayout.enableActiveSpeaker(resourceJid, true); + } + $(this).fadeIn(300); + } }); } }; @@ -142,8 +167,6 @@ var VideoLayout = (function (my) { if (oldContainer) { oldContainer.removeClass("videoContainerFocused"); - VideoLayout.enableActiveSpeaker( - Strophe.getResourceFromJid(focusJid), false); } // Unlock current focused. @@ -152,30 +175,29 @@ var VideoLayout = (function (my) { focusedVideoSrc = null; // Enable the currently set active speaker. if (currentActiveSpeaker) { - VideoLayout.enableActiveSpeaker(currentActiveSpeaker, true); + var activeSpeakerVideo = document + .getElementById('participant_' + currentActiveSpeaker); + + if (activeSpeakerVideo) + VideoLayout.updateLargeVideo(activeSpeakerVideo.src, 1); } return; } - // Remove style for current active speaker if we're going to lock - // another video. - else if (currentActiveSpeaker) { - VideoLayout.enableActiveSpeaker(currentActiveSpeaker, false); - } // Lock new video focusedVideoSrc = videoSrc; + // Update focused/pinned interface. var userJid = getJidFromVideoSrc(videoSrc); if (userJid) { var container = getParticipantContainer(userJid); container.addClass("videoContainerFocused"); - - var resourceJid = Strophe.getResourceFromJid(userJid); - VideoLayout.enableActiveSpeaker(resourceJid, true); } + // Triggers a "video.selected" event. The "false" parameter indicates + // this isn't a prezi. $(document).trigger("video.selected", [false]); VideoLayout.updateLargeVideo(videoSrc, 1); @@ -224,16 +246,29 @@ var VideoLayout = (function (my) { * Shows/hides the large video. */ my.setLargeVideoVisible = function(isVisible) { + var largeVideoJid = getJidFromVideoSrc($('#largeVideo').attr('src')); + var resourceJid = Strophe.getResourceFromJid(largeVideoJid); + if (isVisible) { $('#largeVideo').css({visibility: 'visible'}); $('.watermark').css({visibility: 'visible'}); + VideoLayout.enableActiveSpeaker(resourceJid, true); } else { $('#largeVideo').css({visibility: 'hidden'}); $('.watermark').css({visibility: 'hidden'}); + VideoLayout.enableActiveSpeaker(resourceJid, false); } }; + /** + * Indicates if the large video is currently visible. + * + * @return true if visible, false - otherwise + */ + my.isLargeVideoVisible = function() { + return $('#largeVideo').is(':visible'); + }; /** * Checks if container for participant identified by given peerJid exists @@ -284,6 +319,8 @@ var VideoLayout = (function (my) { */ my.setDisplayName = function(videoSpanId, displayName) { var nameSpan = $('#' + videoSpanId + '>span.displayname'); + var defaultLocalDisplayName = "Me"; + var defaultRemoteDisplayName = "Speaker"; // If we already have a display name for this video. if (nameSpan.length > 0) { @@ -291,21 +328,33 @@ var VideoLayout = (function (my) { if (nameSpanElement.id === 'localDisplayName' && $('#localDisplayName').text() !== displayName) { - $('#localDisplayName').text(displayName); + if (displayName) + $('#localDisplayName').text(displayName + ' (me)'); + else + $('#localDisplayName').text(defaultLocalDisplayName); } else { - $('#' + videoSpanId + '_name').text(displayName); + if (displayName) + $('#' + videoSpanId + '_name').text(displayName); + else + $('#' + videoSpanId + '_name').text(defaultRemoteDisplayName); } } else { var editButton = null; + nameSpan = document.createElement('span'); + nameSpan.className = 'displayname'; + $('#' + videoSpanId)[0].appendChild(nameSpan); + if (videoSpanId === 'localVideoContainer') { editButton = createEditDisplayNameButton(); + nameSpan.innerText = defaultLocalDisplayName; } - if (displayName.length) { - nameSpan = document.createElement('span'); - nameSpan.className = 'displayname'; + else { + nameSpan.innerText = defaultRemoteDisplayName; + } + + if (displayName && displayName.length) { nameSpan.innerText = displayName; - $('#' + videoSpanId)[0].appendChild(nameSpan); } if (!editButton) { @@ -327,7 +376,9 @@ var VideoLayout = (function (my) { editableText.setAttribute('placeholder', 'ex. Jane Pink'); $('#' + videoSpanId)[0].appendChild(editableText); - $('#localVideoContainer .displayname').bind("click", function (e) { + $('#localVideoContainer .displayname') + .bind("click", function (e) { + e.preventDefault(); $('#localDisplayName').hide(); $('#editDisplayName').show(); @@ -345,16 +396,15 @@ var VideoLayout = (function (my) { } if (!$('#localDisplayName').is(":visible")) { - if (nickname) { + if (nickname) $('#localDisplayName').text(nickname + " (me)"); - $('#localDisplayName').show(); - } - else { - $('#localDisplayName').text(nickname); - } - - $('#editDisplayName').hide(); + else + $('#localDisplayName') + .text(defaultLocalDisplayName); + $('#localDisplayName').show(); } + + $('#editDisplayName').hide(); }; $('#editDisplayName').one("focusout", function (e) { @@ -378,8 +428,11 @@ var VideoLayout = (function (my) { * @param isShow indicates if the display name should be shown or hidden */ my.showDisplayName = function(videoSpanId, isShow) { - var nameSpan = $('#' + videoSpanId + '>span.displayname').get(0); - + // FIX: need to use noConflict of jquery, because apparently we're + // using another library that uses $, which conflics with jquery and + // sometimes objects are null because of that!!!!!!!!! + // http://api.jquery.com/jQuery.noConflict/ + var nameSpan = jQuery('#' + videoSpanId + '>span.displayname').get(0); if (isShow) { if (nameSpan && nameSpan.innerHTML && nameSpan.innerHTML.length) nameSpan.setAttribute("style", "display:inline-block;"); @@ -540,16 +593,24 @@ var VideoLayout = (function (my) { if (nameSpan.length > 0) displayName = nameSpan.text(); - console.log("Enable active speaker", displayName, isEnable); + console.log("UI enable active speaker", + displayName, + resourceJid, + isEnable); var videoSpanId = null; + var videoContainerId = null; if (resourceJid - === Strophe.getResourceFromJid(connection.emuc.myroomjid)) + === Strophe.getResourceFromJid(connection.emuc.myroomjid)) { videoSpanId = 'localVideoWrapper'; - else + videoContainerId = 'localVideoContainer'; + } + else { videoSpanId = 'participant_' + resourceJid; + videoContainerId = videoSpanId; + } - videoSpan = document.getElementById(videoSpanId); + videoSpan = document.getElementById(videoContainerId); if (!videoSpan) { console.error("No video element for jid", resourceJid); @@ -559,18 +620,21 @@ var VideoLayout = (function (my) { var video = $('#' + videoSpanId + '>video'); if (video && video.length > 0) { - var videoElement = video.get(0); if (isEnable) { - if (!videoElement.classList.contains("activespeaker")) - videoElement.classList.add("activespeaker"); + VideoLayout.showDisplayName(videoContainerId, true); - VideoLayout.showDisplayName(videoSpanId, true); + if (!videoSpan.classList.contains("activespeaker")) + videoSpan.classList.add("activespeaker"); + + video.css({visibility: 'hidden'}); } else { - VideoLayout.showDisplayName(videoSpanId, false); + VideoLayout.showDisplayName(videoContainerId, false); - if (videoElement.classList.contains("activespeaker")) - videoElement.classList.remove("activespeaker"); + if (videoSpan.classList.contains("activespeaker")) + videoSpan.classList.remove("activespeaker"); + + video.css({visibility: 'visible'}); } } }; @@ -733,10 +797,10 @@ var VideoLayout = (function (my) { }; /** - * Returns the current active speaker. + * Returns the current active speaker resource jid. */ - my.getActiveSpeakerContainerId = function () { - return 'participant_' + currentActiveSpeaker; + my.getActiveSpeakerResourceJid = function () { + return currentActiveSpeaker; }; /** @@ -862,18 +926,6 @@ var VideoLayout = (function (my) { === Strophe.getResourceFromJid(connection.emuc.myroomjid)) return; - // Disable style for previous active speaker. - if (currentActiveSpeaker - && currentActiveSpeaker !== resourceJid - && !focusedVideoSrc) { - var oldContainer = document.getElementById( - 'participant_' + currentActiveSpeaker); - - if (oldContainer) { - VideoLayout.enableActiveSpeaker(currentActiveSpeaker, false); - } - } - // Obtain container for new active speaker. var container = document.getElementById( 'participant_' + resourceJid); @@ -892,7 +944,6 @@ var VideoLayout = (function (my) { if (video.length) { VideoLayout.updateLargeVideo(video[0].src); - VideoLayout.enableActiveSpeaker(resourceJid, true); } } }); From 8aa807b77d2f2e666723205b6f5a15d20026c592 Mon Sep 17 00:00:00 2001 From: turint Date: Tue, 1 Jul 2014 16:18:30 +0300 Subject: [PATCH 009/107] debian folder initial version --- debian/README.Debian | 8 ++ debian/README.source | 6 ++ debian/changelog | 6 ++ debian/compat | 1 + debian/conffiles | 2 + debian/control | 18 +++++ debian/copyright | 31 ++++++++ debian/docs | 1 + debian/install | 2 + debian/postinst | 70 ++++++++++++++++++ debian/postrm | 50 +++++++++++++ debian/preinst | 35 +++++++++ debian/prerm | 36 +++++++++ debian/rules | 13 ++++ debian/source/format | 1 + debian/source/include-binaries | 17 +++++ debian/usr/share/doc/jitsi-meet/README | 13 ++++ .../share/doc/jitsi-meet/changelog.Debian.gz | Bin 0 -> 169 bytes debian/usr/share/doc/jitsi-meet/copyright | 31 ++++++++ .../share/doc/jitsi-meet/jitsi-meet.example | 36 +++++++++ 20 files changed, 377 insertions(+) create mode 100644 debian/README.Debian create mode 100644 debian/README.source create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/conffiles create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/docs create mode 100644 debian/install create mode 100644 debian/postinst create mode 100644 debian/postrm create mode 100644 debian/preinst create mode 100644 debian/prerm create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 debian/source/include-binaries create mode 100644 debian/usr/share/doc/jitsi-meet/README create mode 100644 debian/usr/share/doc/jitsi-meet/changelog.Debian.gz create mode 100644 debian/usr/share/doc/jitsi-meet/copyright create mode 100644 debian/usr/share/doc/jitsi-meet/jitsi-meet.example diff --git a/debian/README.Debian b/debian/README.Debian new file mode 100644 index 000000000..e79c133d2 --- /dev/null +++ b/debian/README.Debian @@ -0,0 +1,8 @@ +Jitsi Meet for Debian +---------------------------- + +This is a WebRTC frontend of the video conferencing tool Jitsi Meet. It depends on the +jitsi-videobridge package, which is a SFU (Selective Forwarding Unit) and both packages +are designed to work together. + + -- Yasen Pramatarov Mon, 30 Jun 2014 23:05:18 +0100 diff --git a/debian/README.source b/debian/README.source new file mode 100644 index 000000000..45d7a1eb7 --- /dev/null +++ b/debian/README.source @@ -0,0 +1,6 @@ +jitsi-meet for Debian +--------------------- + +The jitsi-meet package is built from the sources of Jitsi Meet. + +Jitsi Meet is downloaded from https://github.com/jitsi/jitsi-meet and the git files are removed. you can recreate the source with 'git clone https://github.com/jitsi/jitsi-meet.git'. diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 000000000..256b1fe7e --- /dev/null +++ b/debian/changelog @@ -0,0 +1,6 @@ +jitsi-meet (1.0.1-1) unstable; urgency=low + + * Initial release + * Jitsi Meet github snapshot from 2014-07-01 + + -- Yasen Pramatarov Tue, 01 Jul 2014 16:31:41 +0300 diff --git a/debian/compat b/debian/compat new file mode 100644 index 000000000..45a4fb75d --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +8 diff --git a/debian/conffiles b/debian/conffiles new file mode 100644 index 000000000..61db7897c --- /dev/null +++ b/debian/conffiles @@ -0,0 +1,2 @@ +/etc/apt/sources.list.d/prosody-trunk.list +/etc/apt/sources.list.d/jitsi.list diff --git a/debian/control b/debian/control new file mode 100644 index 000000000..13828633c --- /dev/null +++ b/debian/control @@ -0,0 +1,18 @@ +Source: jitsi-meet +Section: net +Priority: extra +Maintainer: Yasen Pramatarov +Build-Depends: debhelper (>= 8.0.0) +Standards-Version: 3.9.3 +Homepage: https://jitsi.org/Projects/JitsiMeet + +Package: jitsi-meet +Architecture: any +Pre-Depends: adduser, openssl, jitsi-videobridge +Depends: ${shlibs:Depends}, ${misc:Depends}, nginx +Description: WebRTC JavaScript video conferences + Jitsi Meet is a WebRTC JavaScript application that uses Jitsi + Videobridge to provide high quality, scalable video conferences. + . + It is a web interface to Jitsi Videobridge for audio and video + forwarding and relaying, configured to work with nginx diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 000000000..06376c2a9 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,31 @@ +Format: http://dep.debian.net/deps/dep5 +Upstream-Name: Jitsi Meet +Upstream-Contact: Emil Ivov +Source: https://github.com/jitsi/jitsi-meet + +Files: * +Copyright: 2013-2014 Jitsi +License: MIT + +License: MIT + The MIT License (MIT) + . + Copyright (c) 2013 ESTOS GmbH + Copyright (c) 2013 BlueJimp SARL + . + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/debian/docs b/debian/docs new file mode 100644 index 000000000..b43bf86b5 --- /dev/null +++ b/debian/docs @@ -0,0 +1 @@ +README.md diff --git a/debian/install b/debian/install new file mode 100644 index 000000000..f57f50fdd --- /dev/null +++ b/debian/install @@ -0,0 +1,2 @@ +debian/etc/* /etc/ +debian/usr/share/* /usr/share/ diff --git a/debian/postinst b/debian/postinst new file mode 100644 index 000000000..978118b9a --- /dev/null +++ b/debian/postinst @@ -0,0 +1,70 @@ +#!/bin/sh +# postinst script for jitsi-meet +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `configure' +# * `abort-upgrade' +# * `abort-remove' `in-favour' +# +# * `abort-remove' +# * `abort-deconfigure' `in-favour' +# `removing' +# +# for details, see http://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + configure) + + # firewall conf + ufw allow 80 + + # nginx conf + . /etc/default/jitsi-videobridge + cp /usr/share/doc/jitsi-meet/jitsi-meet.example /etc/nginx/sites-available/$JVB_HOSTNAME.conf + if [ ! -f /etc/nginx/sites-enabled/$JVB_HOSTNAME.conf ]; then + ln -s /etc/nginx/sites-available/$JVB_HOSTNAME.conf /etc/nginx/sites-enabled/$JVB_HOSTNAME.conf + fi + sed -i "s/jitsi-meet.example.com/$JVB_HOSTNAME/g" /etc/nginx/sites-available/$JVB_HOSTNAME.conf + # FIXME do we need the default? + if [ ! -f /etc/nginx/sites-enabled/default ]; then + ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default + fi + if grep "# server_names_hash_bucket_size 64" /etc/nginx/nginx.conf > /dev/null; then + sed -i "s/#\ server_names_hash_bucket_size\ 64/\ server_names_hash_bucket_size\ 64/" /etc/nginx/nginx.conf + fi + + # jitsi meet + chown -R www-data:www-data /srv/web/jitsi-meet/ + sed -i "s/jitsi-meet.example.com/$JVB_HOSTNAME/g" /srv/web/jitsi-meet/config.js + # enable turn + if grep "// useStunTurn: true," /srv/web/jitsi-meet/config.js > /dev/null; then + sed -i "s/\/\/\ \ useStunTurn:\ true,/\ \ \ \ useStunTurn:\ true,/" /srv/web/jitsi-meet/config.js + fi + invoke-rc.d nginx restart + + # and we're done + db_stop + + ;; + + abort-upgrade|abort-remove|abort-deconfigure) + ;; + + *) + echo "postinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/postrm b/debian/postrm new file mode 100644 index 000000000..5929810c7 --- /dev/null +++ b/debian/postrm @@ -0,0 +1,50 @@ +#!/bin/sh +# postrm script for jitsi-meet +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `remove' +# * `purge' +# * `upgrade' +# * `failed-upgrade' +# * `abort-install' +# * `abort-install' +# * `abort-upgrade' +# * `disappear' +# +# for details, see http://www.debian.org/doc/debian-policy/ or +# the debian-policy package + +# Load debconf +. /usr/share/debconf/confmodule + + +case "$1" in + purge|remove) + rm /etc/nginx/sites-enabled/jitsi-meet + rm /etc/nginx/sites-available/jitsi-meet + if [ -x "/etc/init.d/nginx" ]; then + invoke-rc.d nginx reload + fi + ;; + + upgrade|failed-upgrade|abort-install|abort-upgrade|disappear) + ;; + + *) + echo "postrm called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +db_stop + +exit 0 diff --git a/debian/preinst b/debian/preinst new file mode 100644 index 000000000..794394d6d --- /dev/null +++ b/debian/preinst @@ -0,0 +1,35 @@ +#!/bin/sh +# preinst script for jitsi-meet +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `install' +# * `install' +# * `upgrade' +# * `abort-upgrade' +# for details, see http://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + install|upgrade) + ;; + + abort-upgrade) + ;; + + *) + echo "preinst called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +# dh_installdeb will replace this with shell code automatically +# generated by other debhelper scripts. + +#DEBHELPER# + +exit 0 diff --git a/debian/prerm b/debian/prerm new file mode 100644 index 000000000..10f8454d5 --- /dev/null +++ b/debian/prerm @@ -0,0 +1,36 @@ +#!/bin/sh +# prerm script for jitsi-meet +# +# see: dh_installdeb(1) + +set -e + +# summary of how this script can be called: +# * `remove' +# * `upgrade' +# * `failed-upgrade' +# * `remove' `in-favour' +# * `deconfigure' `in-favour' +# `removing' +# +# for details, see http://www.debian.org/doc/debian-policy/ or +# the debian-policy package + + +case "$1" in + remove|purge) + ;; + + upgrade|deconfigure) + ;; + + failed-upgrade) + ;; + + *) + echo "prerm called with unknown argument \`$1'" >&2 + exit 1 + ;; +esac + +exit 0 diff --git a/debian/rules b/debian/rules new file mode 100755 index 000000000..b760bee7f --- /dev/null +++ b/debian/rules @@ -0,0 +1,13 @@ +#!/usr/bin/make -f +# -*- makefile -*- +# Sample debian/rules that uses debhelper. +# This file was originally written by Joey Hess and Craig Small. +# As a special exception, when this file is copied by dh-make into a +# dh-make output file, you may use that output file without restriction. +# This special exception was added by Craig Small in version 0.37 of dh-make. + +# Uncomment this to turn on verbose mode. +#export DH_VERBOSE=1 + +%: + dh $@ diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 000000000..163aaf8d8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) diff --git a/debian/source/include-binaries b/debian/source/include-binaries new file mode 100644 index 000000000..1acd4d013 --- /dev/null +++ b/debian/source/include-binaries @@ -0,0 +1,17 @@ +debian/usr/share/jitsi-meet/favicon.ico +debian/usr/share/jitsi-meet/fonts/jitsi.eot +debian/usr/share/jitsi-meet/fonts/jitsi.woff +debian/usr/share/jitsi-meet/fonts/jitsi.ttf +debian/usr/share/jitsi-meet/sounds/left.wav +debian/usr/share/jitsi-meet/sounds/incomingMessage.wav +debian/usr/share/jitsi-meet/sounds/joined.wav +debian/usr/share/jitsi-meet/images/estoslogo.png +debian/usr/share/jitsi-meet/images/chromelogo.png +debian/usr/share/jitsi-meet/images/jitsilogo.png +debian/usr/share/jitsi-meet/images/watermark.png +debian/usr/share/jitsi-meet/images/avatarprezi.png +debian/usr/share/jitsi-meet/images/chromepointer.png +debian/usr/share/jitsi-meet/images/avatar1.png +debian/usr/share/jitsi-meet/images/popupPointer.png +debian/usr/share/jitsi-meet/images/favicon.ico +debian/usr/share/doc/jitsi-meet/changelog.Debian.gz diff --git a/debian/usr/share/doc/jitsi-meet/README b/debian/usr/share/doc/jitsi-meet/README new file mode 100644 index 000000000..a198dcae3 --- /dev/null +++ b/debian/usr/share/doc/jitsi-meet/README @@ -0,0 +1,13 @@ +Jitsi Meet + +==== + +A WebRTC-powered multi-user videochat. For a live demo, check out either +https://meet.estos.de/ or https://meet.jit.si/. + +Built using colibri.js[0] and strophe.jingle[1], powered by the jitsi-videobridge[2] + + +[0] https://github.com/ESTOS/colibri.js +[1] https://github.com/ESTOS/strophe.jingle +[3] https://github.com/jitsi/jitsi-videobridge diff --git a/debian/usr/share/doc/jitsi-meet/changelog.Debian.gz b/debian/usr/share/doc/jitsi-meet/changelog.Debian.gz new file mode 100644 index 0000000000000000000000000000000000000000..91191c76627c86ddfbfcd05cce382b795af2e247 GIT binary patch literal 169 zcmV;a09OAWiwFqBkg`(%17m1mZf9j|Z)Yw6ND65hR{)5vv%e6I`2W{VF=j{b}c(*}KxMzoX Xh^lUA*T@6bB#DYY2wK*FmH+?%d8$ft literal 0 HcmV?d00001 diff --git a/debian/usr/share/doc/jitsi-meet/copyright b/debian/usr/share/doc/jitsi-meet/copyright new file mode 100644 index 000000000..06376c2a9 --- /dev/null +++ b/debian/usr/share/doc/jitsi-meet/copyright @@ -0,0 +1,31 @@ +Format: http://dep.debian.net/deps/dep5 +Upstream-Name: Jitsi Meet +Upstream-Contact: Emil Ivov +Source: https://github.com/jitsi/jitsi-meet + +Files: * +Copyright: 2013-2014 Jitsi +License: MIT + +License: MIT + The MIT License (MIT) + . + Copyright (c) 2013 ESTOS GmbH + Copyright (c) 2013 BlueJimp SARL + . + Permission is hereby granted, free of charge, to any person obtaining a copy of + this software and associated documentation files (the "Software"), to deal in + the Software without restriction, including without limitation the rights to + use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + the Software, and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + . + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + . + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/debian/usr/share/doc/jitsi-meet/jitsi-meet.example b/debian/usr/share/doc/jitsi-meet/jitsi-meet.example new file mode 100644 index 000000000..b2773ad8a --- /dev/null +++ b/debian/usr/share/doc/jitsi-meet/jitsi-meet.example @@ -0,0 +1,36 @@ +server { + listen 80; + server_name jitsi-meet.example.com; + return 301 https://$host$request_uri; +} +server { + listen 443 ssl; + server_name jitsi-meet.example.com; + + ssl_certificate /var/lib/prosody/jitsi-meet.example.com.crt; + ssl_certificate_key /var/lib/prosody/jitsi-meet.example.com.key; + + root /srv/web/jitsi-meet; + index index.html index.htm; + + location ~ ^/([a-zA-Z0-9]+)$ { + rewrite ^/(.*)$ / break; + } + + # BOSH + location /http-bind { + proxy_pass http://localhost:5280/http-bind; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header Host $http_host; + } + + # xmpp websockets + location /xmpp-websocket { + proxy_pass http://localhost:5280; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + tcp_nodelay on; + } +} From 186c1ff4844937aa989173fc05db25ad9f1cf8cd Mon Sep 17 00:00:00 2001 From: turint Date: Tue, 1 Jul 2014 16:21:07 +0300 Subject: [PATCH 010/107] removed apt sources files, they will be in separate package --- debian/install | 1 - 1 file changed, 1 deletion(-) diff --git a/debian/install b/debian/install index f57f50fdd..b7e674a4a 100644 --- a/debian/install +++ b/debian/install @@ -1,2 +1 @@ -debian/etc/* /etc/ debian/usr/share/* /usr/share/ From 450e2a05ff77da3aed42966bba3b2db6c84a2ca4 Mon Sep 17 00:00:00 2001 From: turint Date: Tue, 1 Jul 2014 16:22:52 +0300 Subject: [PATCH 011/107] removed apt sources files --- debian/conffiles | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 debian/conffiles diff --git a/debian/conffiles b/debian/conffiles deleted file mode 100644 index 61db7897c..000000000 --- a/debian/conffiles +++ /dev/null @@ -1,2 +0,0 @@ -/etc/apt/sources.list.d/prosody-trunk.list -/etc/apt/sources.list.d/jitsi.list From ddaf6caa51e3e086781ba3a46dacc728410e19ad Mon Sep 17 00:00:00 2001 From: turint Date: Tue, 1 Jul 2014 16:25:30 +0300 Subject: [PATCH 012/107] /srv/web/ references changed to /usr/share/ ones --- debian/postinst | 8 ++++---- debian/usr/share/doc/jitsi-meet/jitsi-meet.example | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/debian/postinst b/debian/postinst index 978118b9a..b391e20d1 100644 --- a/debian/postinst +++ b/debian/postinst @@ -40,11 +40,11 @@ case "$1" in fi # jitsi meet - chown -R www-data:www-data /srv/web/jitsi-meet/ - sed -i "s/jitsi-meet.example.com/$JVB_HOSTNAME/g" /srv/web/jitsi-meet/config.js + chown -R www-data:www-data /usr/share/jitsi-meet/ + sed -i "s/jitsi-meet.example.com/$JVB_HOSTNAME/g" /usr/share/jitsi-meet/config.js # enable turn - if grep "// useStunTurn: true," /srv/web/jitsi-meet/config.js > /dev/null; then - sed -i "s/\/\/\ \ useStunTurn:\ true,/\ \ \ \ useStunTurn:\ true,/" /srv/web/jitsi-meet/config.js + if grep "// useStunTurn: true," /usr/share/jitsi-meet/config.js > /dev/null; then + sed -i "s/\/\/\ \ useStunTurn:\ true,/\ \ \ \ useStunTurn:\ true,/" /usr/share/jitsi-meet/config.js fi invoke-rc.d nginx restart diff --git a/debian/usr/share/doc/jitsi-meet/jitsi-meet.example b/debian/usr/share/doc/jitsi-meet/jitsi-meet.example index b2773ad8a..dbba423aa 100644 --- a/debian/usr/share/doc/jitsi-meet/jitsi-meet.example +++ b/debian/usr/share/doc/jitsi-meet/jitsi-meet.example @@ -10,7 +10,7 @@ server { ssl_certificate /var/lib/prosody/jitsi-meet.example.com.crt; ssl_certificate_key /var/lib/prosody/jitsi-meet.example.com.key; - root /srv/web/jitsi-meet; + root /usr/share/jitsi-meet; index index.html index.htm; location ~ ^/([a-zA-Z0-9]+)$ { From 90afe1b4dc2bd3369907842bba73fc388b7638b2 Mon Sep 17 00:00:00 2001 From: turint Date: Tue, 1 Jul 2014 16:43:16 +0300 Subject: [PATCH 013/107] added uploaders and fixed homepage address --- debian/control | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 13828633c..4d25e24b1 100644 --- a/debian/control +++ b/debian/control @@ -1,10 +1,11 @@ Source: jitsi-meet Section: net Priority: extra -Maintainer: Yasen Pramatarov +Maintainer: Jitsi Team +Uploaders: Emil Ivov , Damian Minkov Build-Depends: debhelper (>= 8.0.0) Standards-Version: 3.9.3 -Homepage: https://jitsi.org/Projects/JitsiMeet +Homepage: https://jitsi.org/meet Package: jitsi-meet Architecture: any From c0dc84d8248a1a24ca5ae90ff882965cf2d698f6 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Tue, 1 Jul 2014 16:02:34 +0200 Subject: [PATCH 014/107] Adds a button for recording. --- app.js | 78 +++++++++++++++++++++++++++++++++- config.js | 5 ++- css/font.css | 13 +++++- fonts/jitsi.eot | Bin 5136 -> 7264 bytes fonts/jitsi.svg | 10 +++-- fonts/jitsi.ttf | Bin 4980 -> 7108 bytes fonts/jitsi.woff | Bin 3840 -> 5140 bytes index.html | 5 +++ libs/colibri/colibri.focus.js | 34 ++++++++++++++- 9 files changed, 137 insertions(+), 8 deletions(-) diff --git a/app.js b/app.js index c2e5f8b41..360a8f91c 100644 --- a/app.js +++ b/app.js @@ -6,6 +6,7 @@ var activecall = null; var RTC = null; var nickname = null; var sharedKey = ''; +var recordingToken =''; var roomUrl = null; var ssrc2jid = {}; /** @@ -613,6 +614,7 @@ $(document).bind('joined.muc', function (event, jid, info) { if (Object.keys(connection.emuc.members).length < 1) { focus = new ColibriFocus(connection, config.hosts.bridge); + showRecordingButton(false); } if (focus && config.etherpad_base) { @@ -633,6 +635,7 @@ $(document).bind('joined.muc', function (event, jid, info) { $(document).bind('entered.muc', function (event, jid, info, pres) { console.log('entered', jid, info); + console.log('is focus?' + focus ? 'true' : 'false'); // Add Peer's container @@ -643,6 +646,7 @@ $(document).bind('entered.muc', function (event, jid, info, pres) { if (focus.confid === null) { console.log('make new conference with', jid); focus.makeConference(Object.keys(connection.emuc.members)); + showRecordingButton(true); } else { console.log('invite', jid, 'into conference'); focus.addNewParticipant(jid); @@ -688,17 +692,20 @@ $(document).bind('left.muc', function (event, jid) { && !sessionTerminated) { console.log('welcome to our new focus... myself'); focus = new ColibriFocus(connection, config.hosts.bridge); + if (Object.keys(connection.emuc.members).length > 0) { focus.makeConference(Object.keys(connection.emuc.members)); + showRecordingButton(true); } $(document).trigger('focusechanged.muc', [focus]); } else if (focus && Object.keys(connection.emuc.members).length === 0) { console.log('everyone left'); // FIXME: closing the connection is a hack to avoid some - // problemswith reinit + // problems with reinit disposeConference(); focus = new ColibriFocus(connection, config.hosts.bridge); + showRecordingButton(false); } if (connection.emuc.getPrezi(jid)) { $(document).trigger('presentationremoved.muc', @@ -867,6 +874,57 @@ function toggleAudio() { buttonClick("#mute", "icon-microphone icon-mic-disabled"); } +// Starts or stops the recording for the conference. +function toggleRecording() { + if (focus === null || focus.confid === null) { + console.log('non-focus, or conference not yet organized: not enabling recording'); + return; + } + + if (!recordingToken) + { + $.prompt('

Enter recording token

' + + '', + { + persistent: false, + buttons: { "Save": true, "Cancel": false}, + defaultButton: 1, + loaded: function (event) { + document.getElementById('recordingToken').focus(); + }, + submit: function (e, v, m, f) { + if (v) { + var token = document.getElementById('recordingToken'); + + if (token.value) { + setRecordingToken(Util.escapeHtml(token.value)); + toggleRecording(); + } + } + } + } + ); + + return; + } + + var oldState = focus.recordingEnabled; + buttonClick("#recordButton", "icon-recEnable icon-recDisable"); + focus.setRecording(!oldState, + recordingToken, + function (state) { + console.log("New recording state: ", state); + if (state == oldState) //failed to change, reset the token because it might have been wrong + { + buttonClick("#recordButton", "icon-recEnable icon-recDisable"); + setRecordingToken(null); + } + } + ); + + +} + /** * Returns an array of the video horizontal and vertical indents, * so that if fits its parent. @@ -1111,6 +1169,10 @@ function setSharedKey(sKey) { sharedKey = sKey; } +function setRecordingToken(token) { + recordingToken = token; +} + /** * Updates the room invite url. */ @@ -1171,6 +1233,20 @@ function setView(viewName) { // } } +function showRecordingButton(show) { + if (!config.enableRecording) { + return; + } + + if (show) { + $('#recording').css({display: "inline"}); + } + else { + $('#recording').css({display: "none"}); + } + +} + $(document).bind('fatalError.jingle', function (event, session, error) { diff --git a/config.js b/config.js index 22d93589a..966a1da9e 100644 --- a/config.js +++ b/config.js @@ -13,5 +13,6 @@ var config = { chromeExtensionId: 'diibjkoicjeejcmhdnailmkgecihlobk', // Id of desktop streamer Chrome extension minChromeExtVersion: '0.1', // Required version of Chrome extension enableRtpStats: false, // Enables RTP stats processing - openSctp: true //Toggle to enable/disable SCTP channels -}; \ No newline at end of file + openSctp: true, //Toggle to enable/disable SCTP channels + enableRecording: false +}; diff --git a/css/font.css b/css/font.css index b0caacf70..43bc19b47 100755 --- a/css/font.css +++ b/css/font.css @@ -23,7 +23,18 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } - +.icon-callRetro:before { + content: "\e611"; +} +.icon-callModern:before { + content: "\e612"; +} +.icon-recDisable:before { + content: "\e613"; +} +.icon-recEnable:before { + content: "\e614"; +} .icon-kick1:before { content: "\e60f"; } diff --git a/fonts/jitsi.eot b/fonts/jitsi.eot index 0679560af40f425cce4a516651b04c734343c232..41fe9eeea3aed231312d4312f4b328100fa87392 100755 GIT binary patch delta 2450 zcmZ8iO^h5z6|Ps+Rn`A9)6+fwGxqe%_Uy*au=CgLU9TsbEM7~_0V09~vOQ$w7_AhH zjmRMgN6i70D`s;71c5|~#05kSzHkUqE+7;XKsll{9DD*HBm##p%d76)#Y(BWt6tZ8 z-+NW{y;t8|J-i@ZXc6*W^S+3+?|k9Wzwd9~DgBlZ@+*8_xOsc;ZsYU6d+GkWt<)=A zy+#P!C8YYF{abr4!Z+Xj6Cw3|)Qk7=hxUE?3zXkLxxat=1;ZulW~UzD?>Iwd4S!?(+gcH zU3U8P^0D8clT9`m&*>~y26UPzkWJ`773ZoQ@WrW3kZGsv#A5n?7ch;JIh)Ptc(RUW zEpP$N6vT-#1|mNhC%k2s8-+r{w=7@vWgAT3hOKKl*MJJc65KEi?lc<>$7$flZ8Xws z*mSZPVHtvg#?ityI8bZ=n$W6G4xg-QQUjpe&;fr+U4pK29AOihMulxVx>E`2p5;oa z7aLwU9Zh@c6MBuJj@xB<^`L@Ewk@cpiOiIyTsIULg6f9CLpQlQq1<76Kj^PFTkHJ* z`&JYCU*pm&>#?Q2q1(Cu#-%1LLsDRj0%aOw_|nq>{$fC-VWAplAkat_3u!^e1yZUB zZux#$2hLGK0L{?R0QGFg7EZlhvxBqkcG)hMTa6 z8@S=NK7u0ZE~4hC7&VBlP{75 zxk*TZM}7_zH%rK?i=fz9f-aIVC@FGu74u3+q#o}E-C4Oen*_7dPxW+^s8KGar$2un zosxOUEIIOY0N+|Za%-Mtc{La6PCjy$e+P5<(MI0(FJOa>jeR9vxKKE;ho7E|mJ9g6 zEZ5xSYc7%lN#Mda+*;Y(xpHOs>PEWp<;?i~jZY2;;lwz4NXNJ%=g6z%N95<^15E27 z6%rG|uT(p%8jt7j6m(cpnbU#FxnxeGfvmW6Ae1EYsaYa1i$q0^#aPV5x`<>H4q+zZ zG>m(37>CIq5wVD6MO6HeOmZxAz=te}=Cm4P9wwM|Ev+dN2&JP>Y#pN{s-SW&$BnoHO zpuN7cQaaPAJ8uJUr|4LoB_YC`L26@KAd>-ks>I_~$@2-nsKuyYzBF|P%O3+*dJ4}j z2k5}7XN|@`*?va!$Fn+-7P$m}IDh za~p1>XacN#dNHjGqHbv-Ow5~)R(94qSS`$=aj=pt0VH+-R4_}k@{(o?KnZ1B*RG#C zWuTS8m4+#_l2NNQ?JeCYx<)^P)0oaCrfXw?80=n8+;+2NPhW?7X8~r)+;S}jUCcif zF=v`WhkgdEe1OU}LApnOrhP~sV%=XOx5#Vcb@ET7m&&QfE2~u5rIJhKmP#!-K`sQx zHCc{2tPL%O?7|w~!-_xHVwYeVO`};Dh0!?(db4VNBTVDzY>P%C^$uw`cmksETna%l z33|ctJF8-1W9?TL5AwSWz1bE-(}4o)h0zm`-8{Z%K^xXvG+6g5e$YYncpOjWcyEki zbaM$NY7KITp&-IycXjRZB3AwC?jnZhnNhnvdM1i?M(wmc+KIk5vOPY5hfSUzV6PPi0bs!uU25+NREi3OBw<`5Y zY@jzq(<^U2t&SRyz3R->uiSI=^qKCNl{0Ui7p9>zmMS!LSLn#Kf|t=ek2c#5T;y|^ zsEid}E-1j7j|;$COkj5viKAFW$k$frXTMa~`WLZiCjb7A+m;&SJyPxg{1ru=zugM6KXJ9Z|Vb~YXZ}XLbn+eQ6Kl^wQhykJh-)EXL uSx-t*ayA3Q|Nl&L7@)eqN - + - - + + + + + + \ No newline at end of file diff --git a/fonts/jitsi.ttf b/fonts/jitsi.ttf index dfe9bade010eece4967a1d7796e494450a04a84a..654dddab54da071b0c56a7081dc3f98e818b12d9 100755 GIT binary patch delta 2477 zcmZ8iO^h5z6|Ps+_0zxe*FAqbGxqe%_IP7wJip!E^?KrDvunvg3Q@uV#a>uBPF6}x zjFLkl5_Jwpxo{!Q2_y(45)v1Xaxe!DAmsu=K>?H_TEihH5JC`fFtfbs-d(Ibb$8XP zs`tHD)!%#dujr#BB|r$V$yK~#0+)FRtSl^p|dm|#Gc?Jh&xAtDUO(JrL`Zd%~e`D{~&ELG(`U~oBpp(Jv zmtT1m14UW+;Jd%L0{%O&1^$PR!`})i4j&%SyKq2!@)@~L$g1zrUO1%l*@8|d9U80- z*#PwcHpfpd^r-aM$^H>|uS)xHUpaV6Wt98H^CpJO0I%UTev;Vt* zS)44`d_gDE5t_BY12oeR$BQu#`NcTl1E$m`?{?-U;#HBWhia{6{an?X_?$z zX*ONAi65`oOtbEao6QK@6ch}O7LLh*q5v4es69M&18iF>*!~)~X4?;J^*!Y%0gOvS+NPwy7zN4<#_**l9UNjnrD@|b&Oo4% zEEdv&!VOYt2yXj+MFHoiA%I~jG{AL^>j<~es5`;wcDv$KN}hDf4(CqUm0k%4+U?VU zQ*SifY$e$z`%l zu97FoGvupem)syE!C!tBG&f7gtBau7S%NN-F(_$rbT#uzNTeR`2Hkn3H=hRclTYoW zm*`%urYD2nmu|_rWR+Zb(t)>@pLzANZI|mFG~B%BF8>bJ^7Hjv_Aj8o`ue_BFI*@b z+ry8Kd&^z;#H!T2<=Y;T14-b)>t4NLZC$ytd~-cre?Bw*c>RkVLO3xG@6idK$XW6x z`2qPE`2@3hNQK0N@T;{BtHqNAJOUk-R2Ouhb1qrXXdtT|9SE(-d}^LZ%py^h6EP8U zF%prC!XeB>oQ82P4&yKxBqA2Etct2XmPw9<4)~BI(Sp`u%)fkff#S!j>8R!gTk4fiJi+%39x*_IGt&LFihEs)88Jk{!vRP%hoFRo%#Fkf1V z!SaW|^*W8`R04G1b;GC}U)wo@%cPmgjVH#?1K7-Qq*bxi5oF#N%ut{ZQu{=tPdTUD zSYBp&KbT~=3g_0nX3+vzdwh3R9Yo#IR9KidpRaC>I#?~NqIqXE+X6^z0;pn^X5}T# zHh>!HxUU^Qcgn!1fG15$7$vh_UvV~-TlCC+24^tErk3Ymff(#uOT6|<%bC3kZ=430 zYjfMP8FVrKbi|w)8Xfu>u=b&EE+RCbYvd+*o4iZ@iS*Jr^Dv_0O6-WfY( zTj-yQq%^pRux4lUC#bDs(tB{9H*2z67H?-TtAL(9%GbGU{I?%Ivt#jb{Du+und72gI5Q_abfZ{ zdNs20j&bW!uhk}c(=@&E{-gR=1F~2Db3K%MjvhVLJ+*r3{d2-H6=SJJ(|3hJt`*7| z>rpD5toyhDyu}1ISCKfHRfK$Vm45kah0T8xn`Zj&|9IKfoxDZL z-G#rRsz^Rf`q~f<4i5;Scku=|#P1Q5Hp`@O=R2imih7z}$9IF6^djlrIVhd0o1ytr a>r?xFX44@5)wc)=y|Y)Te0aXR4gUk2CMT!> delta 331 zcmX?N{za{xfsuiMft#U$ftkU;KUm+0L+Q^kpvWE|PDsv8ERYI4lg_}vCTd0rEp~@{=tS<$F{bfC3MI0y?>g6$K1(4BHqOgm{2_g}lVv)RWmVkAQp~ zpe5=B`NbtbhXR3c$iyA)i~^Gwof!o-yD+}xj|TYz2#i)3_Qmttd}ZKf0`t$$K3)W3 zKQfvL`y-f(IEUQhG+!-7%*V&np;!PMFjofoIT&nIdjgL zGc)JTwmlntKG$8mVny-VwZ)9(=CW~&+S5brxp`8~2F7wW9KCtr&*HM#a~YdhLQ|V9 z=6NGuSJqc-BV8ZKZ&^Hb-H_8>vE9;*kjx)(j-$Gwim^#!NG`P4=DB#yK}ymsA^ACr z$Hde69Q8YPk!~}|WY4NzoOhMotFPEa0VfTRBK;vxDPav2_0^;sV$4=x@mJ1iKW%H= zzJthhIq z%GPh(Vy|m*c_QVun&~^=D4a9Lg1HvVvtT}BX*QKjW>X^H*k?N;rFQ4*k8o{pd%p6P4UJY|IyxWLwM?lyq;0a?qcUV!PX0LsF zsPntg3`er{0&Y>ZxA4nDyny5(yoZE8*>I}-Q_oTRfVz)uMR@l2C*C zdeo3IOF;F>`aW>u41}{dc@poFKWY2wPujuF<8O+_VPLsQa8(!>nJkt90|P**fZzT# zzW-!=-`paWkb@{5WJunCn? zS6vS`ii~{N1=Mx5oYGF|U2**sN3sUkA?S)m1Xa^iO;uGW@Iwx0A?R9w?oYRv<7)R| zfvSa?E?XWP&3!;z(Bg7zUxarjghbpS@(nQrz*1p#>>LlRpJ2% z9S8;VkVWKS?IiFJ=?YZZ5$&2jQp>4Y_!2Qow(eWCYCxl#~@5Mi09&0WtO#=yWGc5i)%g@3TV z5d#B{4^T-02y-a?IhLH8SO64T1LT9meWXIqq^BnqgT#IS`5aIzkdd00!oa`_f9mQ(=6Vu1V*5LPwzWH^?aSOGMUcLPv>4U9zt81fQx zQ-NYT7#JAULHK00%%g(*;u4_qA545kR^*2VMNd}ZKf0@}d9aDMjjA_V>aKGU4ZdP0(t ivl$rv|7V)Rzz9}^AVeo82!#N>6~MqCy7`ol7b5_G^;t6j diff --git a/index.html b/index.html index a99546eb9..cb519c250 100644 --- a/index.html +++ b/index.html @@ -66,6 +66,11 @@
+
diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 8978b96d6..e0e6cf2c5 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -77,6 +77,8 @@ function ColibriFocus(connection, bridgejid) { // silly wait flag this.wait = true; + + this.recordingEnabled = false; } // creates a conferences with an initial set of peers @@ -164,6 +166,36 @@ ColibriFocus.prototype.makeConference = function (peers) { */ }; +// Sends a COLIBRI message which enables or disables (according to 'state') the +// recording on the bridge. +ColibriFocus.prototype.setRecording = function(state, token, callback) { + var self = this; + var elem = $iq({to: this.bridgejid, type: 'get'}); + elem.c('conference', { + xmlns: 'http://jitsi.org/protocol/colibri', + id: this.confid + }); + elem.c('recording', {state: state, token: token}); + elem.up(); + + this.connection.sendIQ(elem, + function (result) { + console.log('Set recording "', state, '". Result:', result); + var recordingElem = $(result).find('>conference>recording'); + var newState = recordingElem.attr('state'); + if (newState == null){ + newState = false; + } + + self.recordingEnabled = newState; + callback(newState); + }, + function (error) { + console.warn(error); + } + ); +}; + ColibriFocus.prototype._makeConference = function () { var self = this; var elem = $iq({to: this.bridgejid, type: 'get'}); @@ -1120,4 +1152,4 @@ ColibriFocus.prototype.sendTerminate = function (session, reason, text) { window.clearInterval(this.statsinterval); this.statsinterval = null; } -}; \ No newline at end of file +}; From cef26315c3d323262252c0d0752508098b065496 Mon Sep 17 00:00:00 2001 From: Yana Stamcheva Date: Thu, 3 Jul 2014 09:28:25 +0200 Subject: [PATCH 015/107] Fixes problem when unpinning user and returning to active speaker video. --- videolayout.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/videolayout.js b/videolayout.js index 0f81aa255..fb25816ba 100644 --- a/videolayout.js +++ b/videolayout.js @@ -169,14 +169,16 @@ var VideoLayout = (function (my) { oldContainer.removeClass("videoContainerFocused"); } - // Unlock current focused. + // Unlock current focused. if (focusedVideoSrc === videoSrc) { focusedVideoSrc = null; + var activeSpeakerVideo = null; // Enable the currently set active speaker. if (currentActiveSpeaker) { - var activeSpeakerVideo = document - .getElementById('participant_' + currentActiveSpeaker); + activeSpeakerVideo + = $('#participant_' + currentActiveSpeaker + '>video') + .get(0); if (activeSpeakerVideo) VideoLayout.updateLargeVideo(activeSpeakerVideo.src, 1); From a694d0fbdfa10fa95eb9d54dc390426cfc8a787a Mon Sep 17 00:00:00 2001 From: hristoterezov Date: Thu, 3 Jul 2014 14:30:07 +0200 Subject: [PATCH 016/107] Fixes issue with StatsCollector when the conference is recreated the peerconnection in the StatsCollector isn't updated. --- app.js | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app.js b/app.js index 360a8f91c..93ec61035 100644 --- a/app.js +++ b/app.js @@ -469,7 +469,8 @@ function localStatsUpdated(statsCollector) */ function startRtpStatsCollector() { - if (config.enableRtpStats && !statsCollector) + stopRTPStatsCollector(); + if (config.enableRtpStats) { statsCollector = new StatsCollector( getConferenceHandler().peerconnection, 200, statsUpdated); @@ -479,6 +480,19 @@ function startRtpStatsCollector() statsCollector.start(); } } + +/** + * Stops the {@link StatsCollector}. + */ +function stopRTPStatsCollector() +{ + if (statsCollector) + { + statsCollector.stop(); + statsCollector = null; + } +} + /** * Starts the {@link LocalStatsCollector} if the feature is enabled in config.js * @param stream the stream that will be used for collecting statistics. @@ -1082,11 +1096,7 @@ function disposeConference(onUnload) { } handler.peerconnection.close(); } - if (statsCollector) - { - statsCollector.stop(); - statsCollector = null; - } + stopRTPStatsCollector(); if(!onUnload) { startLocalRtpStatsCollector(connection.jingle.localAudio); } From b24898c79ded520313bcc5fb76d683f1da7eb863 Mon Sep 17 00:00:00 2001 From: turint Date: Fri, 4 Jul 2014 21:27:43 +0300 Subject: [PATCH 017/107] removed an unneeded and breaking the build debconf reference --- debian/postinst | 3 --- 1 file changed, 3 deletions(-) diff --git a/debian/postinst b/debian/postinst index b391e20d1..0bcb762b5 100644 --- a/debian/postinst +++ b/debian/postinst @@ -48,9 +48,6 @@ case "$1" in fi invoke-rc.d nginx restart - # and we're done - db_stop - ;; abort-upgrade|abort-remove|abort-deconfigure) From 0c495b3d2835aae0d091ed636da83d58390c5285 Mon Sep 17 00:00:00 2001 From: turint Date: Fri, 4 Jul 2014 22:25:44 +0300 Subject: [PATCH 018/107] jitsi-meet.example.com domain references in the example config --- config.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config.js b/config.js index 22d93589a..a39af7505 100644 --- a/config.js +++ b/config.js @@ -1,14 +1,14 @@ var config = { hosts: { - domain: 'guest.jit.si', - muc: 'meet.jit.si', // FIXME: use XEP-0030 - bridge: 'jitsi-videobridge.lambada.jitsi.net' // FIXME: use XEP-0030 + domain: 'jitsi-meet.example.com', + muc: 'conference.jitsi-meet.example.com', // FIXME: use XEP-0030 + bridge: 'jitsi-videobridge.jitsi-meet.example.com' // FIXME: use XEP-0030 }, // getroomnode: function (path) { return 'someprefixpossiblybasedonpath'; }, // useStunTurn: true, // use XEP-0215 to fetch STUN and TURN server // useIPv6: true, // ipv6 support. use at your own risk useNicks: false, - bosh: '//lambada.jitsi.net/http-bind', // FIXME: use xep-0156 for that + bosh: '//jitsi-meet.example.com/http-bind', // FIXME: use xep-0156 for that desktopSharing: 'ext', // Desktop sharing method. Can be set to 'ext', 'webrtc' or false to disable. chromeExtensionId: 'diibjkoicjeejcmhdnailmkgecihlobk', // Id of desktop streamer Chrome extension minChromeExtVersion: '0.1', // Required version of Chrome extension From fb331314882e59450c7f7d321eff7b17db40f9ef Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Sun, 6 Jul 2014 18:33:21 +0300 Subject: [PATCH 019/107] Add instructions for enabling recoridng. --- INSTALL.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/INSTALL.md b/INSTALL.md index 9bfa0cf63..525b53ba1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -230,3 +230,26 @@ org.jitsi.videobridge.NAT_HARVESTER_PUBLIC_ADDRESS= # Hold your first conference You are now all set and ready to have your first meet by going to http://jitsi.example.com + + +## Enabling recording +Currently recording is only supported for linux-64 and macos. To enable it, add +the following properties to sip-communicator.properties: +``` +org.jitsi.videobridge.ENABLE_MEDIA_RECORDING=true +org.jitsi.videobridge.MEDIA_RECORDING_PATH=/path/to/recordings/dir +org.jitsi.videobridge.MEDIA_RECORDING_TOKEN=secret +``` + +where /path/to/recordings/dir is the path to a pre-existing directory where recordings +will be stored (needs to be writeable by the user running jitsi-videobridge), +and "secret" is a string which will be used for authentication. + +Then, edit the Jitsi-Meet config.js file and set: +``` +enableRecoridng: true +``` + +Restart jitsi-videobridge and start a new conference (making sure that the page +is reloaded with the new config.js) -- the organizer of the conference should +now have a "recoriding" button in the floating menu, near the "mute" button. From de227480babd8c555981a848f36791761fdab30a Mon Sep 17 00:00:00 2001 From: bgrozev Date: Sun, 6 Jul 2014 18:40:17 +0300 Subject: [PATCH 020/107] Fix a typo (thanks, Fippo!) --- INSTALL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index 525b53ba1..99b3b91cf 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -247,7 +247,7 @@ and "secret" is a string which will be used for authentication. Then, edit the Jitsi-Meet config.js file and set: ``` -enableRecoridng: true +enableRecording: true ``` Restart jitsi-videobridge and start a new conference (making sure that the page From 36743da2905cdc8b7210d55f8edd43255b80f493 Mon Sep 17 00:00:00 2001 From: turint Date: Mon, 7 Jul 2014 12:50:29 +0300 Subject: [PATCH 021/107] fix for architecture independent builds (arch "all") --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 4d25e24b1..b11ee6ede 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Standards-Version: 3.9.3 Homepage: https://jitsi.org/meet Package: jitsi-meet -Architecture: any +Architecture: all Pre-Depends: adduser, openssl, jitsi-videobridge Depends: ${shlibs:Depends}, ${misc:Depends}, nginx Description: WebRTC JavaScript video conferences From 3958d8b347bf966100dadefe2bf8f15905c66110 Mon Sep 17 00:00:00 2001 From: turint Date: Mon, 7 Jul 2014 15:30:52 +0300 Subject: [PATCH 022/107] added rule for webroot populating through install & rules --- debian/install | 1 + debian/rules | 2 ++ 2 files changed, 3 insertions(+) diff --git a/debian/install b/debian/install index b7e674a4a..a9db9170c 100644 --- a/debian/install +++ b/debian/install @@ -1 +1,2 @@ +* /usr/share/jitsi-meet/ debian/usr/share/* /usr/share/ diff --git a/debian/rules b/debian/rules index b760bee7f..7bda609c0 100755 --- a/debian/rules +++ b/debian/rules @@ -10,4 +10,6 @@ #export DH_VERBOSE=1 %: + dh_install -Xdebian -Xdoc -XINSTALL.md -XLICENSE -XREADME.md usr/share/jitsi-meet/ + dh $@ From e3d4e108ed282e1e3db95b19c2a2c7e4d82705ae Mon Sep 17 00:00:00 2001 From: turint Date: Tue, 8 Jul 2014 16:45:56 +0300 Subject: [PATCH 023/107] jitsi-meet.example and README added --- debian/docs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/debian/docs b/debian/docs index b43bf86b5..380f29e42 100644 --- a/debian/docs +++ b/debian/docs @@ -1 +1,3 @@ README.md +debian/usr/share/doc/jitsi-meet/README +debian/usr/share/doc/jitsi-meet/jitsi-meet.example From 25031d5a91904bf9cdc695c9739ee11beade3149 Mon Sep 17 00:00:00 2001 From: turint Date: Tue, 8 Jul 2014 23:13:41 +0300 Subject: [PATCH 024/107] config is already deleted and second try breaks package uninstall --- debian/postrm | 2 -- 1 file changed, 2 deletions(-) diff --git a/debian/postrm b/debian/postrm index 5929810c7..89619fa24 100644 --- a/debian/postrm +++ b/debian/postrm @@ -24,8 +24,6 @@ set -e case "$1" in purge|remove) - rm /etc/nginx/sites-enabled/jitsi-meet - rm /etc/nginx/sites-available/jitsi-meet if [ -x "/etc/init.d/nginx" ]; then invoke-rc.d nginx reload fi From 4fbc37b3452b00c377738c0aca2bdbda4447459a Mon Sep 17 00:00:00 2001 From: yanas Date: Sun, 13 Jul 2014 19:30:14 +0200 Subject: [PATCH 025/107] Restores invite participants email functionality. Adds the password to the invitation email, for password protected conferences. --- toolbar.js | 81 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 66 insertions(+), 15 deletions(-) diff --git a/toolbar.js b/toolbar.js index 5d078c260..116b55adb 100644 --- a/toolbar.js +++ b/toolbar.js @@ -5,7 +5,7 @@ var Toolbar = (function (my) { /** * Opens the lock room dialog. */ - my.openLockDialog = function() { + my.openLockDialog = function () { // Only the focus is able to set a shared key. if (focus === null) { if (sharedKey) @@ -54,7 +54,7 @@ var Toolbar = (function (my) { submit: function (e, v, m, f) { if (v) { var lockKey = document.getElementById('lockKey'); - + if (lockKey.value) { setSharedKey(Util.escapeHtml(lockKey.value)); lockRoom(true); @@ -70,24 +70,75 @@ var Toolbar = (function (my) { /** * Opens the invite link dialog. */ - my.openLinkDialog = function() { + my.openLinkDialog = function () { + var inviteLink; + if (roomUrl == null) + inviteLink = "Your conference is currently being created..."; + else + inviteLink = encodeURI(roomUrl); + $.prompt('', - { - title: "Share this link with everyone you want to invite", - persistent: false, - buttons: { "Cancel": false}, - loaded: function (event) { - document.getElementById('inviteLinkRef').select(); + inviteLink + '" onclick="this.select();" readonly>', + { + title: "Share this link with everyone you want to invite", + persistent: false, + buttons: { "Invite": true, "Cancel": false}, + defaultButton: 1, + loaded: function (event) { + if (roomUrl) + document.getElementById('inviteLinkRef').select(); + else + document.getElementById('jqi_state0_buttonInvite') + .disabled = true; + }, + submit: function (e, v, m, f) { + if (v) { + if (roomUrl) { + inviteParticipants(); + } + } + } } - } - ); + ); }; + /** + * Invite participants to conference. + */ + function inviteParticipants() { + if (roomUrl == null) + return; + + var sharedKeyText = ""; + if (sharedKey && sharedKey.length > 0) + sharedKeyText + = "This conference is password protected. Please use the " + + "following pin when joining:%0D%0A%0D%0A" + + sharedKey + "%0D%0A%0D%0A"; + + var conferenceName = roomUrl.substring(roomUrl.lastIndexOf('/') + 1); + var subject = "Invitation to a Jitsi Meet (" + conferenceName + ")"; + var body = "Hey there, I%27d like to invite you to a Jitsi Meet" + + " conference I%27ve just set up.%0D%0A%0D%0A" + + "Please click on the following link in order" + + " to join the conference.%0D%0A%0D%0A" + + roomUrl + "%0D%0A%0D%0A" + + sharedKeyText + + "Note that Jitsi Meet is currently only supported by Chromim," + + " Google Chrome and Opera, so you need" + + " to be using one of these browsers.%0D%0A%0D%0A" + + "Talk to you in a sec!"; + + if (window.localStorage.displayname) + body += "%0D%0A%0D%0A" + window.localStorage.displayname; + + window.open("mailto:?subject=" + subject + "&body=" + body, '_blank'); + } + /** * Opens the settings dialog. */ - my.openSettingsDialog = function() { + my.openSettingsDialog = function () { $.prompt('

Configure your conference

' + ' Participants join muted
' + ' Require nicknames

' + @@ -104,13 +155,13 @@ var Toolbar = (function (my) { if ($('#initMuted').is(":checked")) { // it is checked } - + if ($('#requireNicknames').is(":checked")) { // it is checked } /* var lockKey = document.getElementById('lockKey'); - + if (lockKey.value) { setSharedKey(lockKey.value); From a89555b4b64bf35698b6265fa24ad95bf044cf4e Mon Sep 17 00:00:00 2001 From: yanas Date: Sun, 13 Jul 2014 20:12:38 +0200 Subject: [PATCH 026/107] Makes chat message notifications more visible. --- chat.js | 8 +++++--- css/main.css | 23 ++++++++++++++++------- toolbar.js | 7 +++++-- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/chat.js b/chat.js index b7255a346..d85bd7752 100644 --- a/chat.js +++ b/chat.js @@ -135,8 +135,7 @@ var Chat = (function (my) { { $("#subject").css({display: "block"}); } - } - + }; /** * Opens / closes the chat area. @@ -187,6 +186,9 @@ var Chat = (function (my) { duration: 500}); } else { + // Undock the toolbar when the chat is shown. + Toolbar.dockToolbar(false); + videospace.animate({right: chatSize[0], width: videospaceWidth, height: videospaceHeight}, @@ -290,7 +292,7 @@ var Chat = (function (my) { if (unreadMessages) { unreadMsgElement.innerHTML = unreadMessages.toString(); - Toolbar.showToolbar(); + Toolbar.dockToolbar(true); var chatButtonElement = document.getElementById('chatButton').parentNode; diff --git a/css/main.css b/css/main.css index eb9cc650d..e0a1726e9 100644 --- a/css/main.css +++ b/css/main.css @@ -131,15 +131,24 @@ html, body{ } #chatButton { - -webkit-transition: all .5s ease-in-out;; - -moz-transition: all .5s ease-in-out;; - transition: all .5s ease-in-out;; + -webkit-transition: all .5s ease-in-out; + -moz-transition: all .5s ease-in-out; + transition: all .5s ease-in-out; } - +/*#ffde00*/ #chatButton.active { - -webkit-text-shadow: 0 0 10px #ffffff; - -moz-text-shadow: 0 0 10px #ffffff; - text-shadow: 0 0 10px #ffffff; + -webkit-text-shadow: -1px 0 10px #00ccff, + 0 1px 10px #00ccff, + 1px 0 10px #00ccff, + 0 -1px 10px #00ccff; + -moz-text-shadow: 1px 0 10px #00ccff, + 0 1px 10px #00ccff, + 1px 0 10px #00ccff, + 0 -1px 10px #00ccff; + text-shadow: -1px 0 10px #00ccff, + 0 1px 10px #00ccff, + 1px 0 10px #00ccff, + 0 -1px 10px #00ccff; } a.button:hover { diff --git a/toolbar.js b/toolbar.js index 116b55adb..cdaaef239 100644 --- a/toolbar.js +++ b/toolbar.js @@ -236,9 +236,12 @@ var Toolbar = (function (my) { if (!$('#header').is(':visible')) { Toolbar.showToolbar(); } + // Then clear the time out, to dock the toolbar. - clearTimeout(toolbarTimeout); - toolbarTimeout = null; + if (toolbarTimeout) { + clearTimeout(toolbarTimeout); + toolbarTimeout = null; + } } else { if (!$('#header').is(':visible')) { From 5ac83ec088eeff4d38f11a0bb60d9c4d95d57d5e Mon Sep 17 00:00:00 2001 From: paweldomas Date: Mon, 14 Jul 2014 09:28:22 +0200 Subject: [PATCH 027/107] Fixes issues when given participant does not support all media types. --- libs/colibri/colibri.focus.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index e0e6cf2c5..6c53a29d2 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -540,7 +540,8 @@ ColibriFocus.prototype.initiate = function (peer, isInitiator) { if (1) { //i > 0) { // not for audio FIXME: does not work as intended // re-add all remote a=ssrcs for (var jid in this.remotessrc) { - if (jid == peer) continue; + if (jid == peer || !this.remotessrc[jid][i]) + continue; sdp.media[i] += this.remotessrc[jid][i]; } // and local a=ssrc lines @@ -714,6 +715,9 @@ ColibriFocus.prototype.updateChannel = function (remoteSDP, participant) { change.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid}); for (channel = 0; channel < this.channels[participant].length; channel++) { + if (!remoteSDP.media[channel]) + continue; + var name = SDPUtil.parse_mid(SDPUtil.find_line(remoteSDP.media[channel], 'a=mid:')); change.c('content', {name: name}); if (name !== 'data') @@ -894,6 +898,9 @@ ColibriFocus.prototype.setRemoteDescription = function (session, elem, desctype) this.remotessrc[session.peerjid] = []; for (channel = 0; channel < this.channels[participant].length; channel++) { //if (channel == 0) continue; FIXME: does not work as intended + if (!remoteSDP.media[channel]) + continue; + if (SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc:').length) { this.remotessrc[session.peerjid][channel] = @@ -905,6 +912,9 @@ ColibriFocus.prototype.setRemoteDescription = function (session, elem, desctype) // ACT 4: add new a=ssrc lines to local remotedescription for (channel = 0; channel < this.channels[participant].length; channel++) { //if (channel == 0) continue; FIXME: does not work as intended + if (!remoteSDP.media[channel]) + continue; + if (SDPUtil.find_lines(remoteSDP.media[channel], 'a=ssrc:').length) { this.peerconnection.enqueueAddSsrc( channel, From 3a87900bdfa981dc767549fb1a509bb48d97334b Mon Sep 17 00:00:00 2001 From: paweldomas Date: Mon, 14 Jul 2014 11:19:28 +0200 Subject: [PATCH 028/107] Adds googLeakyBucket flag to screen sharing mandatory constraints. --- libs/strophe/strophe.jingle.adapter.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/libs/strophe/strophe.jingle.adapter.js b/libs/strophe/strophe.jingle.adapter.js index 2e8e1e209..1db9c6fdd 100644 --- a/libs/strophe/strophe.jingle.adapter.js +++ b/libs/strophe/strophe.jingle.adapter.js @@ -519,6 +519,7 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res constraints.video = { mandatory: { chromeMediaSource: "screen", + googLeakyBucket: true, maxWidth: window.screen.width, maxHeight: window.screen.height, maxFrameRate: 3 @@ -530,6 +531,7 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: desktopStream, + googLeakyBucket: true, maxWidth: window.screen.width, maxHeight: window.screen.height, maxFrameRate: 3 From 66a64b6b787860b9f0a1822cf408151b175f04c4 Mon Sep 17 00:00:00 2001 From: yanas Date: Mon, 14 Jul 2014 12:33:57 +0200 Subject: [PATCH 029/107] Fixes thumbnails wrap after opening chat window. Closes issue #89. --- chat.js | 25 +++++++++++++++++++++++-- videolayout.js | 12 ++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/chat.js b/chat.js index d85bd7752..051829dac 100644 --- a/chat.js +++ b/chat.js @@ -115,8 +115,7 @@ var Chat = (function (my) { + ''); $('#chatconversation').animate( { scrollTop: $('#chatconversation')[0].scrollHeight}, 1000); - - } + }; /** * Sets the subject to the UI @@ -158,6 +157,10 @@ var Chat = (function (my) { var horizontalIndent = videoPosition[0]; var verticalIndent = videoPosition[1]; + var thumbnailSize = VideoLayout.calculateThumbnailSize(videospaceWidth); + var thumbnailsWidth = thumbnailSize[0]; + var thumbnailsHeight = thumbnailSize[1]; + if (chatspace.is(":visible")) { videospace.animate({right: chatSize[0], width: videospaceWidth, @@ -165,6 +168,15 @@ var Chat = (function (my) { {queue: false, duration: 500}); + $('#remoteVideos').animate({height: thumbnailsHeight}, + {queue: false, + duration: 500}); + + $('#remoteVideos>span').animate({height: thumbnailsHeight, + width: thumbnailsWidth}, + {queue: false, + duration: 500}); + $('#largeVideoContainer').animate({ width: videospaceWidth, height: videospaceHeight}, {queue: false, @@ -200,6 +212,15 @@ var Chat = (function (my) { } }); + $('#remoteVideos').animate({height: thumbnailsHeight}, + {queue: false, + duration: 500}); + + $('#remoteVideos>span').animate({height: thumbnailsHeight, + width: thumbnailsWidth}, + {queue: false, + duration: 500}); + $('#largeVideoContainer').animate({ width: videospaceWidth, height: videospaceHeight}, {queue: false, diff --git a/videolayout.js b/videolayout.js index fb25816ba..7178c3190 100644 --- a/videolayout.js +++ b/videolayout.js @@ -570,7 +570,9 @@ var VideoLayout = (function (my) { * Resizes thumbnails. */ my.resizeThumbnails = function() { - var thumbnailSize = calculateThumbnailSize(); + var videoSpaceWidth = $('#remoteVideos').width(); + + var thumbnailSize = VideoLayout.calculateThumbnailSize(videoSpaceWidth); var width = thumbnailSize[0]; var height = thumbnailSize[1]; @@ -682,7 +684,7 @@ var VideoLayout = (function (my) { /** * Calculates the thumbnail size. */ - var calculateThumbnailSize = function () { + 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 @@ -691,8 +693,10 @@ var VideoLayout = (function (my) { var numvids = $('#remoteVideos>span:visible').length; - // Remove the 1px borders arround videos and the chat width. - var availableWinWidth = $('#remoteVideos').width() - 2 * numvids - 50; + // Remove the 3px borders arround videos and border around the remote + // videos area + var availableWinWidth = videoSpaceWidth - 2 * 3 * numvids - 50; + var availableWidth = availableWinWidth / numvids; var aspectRatio = 16.0 / 9.0; var maxHeight = Math.min(160, availableHeight); From 7c93a87a3e5be60156f4345fec90ef6427e100df Mon Sep 17 00:00:00 2001 From: yanas Date: Mon, 14 Jul 2014 12:58:58 +0200 Subject: [PATCH 030/107] Updates versions. --- index.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index cb519c250..5f2eca872 100644 --- a/index.html +++ b/index.html @@ -27,9 +27,9 @@ - + - + @@ -39,11 +39,11 @@ - - + + - + From c32ad97c0a8bdc7057bf33de96eb0af6bef5764d Mon Sep 17 00:00:00 2001 From: yanas Date: Mon, 14 Jul 2014 13:54:26 +0200 Subject: [PATCH 031/107] Fixes focus indicator tooltip showing when we're not focus. --- index.html | 4 ++-- videolayout.js | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index 5f2eca872..c197e091d 100644 --- a/index.html +++ b/index.html @@ -39,7 +39,7 @@ - + @@ -123,7 +123,7 @@ - + diff --git a/videolayout.js b/videolayout.js index 7178c3190..8cb243725 100644 --- a/videolayout.js +++ b/videolayout.js @@ -477,9 +477,7 @@ var VideoLayout = (function (my) { if (!indicatorSpan || indicatorSpan.length === 0) { indicatorSpan = document.createElement('span'); indicatorSpan.className = 'focusindicator'; - Util.setTooltip(indicatorSpan, - "The owner of
this conference", - "top"); + focusContainer.appendChild(indicatorSpan); createFocusIndicatorElement(indicatorSpan); @@ -772,6 +770,10 @@ var VideoLayout = (function (my) { var focusIndicator = document.createElement('i'); focusIndicator.className = 'fa fa-star'; parentElement.appendChild(focusIndicator); + + Util.setTooltip(parentElement, + "The owner of
this conference", + "top"); } /** From 9d24910f82614692298519437773604c1233986f Mon Sep 17 00:00:00 2001 From: George Politis Date: Thu, 3 Jul 2014 16:35:47 +0200 Subject: [PATCH 032/107] Adds the method focus.setRTCPTerminationStrategy() --- libs/colibri/colibri.focus.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 6c53a29d2..169db05fa 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -1163,3 +1163,27 @@ ColibriFocus.prototype.sendTerminate = function (session, reason, text) { this.statsinterval = null; } }; + +ColibriFocus.prototype.setRTCPTerminationStrategy = function (strategyFQN) { + var self = this; + var strategyIQ = $iq({to: this.bridgejid, type: 'set'}); + strategyIQ.c('conference', { + xmlns: 'http://jitsi.org/protocol/colibri', + id: this.confid, + }); + + strategyIQ.c('rtcp-termination-strategy', {name: strategyFQN }); + + strategyIQ.c('content', {name: "video"}); + strategyIQ.up(); // end of content + + console.log('setting RTCP termination strategy', strategyFQN); + this.connection.sendIQ(strategyIQ, + function (res) { + console.log('got result'); + }, + function (err) { + console.error('got error', err); + } + ); + From 69508d77348b6232f27f1e3d3e01179fdfd304f8 Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Wed, 16 Jul 2014 01:36:51 +0300 Subject: [PATCH 033/107] Fixes a syntax error. Adds the ability to define a default value for the channel attribute last-n. Parses JSON messages from Videobridge received on the data channel. Fixes unnecessary changing of the value of the channel attribute expire from 15 to 60. --- config.js | 3 +- data_channels.js | 38 ++++++++++++--- libs/colibri/colibri.focus.js | 90 +++++++++++++++++++++-------------- 3 files changed, 87 insertions(+), 44 deletions(-) diff --git a/config.js b/config.js index 90bf9cf85..b68914179 100644 --- a/config.js +++ b/config.js @@ -13,6 +13,7 @@ var config = { chromeExtensionId: 'diibjkoicjeejcmhdnailmkgecihlobk', // Id of desktop streamer Chrome extension minChromeExtVersion: '0.1', // Required version of Chrome extension enableRtpStats: false, // Enables RTP stats processing - openSctp: true, //Toggle to enable/disable SCTP channels + openSctp: true, // Toggle to enable/disable SCTP channels +// channelLastN: -1, // The default value of the channel attribute last-n. enableRecording: false }; diff --git a/data_channels.js b/data_channels.js index 7a1ae59e3..06f0b5e84 100644 --- a/data_channels.js +++ b/data_channels.js @@ -10,7 +10,7 @@ function onDataChannel(event) dataChannel.onopen = function () { - console.info("Data channel opened by the bridge !!!", dataChannel); + console.info("Data channel opened by the Videobridge!", dataChannel); // Code sample for sending string and/or binary data // Sends String message to the bridge @@ -26,19 +26,42 @@ function onDataChannel(event) dataChannel.onmessage = function (event) { - var msgData = event.data; - console.info("Got Data Channel Message:", msgData, dataChannel); + var data = event.data; + + console.info("Got Data Channel Message:", data, dataChannel); // Active speaker event - if (msgData.indexOf('activeSpeaker') === 0) + if (data.indexOf('activeSpeaker') === 0) { - // Endpoint ID from the bridge - var resourceJid = msgData.split(":")[1]; + // Endpoint ID from the Videobridge. + var resourceJid = data.split(":")[1]; console.info( "Data channel new active speaker event: " + resourceJid); $(document).trigger('activespeakerchanged', [resourceJid]); } + else + { + // JSON + var obj; + + try + { + obj = JSON.parse(data); + } + catch (e) + { + console.error( + "Failed to parse data channel message as JSON: ", + data, + dataChannel); + } + if (('undefined' !== typeof(obj)) && (null !== obj)) + { + // TODO Consume the JSON-formatted data channel message. + console.debug("Data channel JSON-formatted message: ", obj); + } + } }; dataChannel.onclose = function () @@ -77,4 +100,5 @@ function bindDataChannelListener(peerConnection) var msgData = event.data; console.info("Got My Data Channel Message:", msgData, dataChannel); };*/ -} \ No newline at end of file +} + diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 169db05fa..688642f74 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -54,17 +54,16 @@ function ColibriFocus(connection, bridgejid) { * Default channel expire value in seconds. * @type {number} */ - this.channelExpire = 60; + this.channelExpire + = ('number' === typeof(config.channelExpire)) + ? config.channelExpire + : 15; // media types of the conference if (config.openSctp) - { this.media = ['audio', 'video', 'data']; - } else - { this.media = ['audio', 'video']; - } this.connection.jingle.sessions[this.sid] = this; this.mychannel = []; @@ -202,29 +201,38 @@ ColibriFocus.prototype._makeConference = function () { elem.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri'}); this.media.forEach(function (name) { - var isData = name === 'data'; - var channel = isData ? 'sctpconnection' : 'channel'; + var elemName; + var elemAttrs = { initiator: 'true', expire: self.channelExpire }; + + if ('data' === name) + { + elemName = 'sctpconnection'; + elemAttrs['port'] = 5000; + } + else + { + elemName = 'channel'; + if ('video' === name) + { + // last-n + var lastN = config.channelLastN; + if ('undefined' !== typeof(lastN)) + elemAttrs['last-n'] = lastN; + } + } elem.c('content', {name: name}); - elem.c(channel, { - initiator: 'true', - expire: '15', - endpoint: self.myMucResource - }); - if (isData) - elem.attrs({port: 5000}); - elem.up();// end of channel + elem.c(elemName, elemAttrs); + elem.attrs({ endpoint: self.myMucResource }); + elem.up();// end of channel/sctpconnection for (var j = 0; j < self.peers.length; j++) { - elem.c(channel, { - initiator: 'true', - expire: '15', - endpoint: self.peers[j].substr(1 + self.peers[j].lastIndexOf('/')) - }); - if (isData) - elem.attrs({port: 5000}); - elem.up(); // end of channel + var peer = self.peers[j]; + + elem.c(elemName, elemAttrs); + elem.attrs({ endpoint: peer.substr(1 + peer.lastIndexOf('/')) }); + elem.up(); // end of channel/sctpconnection } elem.up(); // end of content }); @@ -233,7 +241,7 @@ ColibriFocus.prototype._makeConference = function () { localSDP.media.forEach(function (media, channel) { var name = SDPUtil.parse_mline(media.split('\r\n')[0]).media; elem.c('content', {name: name}); - elem.c('channel', {initiator: 'false', expire: '15'}); + elem.c('channel', {initiator: 'false', expire: self.channelExpire}); // FIXME: should reuse code from .toJingle var mline = SDPUtil.parse_mline(media.split('\r\n')[0]); @@ -247,7 +255,7 @@ ColibriFocus.prototype._makeConference = function () { elem.up(); // end of channel for (j = 0; j < self.peers.length; j++) { - elem.c('channel', {initiator: 'true', expire:'15' }).up(); + elem.c('channel', {initiator: 'true', expire: self.channelExpire }).up(); } elem.up(); // end of content }); @@ -662,24 +670,33 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { var localSDP = new SDP(this.peerconnection.localDescription.sdp); localSDP.media.forEach(function (media, channel) { var name = SDPUtil.parse_mid(SDPUtil.find_line(media, 'a=mid:')); - elem.c('content', {name: name}); - if (name !== 'data') - { - elem.c('channel', { + var elemName; + var elemAttrs + = { initiator: 'true', expire: self.channelExpire, endpoint: peer.substr(1 + peer.lastIndexOf('/')) - }); + }; + + if ('data' == name) + { + elemName = 'sctpconnection'; + elemAttrs['port'] = 5000; } else { - elem.c('sctpconnection', { - endpoint: peer.substr(1 + peer.lastIndexOf('/')), - initiator: 'true', - expire: self.channelExpire, - port: 5000 - }); + elemName = 'channel'; + if ('video' === name) + { + // last-n + var lastN = config.channelLastN; + if ('undefined' !== typeof(lastN)) + elemAttrs['last-n'] = lastN; + } } + + elem.c('content', {name: name}); + elem.c(elemName, elemAttrs); elem.up(); // end of channel/sctpconnection elem.up(); // end of content }); @@ -1186,4 +1203,5 @@ ColibriFocus.prototype.setRTCPTerminationStrategy = function (strategyFQN) { console.error('got error', err); } ); +}; From 7ce446bcda8121dcc26ed0b8d4112c2937de1873 Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Wed, 16 Jul 2014 17:35:54 +0300 Subject: [PATCH 034/107] Receives all data-channel events/messages from Videobridge as JSON-formatted text. Renames active speaker to dominant speaker. --- app.js | 2 +- css/videolayout_default.css | 2 +- data_channels.js | 59 ++++++++++++++++++++++------------- videolayout.js | 62 ++++++++++++++++++------------------- 4 files changed, 70 insertions(+), 55 deletions(-) diff --git a/app.js b/app.js index 93ec61035..29d00e983 100644 --- a/app.js +++ b/app.js @@ -562,7 +562,7 @@ $(document).bind('callactive.jingle', function (event, videoelem, sid) { // Update the large video to the last added video only if there's no // current active or focused speaker. - if (!focusedVideoSrc && !VideoLayout.getActiveSpeakerResourceJid()) + if (!focusedVideoSrc && !VideoLayout.getDominantSpeakerResourceJid()) VideoLayout.updateLargeVideo(videoelem.attr('src'), 1); VideoLayout.showFocusIndicator(); diff --git a/css/videolayout_default.css b/css/videolayout_default.css index e37cf6c62..f140071b8 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -94,7 +94,7 @@ height: 100%; } -.activespeaker { +.dominantspeaker { background: #000 !important; } diff --git a/data_channels.js b/data_channels.js index 06f0b5e84..ac6ce4dd1 100644 --- a/data_channels.js +++ b/data_channels.js @@ -27,38 +27,53 @@ function onDataChannel(event) dataChannel.onmessage = function (event) { var data = event.data; + // JSON + var obj; - console.info("Got Data Channel Message:", data, dataChannel); - - // Active speaker event - if (data.indexOf('activeSpeaker') === 0) + try { - // Endpoint ID from the Videobridge. - var resourceJid = data.split(":")[1]; - - console.info( - "Data channel new active speaker event: " + resourceJid); - $(document).trigger('activespeakerchanged', [resourceJid]); + obj = JSON.parse(data); } - else + catch (e) { - // JSON - var obj; + console.error( + "Failed to parse data channel message as JSON: ", + data, + dataChannel); + } + if (('undefined' !== typeof(obj)) && (null !== obj)) + { + var colibriClass = obj.colibriClass; - try + if ("DominantSpeakerEndpointChangeEvent" === colibriClass) { - obj = JSON.parse(data); + // Endpoint ID from the Videobridge. + var dominantSpeakerEndpoint = obj.dominantSpeakerEndpoint; + + console.info( + "Data channel new dominant speaker event: ", + dominantSpeakerEndpoint); + $(document).trigger( + 'dominantspeakerchanged', + [dominantSpeakerEndpoint]); } - catch (e) + else if ("LastNEndpointsChangeEvent" === colibriClass) { - console.error( - "Failed to parse data channel message as JSON: ", - data, - dataChannel); + // The new/latest list of last-n endpoint IDs. + var lastNEndpoints = obj.lastNEndpoints; + /* + * The list of endpoint IDs which are entering the list of + * last-n at this time i.e. were not in the old list of last-n + * endpoint IDs. + */ + var endpointsEnteringLastN = obj.endpointsEnteringLastN; + + console.debug( + "Data channel new last-n event: ", + lastNEndpoints); } - if (('undefined' !== typeof(obj)) && (null !== obj)) + else { - // TODO Consume the JSON-formatted data channel message. console.debug("Data channel JSON-formatted message: ", obj); } } diff --git a/videolayout.js b/videolayout.js index 8cb243725..11c2dbb1c 100644 --- a/videolayout.js +++ b/videolayout.js @@ -1,6 +1,6 @@ var VideoLayout = (function (my) { var preMuted = false; - var currentActiveSpeaker = null; + var currentDominantSpeaker = null; my.changeLocalAudio = function(stream) { connection.jingle.localAudio = stream; @@ -139,19 +139,19 @@ var VideoLayout = (function (my) { if (isVisible) { // Only if the large video is currently visible. - // Disable previous active speaker video. + // Disable previous dominant speaker video. var oldJid = getJidFromVideoSrc(oldSrc); if (oldJid) { var oldResourceJid = Strophe.getResourceFromJid(oldJid); - VideoLayout.enableActiveSpeaker(oldResourceJid, false); + VideoLayout.enableDominantSpeaker(oldResourceJid, false); } - // Enable new active speaker in the remote videos section. + // Enable new dominant speaker in the remote videos section. var userJid = getJidFromVideoSrc(newSrc); if (userJid) { var resourceJid = Strophe.getResourceFromJid(userJid); - VideoLayout.enableActiveSpeaker(resourceJid, true); + VideoLayout.enableDominantSpeaker(resourceJid, true); } $(this).fadeIn(300); @@ -173,15 +173,15 @@ var VideoLayout = (function (my) { if (focusedVideoSrc === videoSrc) { focusedVideoSrc = null; - var activeSpeakerVideo = null; - // Enable the currently set active speaker. - if (currentActiveSpeaker) { - activeSpeakerVideo - = $('#participant_' + currentActiveSpeaker + '>video') + var dominantSpeakerVideo = null; + // Enable the currently set dominant speaker. + if (currentDominantSpeaker) { + dominantSpeakerVideo + = $('#participant_' + currentDominantSpeaker + '>video') .get(0); - if (activeSpeakerVideo) - VideoLayout.updateLargeVideo(activeSpeakerVideo.src, 1); + if (dominantSpeakerVideo) + VideoLayout.updateLargeVideo(dominantSpeakerVideo.src, 1); } return; @@ -254,12 +254,12 @@ var VideoLayout = (function (my) { if (isVisible) { $('#largeVideo').css({visibility: 'visible'}); $('.watermark').css({visibility: 'visible'}); - VideoLayout.enableActiveSpeaker(resourceJid, true); + VideoLayout.enableDominantSpeaker(resourceJid, true); } else { $('#largeVideo').css({visibility: 'hidden'}); $('.watermark').css({visibility: 'hidden'}); - VideoLayout.enableActiveSpeaker(resourceJid, false); + VideoLayout.enableDominantSpeaker(resourceJid, false); } }; @@ -582,20 +582,20 @@ var VideoLayout = (function (my) { }; /** - * Enables the active speaker UI. + * Enables the dominant speaker UI. * * @param resourceJid the jid indicating the video element to * activate/deactivate - * @param isEnable indicates if the active speaker should be enabled or + * @param isEnable indicates if the dominant speaker should be enabled or * disabled */ - my.enableActiveSpeaker = function(resourceJid, isEnable) { + my.enableDominantSpeaker = function(resourceJid, isEnable) { var displayName = resourceJid; var nameSpan = $('#participant_' + resourceJid + '>span.displayname'); if (nameSpan.length > 0) displayName = nameSpan.text(); - console.log("UI enable active speaker", + console.log("UI enable dominant speaker", displayName, resourceJid, isEnable); @@ -625,16 +625,16 @@ var VideoLayout = (function (my) { if (isEnable) { VideoLayout.showDisplayName(videoContainerId, true); - if (!videoSpan.classList.contains("activespeaker")) - videoSpan.classList.add("activespeaker"); + if (!videoSpan.classList.contains("dominantspeaker")) + videoSpan.classList.add("dominantspeaker"); video.css({visibility: 'hidden'}); } else { VideoLayout.showDisplayName(videoContainerId, false); - if (videoSpan.classList.contains("activespeaker")) - videoSpan.classList.remove("activespeaker"); + if (videoSpan.classList.contains("dominantspeaker")) + videoSpan.classList.remove("dominantspeaker"); video.css({visibility: 'visible'}); } @@ -805,10 +805,10 @@ var VideoLayout = (function (my) { }; /** - * Returns the current active speaker resource jid. + * Returns the current dominant speaker resource jid. */ - my.getActiveSpeakerResourceJid = function () { - return currentActiveSpeaker; + my.getDominantSpeakerResourceJid = function () { + return currentDominantSpeaker; }; /** @@ -926,21 +926,21 @@ var VideoLayout = (function (my) { }); /** - * On active speaker changed event. + * On dominant speaker changed event. */ - $(document).bind('activespeakerchanged', function (event, resourceJid) { + $(document).bind('dominantspeakerchanged', function (event, resourceJid) { // We ignore local user events. if (resourceJid === Strophe.getResourceFromJid(connection.emuc.myroomjid)) return; - // Obtain container for new active speaker. + // Obtain container for new dominant speaker. var container = document.getElementById( 'participant_' + resourceJid); - // Update the current active speaker. - if (resourceJid !== currentActiveSpeaker) - currentActiveSpeaker = resourceJid; + // Update the current dominant speaker. + if (resourceJid !== currentDominantSpeaker) + currentDominantSpeaker = resourceJid; else return; From 6a19d90420f49584946a014136effdb909a3904d Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Thu, 17 Jul 2014 23:20:36 +0300 Subject: [PATCH 035/107] Adds a method setChannelLastN to ColibriFocus which sets the default value of the channel last-n attribute and updates/patches the existing channels with it. --- libs/colibri/colibri.focus.js | 69 ++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 688642f74..56708074b 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -58,6 +58,12 @@ function ColibriFocus(connection, bridgejid) { = ('number' === typeof(config.channelExpire)) ? config.channelExpire : 15; + /** + * Default channel last-n value. + * @type {number} + */ + this.channelLastN + = ('number' === typeof(config.channelLastN)) ? config.channelLastN : -1; // media types of the conference if (config.openSctp) @@ -212,13 +218,8 @@ ColibriFocus.prototype._makeConference = function () { else { elemName = 'channel'; - if ('video' === name) - { - // last-n - var lastN = config.channelLastN; - if ('undefined' !== typeof(lastN)) - elemAttrs['last-n'] = lastN; - } + if (('video' === name) && (this.channelLastN >= 0)) + elemAttrs['last-n'] = this.channelLastN; } elem.c('content', {name: name}); @@ -686,13 +687,8 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { else { elemName = 'channel'; - if ('video' === name) - { - // last-n - var lastN = config.channelLastN; - if ('undefined' !== typeof(lastN)) - elemAttrs['last-n'] = lastN; - } + if (('video' === name) && (this.channelLastN >= 0)) + elemAttrs['last-n'] = this.channelLastN; } elem.c('content', {name: name}); @@ -1205,3 +1201,48 @@ ColibriFocus.prototype.setRTCPTerminationStrategy = function (strategyFQN) { ); }; +/** + * Sets the default value of the channel last-n attribute in this conference and + * updates/patches the existing channels. + */ +ColibriFocus.prototype.setChannelLastN = function (channelLastN) { + if (('number' === typeof(channelLastN)) + && (this.channelLastN !== channelLastN)) + { + this.channelLastN = channelLastN; + + // Update/patch the existing channels. + var patch = $iq({ to:this.bridgejid, type:'set' }); + + patch.c( + 'conference', + { xmlns:'http://jitsi.org/protocol/colibri', id:this.confid }); + patch.c('content', { name:'video' }); + patch.c( + 'channel', + { + id:$(this.mychannel[1 /* video */]).attr('id'), + 'last-n':this.channelLastN + }); + patch.up(); // end of channel + for (var p = 0; p < this.channels.length; p++) + { + patch.c( + 'channel', + { + id:$(this.channels[p][1 /* video */]).attr('id'), + 'last-n':this.channelLastN + }); + patch.up(); // end of channel + } + this.connection.sendIQ( + patch, + function (res) { + console.info('Set channel last-n succeeded: ', res); + }, + function (err) { + console.error('Set channel last-n failed: ', err); + }); + } +}; + From 7c16d5509455c1c0ab819f936367ebc510fb1461 Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Fri, 18 Jul 2014 15:17:55 +0300 Subject: [PATCH 036/107] Makes video aspect ratio an optional constraint in order to fix failures in which available resolutions meet the constraints on the width and height of a requested resolution but none of the available resolutions satisfy the constraint with respect to aspect ratio. --- libs/strophe/strophe.jingle.adapter.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/libs/strophe/strophe.jingle.adapter.js b/libs/strophe/strophe.jingle.adapter.js index 1db9c6fdd..05c69bcd6 100644 --- a/libs/strophe/strophe.jingle.adapter.js +++ b/libs/strophe/strophe.jingle.adapter.js @@ -510,7 +510,7 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res var constraints = {audio: false, video: false}; if (um.indexOf('video') >= 0) { - constraints.video = {mandatory: {}};// same behaviour as true + constraints.video = { mandatory: {}, optional: [] };// same behaviour as true } if (um.indexOf('audio') >= 0) { constraints.audio = {};// same behaviour as true @@ -523,7 +523,8 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res maxWidth: window.screen.width, maxHeight: window.screen.height, maxFrameRate: 3 - } + }, + optional: [] }; } if (um.indexOf('desktop') >= 0) { @@ -535,7 +536,8 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res maxWidth: window.screen.width, maxHeight: window.screen.height, maxFrameRate: 3 - } + }, + optional: [] } } @@ -543,7 +545,7 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res var isAndroid = navigator.userAgent.indexOf('Android') != -1; if (resolution && !constraints.video || isAndroid) { - constraints.video = {mandatory: {}};// same behaviour as true + constraints.video = { mandatory: {}, optional: [] };// same behaviour as true } // see https://code.google.com/p/chromium/issues/detail?id=143631#c9 for list of supported resolutions switch (resolution) { @@ -552,23 +554,23 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res case 'fullhd': constraints.video.mandatory.minWidth = 1920; constraints.video.mandatory.minHeight = 1080; - constraints.video.mandatory.minAspectRatio = 1.77; + constraints.video.optional.push({ minAspectRatio: 1.77 }); break; case '720': case 'hd': constraints.video.mandatory.minWidth = 1280; constraints.video.mandatory.minHeight = 720; - constraints.video.mandatory.minAspectRatio = 1.77; + constraints.video.optional.push({ minAspectRatio: 1.77 }); break; case '360': constraints.video.mandatory.minWidth = 640; constraints.video.mandatory.minHeight = 360; - constraints.video.mandatory.minAspectRatio = 1.77; + constraints.video.optional.push({ minAspectRatio: 1.77 }); break; case '180': constraints.video.mandatory.minWidth = 320; constraints.video.mandatory.minHeight = 180; - constraints.video.mandatory.minAspectRatio = 1.77; + constraints.video.optional.push({ minAspectRatio: 1.77 }); break; // 4:3 case '960': From 8146655a70d56659b0fd79c904fad194e2aa5185 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Fri, 18 Jul 2014 17:36:03 +0200 Subject: [PATCH 037/107] Fix bug with starting recording multiple times. --- libs/colibri/colibri.focus.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 56708074b..ed62d818e 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -172,7 +172,8 @@ ColibriFocus.prototype.makeConference = function (peers) { }; // Sends a COLIBRI message which enables or disables (according to 'state') the -// recording on the bridge. +// recording on the bridge. Waits for the result IQ and calls 'callback' with +// the new recording state, according to the IQ. ColibriFocus.prototype.setRecording = function(state, token, callback) { var self = this; var elem = $iq({to: this.bridgejid, type: 'get'}); @@ -187,10 +188,7 @@ ColibriFocus.prototype.setRecording = function(state, token, callback) { function (result) { console.log('Set recording "', state, '". Result:', result); var recordingElem = $(result).find('>conference>recording'); - var newState = recordingElem.attr('state'); - if (newState == null){ - newState = false; - } + var newState = ('true' === recordingElem.attr('state')); self.recordingEnabled = newState; callback(newState); From b8d27e8ab02b92eac8e63fe6fcda1caa9a199a99 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Fri, 18 Jul 2014 17:37:52 +0200 Subject: [PATCH 038/107] s/secrect/secret/ --- toolbar.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/toolbar.js b/toolbar.js index cdaaef239..f486aa6bb 100644 --- a/toolbar.js +++ b/toolbar.js @@ -29,7 +29,7 @@ var Toolbar = (function (my) { if (sharedKey) { $.prompt("Are you sure you would like to remove your secret key?", { - title: "Remove secrect key", + title: "Remove secret key", persistent: false, buttons: { "Remove": true, "Cancel": false}, defaultButton: 1, @@ -42,7 +42,7 @@ var Toolbar = (function (my) { } ); } else { - $.prompt('

Set a secrect key to lock your room

' + + $.prompt('

Set a secret key to lock your room

' + '', { persistent: false, @@ -142,7 +142,7 @@ var Toolbar = (function (my) { $.prompt('

Configure your conference

' + ' Participants join muted
' + ' Require nicknames

' + - 'Set a secrect key to lock your room: ', + 'Set a secret key to lock your room: ', { persistent: false, buttons: { "Save": true, "Cancel": false}, @@ -285,4 +285,4 @@ var Toolbar = (function (my) { }; return my; -}(Toolbar || {})); \ No newline at end of file +}(Toolbar || {})); From 6964e3197abe16b5dd11f7db6ff22821902ecdc4 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Fri, 18 Jul 2014 18:06:06 +0200 Subject: [PATCH 039/107] Uses glow to indicate that recording is active. --- app.js | 4 ++-- css/main.css | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 29d00e983..4ad7dc55d 100644 --- a/app.js +++ b/app.js @@ -923,14 +923,14 @@ function toggleRecording() { } var oldState = focus.recordingEnabled; - buttonClick("#recordButton", "icon-recEnable icon-recDisable"); + $('#recordButton').toggleClass('active'); focus.setRecording(!oldState, recordingToken, function (state) { console.log("New recording state: ", state); if (state == oldState) //failed to change, reset the token because it might have been wrong { - buttonClick("#recordButton", "icon-recEnable icon-recDisable"); + $('#recordButton').toggleClass('active'); setRecordingToken(null); } } diff --git a/css/main.css b/css/main.css index e0a1726e9..620faf17e 100644 --- a/css/main.css +++ b/css/main.css @@ -151,6 +151,27 @@ html, body{ 0 -1px 10px #00ccff; } +#recordButton { + -webkit-transition: all .5s ease-in-out; + -moz-transition: all .5s ease-in-out; + transition: all .5s ease-in-out; +} +/*#ffde00*/ +#recordButton.active { + -webkit-text-shadow: -1px 0 10px #00ccff, + 0 1px 10px #00ccff, + 1px 0 10px #00ccff, + 0 -1px 10px #00ccff; + -moz-text-shadow: 1px 0 10px #00ccff, + 0 1px 10px #00ccff, + 1px 0 10px #00ccff, + 0 -1px 10px #00ccff; + text-shadow: -1px 0 10px #00ccff, + 0 1px 10px #00ccff, + 1px 0 10px #00ccff, + 0 -1px 10px #00ccff; +} + a.button:hover { top: 0; cursor: pointer; From 777475c9ce4c1e222797b7f5bc8c4746d1666c1d Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Sun, 20 Jul 2014 09:01:23 +0300 Subject: [PATCH 040/107] Fixes an issue which could cause last-n settings to not be respected by new channel allocations. --- libs/colibri/colibri.focus.js | 68 ++++++++++++++++------------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index ed62d818e..a078cfc11 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -201,8 +201,8 @@ ColibriFocus.prototype.setRecording = function(state, token, callback) { ColibriFocus.prototype._makeConference = function () { var self = this; - var elem = $iq({to: this.bridgejid, type: 'get'}); - elem.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri'}); + var elem = $iq({ to: this.bridgejid, type: 'get' }); + elem.c('conference', { xmlns: 'http://jitsi.org/protocol/colibri' }); this.media.forEach(function (name) { var elemName; @@ -216,11 +216,11 @@ ColibriFocus.prototype._makeConference = function () { else { elemName = 'channel'; - if (('video' === name) && (this.channelLastN >= 0)) - elemAttrs['last-n'] = this.channelLastN; + if (('video' === name) && (self.channelLastN >= 0)) + elemAttrs['last-n'] = self.channelLastN; } - elem.c('content', {name: name}); + elem.c('content', { name: name }); elem.c(elemName, elemAttrs); elem.attrs({ endpoint: self.myMucResource }); @@ -655,9 +655,7 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { { console.error('local description not ready yet, postponing', peer); } - window.setTimeout(function () { - self.addNewParticipant(peer); - }, 250); + window.setTimeout(function () { self.addNewParticipant(peer); }, 250); return; } var index = this.channels.length; @@ -665,7 +663,9 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { this.peers.push(peer); var elem = $iq({to: this.bridgejid, type: 'get'}); - elem.c('conference', {xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid}); + elem.c( + 'conference', + { xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid }); var localSDP = new SDP(this.peerconnection.localDescription.sdp); localSDP.media.forEach(function (media, channel) { var name = SDPUtil.parse_mid(SDPUtil.find_line(media, 'a=mid:')); @@ -685,11 +685,11 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { else { elemName = 'channel'; - if (('video' === name) && (this.channelLastN >= 0)) - elemAttrs['last-n'] = this.channelLastN; + if (('video' === name) && (self.channelLastN >= 0)) + elemAttrs['last-n'] = self.channelLastN; } - elem.c('content', {name: name}); + elem.c('content', { name: name }); elem.c(elemName, elemAttrs); elem.up(); // end of channel/sctpconnection elem.up(); // end of content @@ -817,12 +817,7 @@ ColibriFocus.prototype.addSource = function (elem, fromJid) { if (!this.peerconnection.localDescription) { console.warn("addSource - localDescription not ready yet") - setTimeout(function() - { - self.addSource(elem, fromJid); - }, - 200 - ); + setTimeout(function() { self.addSource(elem, fromJid); }, 200); return; } @@ -863,12 +858,7 @@ ColibriFocus.prototype.removeSource = function (elem, fromJid) { if (!self.peerconnection.localDescription) { console.warn("removeSource - localDescription not ready yet"); - setTimeout(function() - { - self.removeSource(elem, fromJid); - }, - 200 - ); + setTimeout(function() { self.removeSource(elem, fromJid); }, 200); return; } @@ -1009,11 +999,13 @@ ColibriFocus.prototype.sendIceCandidate = function (candidate) { } if (this.drip_container.length === 0) { // start 20ms callout - window.setTimeout(function () { - if (self.drip_container.length === 0) return; - self.sendIceCandidates(self.drip_container); - self.drip_container = []; - }, 20); + window.setTimeout( + function () { + if (self.drip_container.length === 0) return; + self.sendIceCandidates(self.drip_container); + self.drip_container = []; + }, + 20); } this.drip_container.push(candidate); }; @@ -1210,17 +1202,17 @@ ColibriFocus.prototype.setChannelLastN = function (channelLastN) { this.channelLastN = channelLastN; // Update/patch the existing channels. - var patch = $iq({ to:this.bridgejid, type:'set' }); + var patch = $iq({ to: this.bridgejid, type: 'set' }); patch.c( 'conference', - { xmlns:'http://jitsi.org/protocol/colibri', id:this.confid }); - patch.c('content', { name:'video' }); + { xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid }); + patch.c('content', { name: 'video' }); patch.c( 'channel', { - id:$(this.mychannel[1 /* video */]).attr('id'), - 'last-n':this.channelLastN + id: $(this.mychannel[1 /* video */]).attr('id'), + 'last-n': this.channelLastN }); patch.up(); // end of channel for (var p = 0; p < this.channels.length; p++) @@ -1228,18 +1220,18 @@ ColibriFocus.prototype.setChannelLastN = function (channelLastN) { patch.c( 'channel', { - id:$(this.channels[p][1 /* video */]).attr('id'), - 'last-n':this.channelLastN + id: $(this.channels[p][1 /* video */]).attr('id'), + 'last-n': this.channelLastN }); patch.up(); // end of channel } this.connection.sendIQ( patch, function (res) { - console.info('Set channel last-n succeeded: ', res); + console.info('Set channel last-n succeeded:', res); }, function (err) { - console.error('Set channel last-n failed: ', err); + console.error('Set channel last-n failed:', err); }); } }; From 304cdf5b406ac4398bba198d1970abc3cbd00704 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Mon, 21 Jul 2014 09:49:50 +0200 Subject: [PATCH 041/107] Fix a potential problem with removing old SSRCs. --- app.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.js b/app.js index 4ad7dc55d..1251849a4 100644 --- a/app.js +++ b/app.js @@ -734,7 +734,7 @@ $(document).bind('presence.muc', function (event, jid, info, pres) { if (ssrc2jid[ssrc] == jid) { delete ssrc2jid[ssrc]; } - if (ssrc2videoType == jid) { + if (ssrc2videoType[ssrc] == jid) { delete ssrc2videoType[ssrc]; } }); From 50f1521d5cb863576b53d0df183f837b0fdc4f47 Mon Sep 17 00:00:00 2001 From: yanas Date: Mon, 21 Jul 2014 12:36:11 +0200 Subject: [PATCH 042/107] Audio level indication. Improvements in rollover and active speaker UI. --- audio_levels.js | 193 ++++++++++++++++++++++++++++++++++++++++++++++++ canvas_util.js | 101 +++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 audio_levels.js create mode 100644 canvas_util.js diff --git a/audio_levels.js b/audio_levels.js new file mode 100644 index 000000000..3f9734378 --- /dev/null +++ b/audio_levels.js @@ -0,0 +1,193 @@ +/** + * The audio Levels plugin. + */ +var AudioLevels = (function(my) { + var CANVAS_EXTRA = 104; + var CANVAS_RADIUS = 7; + var SHADOW_COLOR = '#00ccff'; + var audioLevelCanvasCache = {}; + + /** + * Updates the audio level canvas for the given peerJid. If the canvas + * didn't exist we create it. + */ + my.updateAudioLevelCanvas = function (peerJid) { + var resourceJid = null; + var videoSpanId = null; + if (!peerJid) + videoSpanId = 'localVideoContainer'; + else { + resourceJid = Strophe.getResourceFromJid(peerJid); + + videoSpanId = 'participant_' + resourceJid; + } + + videoSpan = document.getElementById(videoSpanId); + + if (!videoSpan) { + if (resourceJid) + console.error("No video element for jid", resourceJid); + else + console.error("No video element for local video."); + + return; + } + + var audioLevelCanvas = $('#' + videoSpanId + '>canvas'); + + var videoSpaceWidth = $('#remoteVideos').width(); + var thumbnailSize + = VideoLayout.calculateThumbnailSize(videoSpaceWidth); + var thumbnailWidth = thumbnailSize[0]; + var thumbnailHeight = thumbnailSize[1]; + + if (!audioLevelCanvas || audioLevelCanvas.length === 0) { + + audioLevelCanvas = document.createElement('canvas'); + audioLevelCanvas.className = "audiolevel"; + audioLevelCanvas.style.bottom = "-" + CANVAS_EXTRA/2 + "px"; + audioLevelCanvas.style.left = "-" + CANVAS_EXTRA/2 + "px"; + resizeAudioLevelCanvas( audioLevelCanvas, + thumbnailWidth, + thumbnailHeight); + + videoSpan.appendChild(audioLevelCanvas); + } else { + audioLevelCanvas = audioLevelCanvas.get(0); + + resizeAudioLevelCanvas( audioLevelCanvas, + thumbnailWidth, + thumbnailHeight); + } + }; + + /** + * Updates the audio level UI for the given resourceJid. + * + * @param resourceJid the resource jid indicating the video element for + * which we draw the audio level + * @param audioLevel the newAudio level to render + */ + my.updateAudioLevel = function (resourceJid, audioLevel) { + drawAudioLevelCanvas(resourceJid, audioLevel); + + var videoSpanId = null; + if (resourceJid + === Strophe.getResourceFromJid(connection.emuc.myroomjid)) + videoSpanId = 'localVideoContainer'; + else + videoSpanId = 'participant_' + resourceJid; + + var audioLevelCanvas = $('#' + videoSpanId + '>canvas').get(0); + + if (!audioLevelCanvas) + return ; + + var drawContext = audioLevelCanvas.getContext('2d'); + + var canvasCache = audioLevelCanvasCache[resourceJid]; + + drawContext.clearRect (0, 0, + audioLevelCanvas.width, audioLevelCanvas.height); + drawContext.drawImage(canvasCache, 0, 0); + }; + + function resizeAudioLevelCanvas(audioLevelCanvas, + thumbnailWidth, + thumbnailHeight) { + audioLevelCanvas.width = thumbnailWidth + CANVAS_EXTRA; + audioLevelCanvas.height = thumbnailHeight + CANVAS_EXTRA; + }; + + /** + * Draws the audio level canvas into the cached canvas object. + * + * @param resourceJid the resource jid indicating the video element for + * which we draw the audio level + * @param audioLevel the newAudio level to render + */ + function drawAudioLevelCanvas(resourceJid, audioLevel) { + if (!audioLevelCanvasCache[resourceJid]) { + var videoSpanId = null; + if (resourceJid + === Strophe.getResourceFromJid(connection.emuc.myroomjid)) + videoSpanId = 'localVideoContainer'; + else + videoSpanId = 'participant_' + resourceJid; + + var audioLevelCanvasOrig = $('#' + videoSpanId + '>canvas').get(0); + + audioLevelCanvasCache[resourceJid] + = CanvasUtil.cloneCanvas(audioLevelCanvasOrig); + } + + var canvas = audioLevelCanvasCache[resourceJid]; + + var drawContext = canvas.getContext('2d'); + + drawContext.clearRect (0, 0, canvas.width, canvas.height); + + var shadowLevel = getShadowLevel(audioLevel); + + if (shadowLevel > 0) + // drawContext, x, y, w, h, r, shadowColor, shadowLevel + CanvasUtil.drawRoundRectGlow( drawContext, + CANVAS_EXTRA/2, CANVAS_EXTRA/2, + canvas.width - CANVAS_EXTRA, + canvas.height - CANVAS_EXTRA, + CANVAS_RADIUS, + SHADOW_COLOR, + shadowLevel); + }; + + /** + * Returns the shadow/glow level for the given audio level. + * + * @param audioLevel the audio level from which we determine the shadow + * level + */ + function getShadowLevel (audioLevel) { + var shadowLevel = 0; + + if (audioLevel <= 0.3) { + shadowLevel = Math.round(CANVAS_EXTRA/2*(audioLevel/0.3)); + } + else if (audioLevel <= 0.6) { + shadowLevel = Math.round(CANVAS_EXTRA/2*((audioLevel - 0.3) / 0.3)); + } + else { + shadowLevel = Math.round(CANVAS_EXTRA/2*((audioLevel - 0.6) / 0.4)); + } + return shadowLevel; + }; + + /** + * Indicates that the remote video has been resized. + */ + $(document).bind('remotevideo.resized', function (event, width, height) { + var resized = false; + $('#remoteVideos>span>canvas').each(function() { + var canvas = $(this).get(0); + if (canvas.width !== width + CANVAS_EXTRA) { + canvas.width = width + CANVAS_EXTRA; + resized = true; + } + + if (canvas.heigh !== height + CANVAS_EXTRA) { + canvas.height = height + CANVAS_EXTRA; + resized = true; + } + }); + + if (resized) + Object.keys(audioLevelCanvasCache).forEach(function (resourceJid) { + audioLevelCanvasCache[resourceJid].width + = width + CANVAS_EXTRA; + audioLevelCanvasCache[resourceJid].height + = height + CANVAS_EXTRA; + }); + }); + + return my; + +})(AudioLevels || {}); \ No newline at end of file diff --git a/canvas_util.js b/canvas_util.js new file mode 100644 index 000000000..bc96f030a --- /dev/null +++ b/canvas_util.js @@ -0,0 +1,101 @@ +/** + * Utility class for drawing canvas shapes. + */ +var CanvasUtil = (function(my) { + + /** + * Draws a round rectangle with a glow. The glowWidth indicates the depth + * of the glow. + * + * @param drawContext the context of the canvas to draw to + * @param x the x coordinate of the round rectangle + * @param y the y coordinate of the round rectangle + * @param w the width of the round rectangle + * @param h the height of the round rectangle + * @param glowColor the color of the glow + * @param glowWidth the width of the glow + */ + my.drawRoundRectGlow + = function(drawContext, x, y, w, h, r, glowColor, glowWidth) { + + // Save the previous state of the context. + drawContext.save(); + + if (w < 2 * r) r = w / 2; + if (h < 2 * r) r = h / 2; + + // Draw a round rectangle. + drawContext.beginPath(); + drawContext.moveTo(x+r, y); + drawContext.arcTo(x+w, y, x+w, y+h, r); + drawContext.arcTo(x+w, y+h, x, y+h, r); + drawContext.arcTo(x, y+h, x, y, r); + drawContext.arcTo(x, y, x+w, y, r); + drawContext.closePath(); + + // Add a shadow around the rectangle + drawContext.shadowColor = glowColor; + drawContext.shadowBlur = glowWidth; + drawContext.shadowOffsetX = 0; + drawContext.shadowOffsetY = 0; + + // Fill the shape. + drawContext.fill(); + + drawContext.save(); + + drawContext.restore(); + +// 1) Uncomment this line to use Composite Operation, which is doing the +// same as the clip function below and is also antialiasing the round +// border, but is said to be less fast performance wise. + +// drawContext.globalCompositeOperation='destination-out'; + + drawContext.beginPath(); + drawContext.moveTo(x+r, y); + drawContext.arcTo(x+w, y, x+w, y+h, r); + drawContext.arcTo(x+w, y+h, x, y+h, r); + drawContext.arcTo(x, y+h, x, y, r); + drawContext.arcTo(x, y, x+w, y, r); + drawContext.closePath(); + +// 2) Uncomment this line to use Composite Operation, which is doing the +// same as the clip function below and is also antialiasing the round +// border, but is said to be less fast performance wise. + +// drawContext.fill(); + + // Comment these two lines if choosing to do the same with composite + // operation above 1 and 2. + drawContext.clip(); + drawContext.clearRect(0, 0, 277, 200); + + // Restore the previous context state. + drawContext.restore(); + }; + + /** + * Clones the given canvas. + * + * @return the new cloned canvas. + */ + my.cloneCanvas = function (oldCanvas) { + + //create a new canvas + var newCanvas = document.createElement('canvas'); + var context = newCanvas.getContext('2d'); + + //set dimensions + newCanvas.width = oldCanvas.width; + newCanvas.height = oldCanvas.height; + + //apply the old canvas to the new one + context.drawImage(oldCanvas, 0, 0); + + //return the new canvas + return newCanvas; + }; + + return my; +})(CanvasUtil || {}); \ No newline at end of file From 1d3df3c41c877e390001247f18a1ba6b4523ef1b Mon Sep 17 00:00:00 2001 From: yanas Date: Tue, 22 Jul 2014 15:12:48 +0200 Subject: [PATCH 043/107] Audio level indication. Improvements in rollover and active speaker UI. Part 2. --- app.js | 10 +++++++--- chat.js | 15 +++++++++++++-- config.js | 2 +- css/videolayout_default.css | 19 +++++++++++++++++-- index.html | 12 +++++++----- local_stats.js | 9 +++------ videolayout.js | 8 ++++++++ 7 files changed, 56 insertions(+), 19 deletions(-) diff --git a/app.js b/app.js index 1251849a4..c15fd864b 100644 --- a/app.js +++ b/app.js @@ -448,8 +448,9 @@ function statsUpdated(statsCollector) var peerStats = statsCollector.jid2stats[jid]; Object.keys(peerStats.ssrc2AudioLevel).forEach(function (ssrc) { -// console.info(jid + " audio level: " + -// peerStats.ssrc2AudioLevel[ssrc] + " of ssrc: " + ssrc); + if (jid !== connection.emuc.myRoomJid) + AudioLevels.updateAudioLevel( Strophe.getResourceFromJid(jid), + peerStats.ssrc2AudioLevel[ssrc]); }); }); } @@ -461,7 +462,10 @@ function statsUpdated(statsCollector) */ function localStatsUpdated(statsCollector) { -// console.info("Local audio level: " + statsCollector.audioLevel); + if (connection.emuc.myRoomJid) + AudioLevels.updateAudioLevel( + Strophe.getResourceFromJid(connection.emuc.myRoomJid), + statsCollector.audioLevel); } /** diff --git a/chat.js b/chat.js index 051829dac..afb06c771 100644 --- a/chat.js +++ b/chat.js @@ -175,7 +175,13 @@ var Chat = (function (my) { $('#remoteVideos>span').animate({height: thumbnailsHeight, width: thumbnailsWidth}, {queue: false, - duration: 500}); + duration: 500, + complete: function() { + $(document).trigger( + "remotevideo.resized", + [thumbnailsWidth, + thumbnailsHeight]); + }}); $('#largeVideoContainer').animate({ width: videospaceWidth, height: videospaceHeight}, @@ -219,7 +225,12 @@ var Chat = (function (my) { $('#remoteVideos>span').animate({height: thumbnailsHeight, width: thumbnailsWidth}, {queue: false, - duration: 500}); + duration: 500, + complete: function() { + $(document).trigger( + "remotevideo.resized", + [thumbnailsWidth, thumbnailsHeight]); + }}); $('#largeVideoContainer').animate({ width: videospaceWidth, height: videospaceHeight}, diff --git a/config.js b/config.js index b68914179..db9c3b195 100644 --- a/config.js +++ b/config.js @@ -12,7 +12,7 @@ var config = { desktopSharing: 'ext', // Desktop sharing method. Can be set to 'ext', 'webrtc' or false to disable. chromeExtensionId: 'diibjkoicjeejcmhdnailmkgecihlobk', // Id of desktop streamer Chrome extension minChromeExtVersion: '0.1', // Required version of Chrome extension - enableRtpStats: false, // Enables RTP stats processing + enableRtpStats: true, // Enables RTP stats processing openSctp: true, // Toggle to enable/disable SCTP channels // channelLastN: -1, // The default value of the channel attribute last-n. enableRecording: false diff --git a/css/videolayout_default.css b/css/videolayout_default.css index f140071b8..b87782db4 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -49,8 +49,16 @@ -webkit-animation-name: greyPulse; -webkit-animation-duration: 2s; -webkit-animation-iteration-count: 1; - -webkit-box-shadow: 0 0 18px #388396; - border: 2px solid #388396; +} + +#remoteVideos .videocontainer:hover { + -webkit-box-shadow: inset 0 0 10px #FFFFFF, 0 0 10px #FFFFFF; + border: 2px solid #FFFFFF; +} + +#remoteVideos .videocontainer.videoContainerFocused { + -webkit-box-shadow: inset 0 0 28px #006d91; + border: 2px solid #006d91; } #localVideoWrapper { @@ -291,3 +299,10 @@ background-image:url(../images/rightwatermark.png); background-position: center right; } + +.audiolevel { + display: inline-block; + position: absolute; + z-index: 0; + border-radius:10px; +} diff --git a/index.html b/index.html index c197e091d..ef310d7a7 100644 --- a/index.html +++ b/index.html @@ -22,14 +22,14 @@ - + - + - + @@ -39,12 +39,14 @@ - + + + - + diff --git a/local_stats.js b/local_stats.js index 2802fe455..6b2c44277 100644 --- a/local_stats.js +++ b/local_stats.js @@ -32,7 +32,6 @@ var LocalStatsCollector = (function() { this.audioLevel = 0; } - /** * Starts the collecting the statistics. */ @@ -61,8 +60,7 @@ var LocalStatsCollector = (function() { }, this.intervalMilis ); - - } + }; /** * Stops collecting the statistics. @@ -72,8 +70,7 @@ var LocalStatsCollector = (function() { clearInterval(this.intervalId); this.intervalId = null; } - } - + }; /** * Converts frequency data array to audio level. @@ -91,7 +88,7 @@ var LocalStatsCollector = (function() { } return maxVolume / 255; - } + }; return LocalStatsCollectorProto; })(); \ No newline at end of file diff --git a/videolayout.js b/videolayout.js index 11c2dbb1c..0c94799f3 100644 --- a/videolayout.js +++ b/videolayout.js @@ -26,6 +26,8 @@ var VideoLayout = (function (my) { var localVideoContainer = document.getElementById('localVideoWrapper'); localVideoContainer.appendChild(localVideo); + AudioLevels.updateAudioLevelCanvas(); + var localVideoSelector = $('#' + localVideo.id); // Add click handler to both video and video wrapper elements in case // there's no video. @@ -313,6 +315,8 @@ var VideoLayout = (function (my) { addRemoteVideoMenu(peerJid, container); remotes.appendChild(container); + AudioLevels.updateAudioLevelCanvas(peerJid); + return container; }; @@ -579,6 +583,8 @@ var VideoLayout = (function (my) { $('#remoteVideos').height(height); $('#remoteVideos>span').width(width); $('#remoteVideos>span').height(height); + + $(document).trigger("remotevideo.resized", [width, height]); }; /** @@ -958,3 +964,5 @@ var VideoLayout = (function (my) { return my; }(VideoLayout || {})); + + From 256694b966392b7aa3c01f25a46c073e3ad82e86 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Wed, 23 Jul 2014 09:12:36 +0200 Subject: [PATCH 044/107] Sends endpoint information in COLIBRI messages (in 'endpoint' children of 'conference'). --- app.js | 16 +++++++ libs/colibri/colibri.focus.js | 85 ++++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/app.js b/app.js index c15fd864b..0eeb5fdc4 100644 --- a/app.js +++ b/app.js @@ -632,6 +632,10 @@ $(document).bind('joined.muc', function (event, jid, info) { if (Object.keys(connection.emuc.members).length < 1) { focus = new ColibriFocus(connection, config.hosts.bridge); + if (nickname !== null) { + focus.setEndpointDisplayName(connection.emuc.myroomjid, + nickname); + } showRecordingButton(false); } @@ -710,6 +714,10 @@ $(document).bind('left.muc', function (event, jid) { && !sessionTerminated) { console.log('welcome to our new focus... myself'); focus = new ColibriFocus(connection, config.hosts.bridge); + if (nickname !== null) { + focus.setEndpointDisplayName(connection.emuc.myroomjid, + nickname); + } if (Object.keys(connection.emuc.members).length > 0) { focus.makeConference(Object.keys(connection.emuc.members)); @@ -723,6 +731,10 @@ $(document).bind('left.muc', function (event, jid) { // problems with reinit disposeConference(); focus = new ColibriFocus(connection, config.hosts.bridge); + if (nickname !== null) { + focus.setEndpointDisplayName(connection.emuc.myroomjid, + nickname); + } showRecordingButton(false); } if (connection.emuc.getPrezi(jid)) { @@ -776,6 +788,10 @@ $(document).bind('presence.muc', function (event, jid, info, pres) { 'participant_' + Strophe.getResourceFromJid(jid), info.displayName); } + + if (focus !== null && info.displayName !== null) { + focus.setEndpointDisplayName(jid, info.displayName); + } }); $(document).bind('passwordrequired.muc', function (event, jid) { diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index a078cfc11..20a55a53e 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -84,6 +84,10 @@ function ColibriFocus(connection, bridgejid) { this.wait = true; this.recordingEnabled = false; + + // stores information about the endpoints (i.e. display names) to + // be sent to the videobridge. + this.endpointsInfo = null; } // creates a conferences with an initial set of peers @@ -176,7 +180,7 @@ ColibriFocus.prototype.makeConference = function (peers) { // the new recording state, according to the IQ. ColibriFocus.prototype.setRecording = function(state, token, callback) { var self = this; - var elem = $iq({to: this.bridgejid, type: 'get'}); + var elem = $iq({to: this.bridgejid, type: 'set'}); elem.c('conference', { xmlns: 'http://jitsi.org/protocol/colibri', id: this.confid @@ -199,6 +203,74 @@ ColibriFocus.prototype.setRecording = function(state, token, callback) { ); }; +/* + * Updates the display name for an endpoint with a specific jid. + * jid: the jid associated with the endpoint. + * displayName: the new display name for the endpoint. + */ +ColibriFocus.prototype.setEndpointDisplayName = function(jid, displayName) { + var endpointId = jid.substr(1 + jid.lastIndexOf('/')); + var update = false; + + if (this.endpointsInfo === null) { + this.endpointsInfo = {}; + } + + var endpointInfo = this.endpointsInfo[endpointId]; + if ('undefined' === typeof endpointInfo) { + endpointInfo = this.endpointsInfo[endpointId] = {}; + } + + if (endpointInfo['displayname'] !== displayName) { + endpointInfo['displayname'] = displayName; + update = true; + } + + if (update) { + this.updateEndpoints(); + } +}; + +/* + * Sends a colibri message to the bridge that contains the + * current endpoints and their display names. + */ +ColibriFocus.prototype.updateEndpoints = function() { + if (this.confid === null + || this.endpointsInfo === null) { + return; + } + + if (this.confid === 0) { + // the colibri conference is currently initiating + var self = this; + window.setTimeout(function() { self.updateEndpoints()}, 1000); + return; + } + + var elem = $iq({to: this.bridgejid, type: 'set'}); + elem.c('conference', { + xmlns: 'http://jitsi.org/protocol/colibri', + id: this.confid + }); + + for (var id in this.endpointsInfo) { + elem.c('endpoint'); + elem.attrs({ id: id, + displayname: this.endpointsInfo[id]['displayname'] + }); + elem.up(); + } + + //elem.up(); //conference + + this.connection.sendIQ( + elem, + function (result) {}, + function (error) { console.warn(error); } + ); +}; + ColibriFocus.prototype._makeConference = function () { var self = this; var elem = $iq({ to: this.bridgejid, type: 'get' }); @@ -235,6 +307,17 @@ ColibriFocus.prototype._makeConference = function () { } elem.up(); // end of content }); + + if (this.endpointsInfo !== null) { + for (var id in this.endpointsInfo) { + elem.c('endpoint'); + elem.attrs({ id: id, + displayname: this.endpointsInfo[id]['displayname'] + }); + elem.up(); + } + } + /* var localSDP = new SDP(this.peerconnection.localDescription.sdp); localSDP.media.forEach(function (media, channel) { From 05975e30a3be7e5a7863ed8a15ee7ac2f4bdfafd Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Wed, 23 Jul 2014 10:47:00 +0300 Subject: [PATCH 045/107] Moves recording button related code to toolbar.js. --- app.js | 24 ++++++------------------ toolbar.js | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/app.js b/app.js index 0eeb5fdc4..447a76753 100644 --- a/app.js +++ b/app.js @@ -636,7 +636,7 @@ $(document).bind('joined.muc', function (event, jid, info) { focus.setEndpointDisplayName(connection.emuc.myroomjid, nickname); } - showRecordingButton(false); + Toolbar.showRecordingButton(false); } if (focus && config.etherpad_base) { @@ -668,7 +668,7 @@ $(document).bind('entered.muc', function (event, jid, info, pres) { if (focus.confid === null) { console.log('make new conference with', jid); focus.makeConference(Object.keys(connection.emuc.members)); - showRecordingButton(true); + Toolbar.showRecordingButton(true); } else { console.log('invite', jid, 'into conference'); focus.addNewParticipant(jid); @@ -721,7 +721,7 @@ $(document).bind('left.muc', function (event, jid) { if (Object.keys(connection.emuc.members).length > 0) { focus.makeConference(Object.keys(connection.emuc.members)); - showRecordingButton(true); + Toolbar.showRecordingButton(true); } $(document).trigger('focusechanged.muc', [focus]); } @@ -735,7 +735,7 @@ $(document).bind('left.muc', function (event, jid) { focus.setEndpointDisplayName(connection.emuc.myroomjid, nickname); } - showRecordingButton(false); + Toolbar.showRecordingButton(false); } if (connection.emuc.getPrezi(jid)) { $(document).trigger('presentationremoved.muc', @@ -943,14 +943,14 @@ function toggleRecording() { } var oldState = focus.recordingEnabled; - $('#recordButton').toggleClass('active'); + Toolbar.toggleRecordingButtonState(); focus.setRecording(!oldState, recordingToken, function (state) { console.log("New recording state: ", state); if (state == oldState) //failed to change, reset the token because it might have been wrong { - $('#recordButton').toggleClass('active'); + Toolbar.toggleRecordingButtonState(); setRecordingToken(null); } } @@ -1263,19 +1263,7 @@ function setView(viewName) { // } } -function showRecordingButton(show) { - if (!config.enableRecording) { - return; - } - if (show) { - $('#recording').css({display: "inline"}); - } - else { - $('#recording').css({display: "none"}); - } - -} $(document).bind('fatalError.jingle', function (event, session, error) diff --git a/toolbar.js b/toolbar.js index f486aa6bb..870dfa47b 100644 --- a/toolbar.js +++ b/toolbar.js @@ -284,5 +284,24 @@ var Toolbar = (function (my) { } }; + // Shows or hides the 'recording' button. + my.showRecordingButton = function (show) { + if (!config.enableRecording) { + return; + } + + if (show) { + $('#recording').css({display: "inline"}); + } + else { + $('#recording').css({display: "none"}); + } + }; + + // Toggle the state of the recording button + my.toggleRecordingButtonState = function() { + $('#recordButton').toggleClass('active'); + }; + return my; }(Toolbar || {})); From e9a3b453165f1b6ef20a6f99d881593194ea0953 Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Wed, 23 Jul 2014 19:05:56 +0300 Subject: [PATCH 046/107] Fixes an error caused by trying to get a property of undefined related to audio levels. --- audio_levels.js | 20 ++++++++++++++++---- canvas_util.js | 10 +++++++++- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/audio_levels.js b/audio_levels.js index 3f9734378..a25da0116 100644 --- a/audio_levels.js +++ b/audio_levels.js @@ -117,15 +117,27 @@ var AudioLevels = (function(my) { var audioLevelCanvasOrig = $('#' + videoSpanId + '>canvas').get(0); - audioLevelCanvasCache[resourceJid] - = CanvasUtil.cloneCanvas(audioLevelCanvasOrig); + /* + * FIXME Testing has shown that audioLevelCanvasOrig may not exist. + * In such a case, the method CanvasUtil.cloneCanvas may throw an + * error. Since audio levels are frequently updated, the errors have + * been observed to pile into the console, strain the CPU. + */ + if (audioLevelCanvasOrig) + { + audioLevelCanvasCache[resourceJid] + = CanvasUtil.cloneCanvas(audioLevelCanvasOrig); + } } var canvas = audioLevelCanvasCache[resourceJid]; + if (!canvas) + return; + var drawContext = canvas.getContext('2d'); - drawContext.clearRect (0, 0, canvas.width, canvas.height); + drawContext.clearRect(0, 0, canvas.width, canvas.height); var shadowLevel = getShadowLevel(audioLevel); @@ -190,4 +202,4 @@ var AudioLevels = (function(my) { return my; -})(AudioLevels || {}); \ No newline at end of file +})(AudioLevels || {}); diff --git a/canvas_util.js b/canvas_util.js index bc96f030a..b8a1b0e9e 100644 --- a/canvas_util.js +++ b/canvas_util.js @@ -81,6 +81,14 @@ var CanvasUtil = (function(my) { * @return the new cloned canvas. */ my.cloneCanvas = function (oldCanvas) { + /* + * FIXME Testing has shown that oldCanvas may not exist. In such a case, + * the method CanvasUtil.cloneCanvas may throw an error. Since audio + * levels are frequently updated, the errors have been observed to pile + * into the console, strain the CPU. + */ + if (!oldCanvas) + return oldCanvas; //create a new canvas var newCanvas = document.createElement('canvas'); @@ -98,4 +106,4 @@ var CanvasUtil = (function(my) { }; return my; -})(CanvasUtil || {}); \ No newline at end of file +})(CanvasUtil || {}); From 5f34f67fc5eb404f60d918660c65dcea4e771076 Mon Sep 17 00:00:00 2001 From: Lyubomir Marinov Date: Wed, 23 Jul 2014 20:08:53 +0300 Subject: [PATCH 047/107] Fixes an error when clicking on a thumbnail without video. --- app.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app.js b/app.js index 447a76753..9711e8209 100644 --- a/app.js +++ b/app.js @@ -312,8 +312,15 @@ $(document).bind('remotestreamadded.jingle', function (event, data, sid) { // Add click handler. container.onclick = function (event) { - VideoLayout.handleVideoThumbClicked( - $('#' + container.id + '>video').get(0).src); + /* + * FIXME It turns out that videoThumb may not exist (if there is no + * actual video). + */ + var videoThumb = $('#' + container.id + '>video').get(0); + + if (videoThumb) + VideoLayout.handleVideoThumbClicked(videoThumb.src); + event.preventDefault(); return false; }; From 9e29378d7f379ac2b544462e9cfe6f6dc1e86c8e Mon Sep 17 00:00:00 2001 From: yanas Date: Thu, 24 Jul 2014 13:22:51 +0200 Subject: [PATCH 048/107] Fixes appearing scroll after adding the audio levels. --- css/videolayout_default.css | 1 + 1 file changed, 1 insertion(+) diff --git a/css/videolayout_default.css b/css/videolayout_default.css index b87782db4..145fcf616 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -4,6 +4,7 @@ top: 0px; left: 0px; right: 0px; + overflow: hidden; } #remoteVideos { From 1dcbf7fce65ea169316fe14401b84f938dcd2509 Mon Sep 17 00:00:00 2001 From: yanas Date: Thu, 24 Jul 2014 13:39:46 +0200 Subject: [PATCH 049/107] Fixes a minor bug in toolbar hide/show behavior when etherpad or prezi is opened. --- chat.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/chat.js b/chat.js index afb06c771..8db249b7e 100644 --- a/chat.js +++ b/chat.js @@ -204,8 +204,10 @@ var Chat = (function (my) { duration: 500}); } else { - // Undock the toolbar when the chat is shown. - Toolbar.dockToolbar(false); + // Undock the toolbar when the chat is shown and if we're in a + // video mode. + if (VideoLayout.isLargeVideoVisible()) + Toolbar.dockToolbar(false); videospace.animate({right: chatSize[0], width: videospaceWidth, From 913cdb9c7a90642fbc122ba4d360206579f3d9d5 Mon Sep 17 00:00:00 2001 From: yanas Date: Thu, 24 Jul 2014 16:14:37 +0200 Subject: [PATCH 050/107] Fixes audio level interface in the single user in conference case. --- app.js | 15 +++++++-------- audio_levels.js | 38 +++++++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/app.js b/app.js index 9711e8209..c34a0605c 100644 --- a/app.js +++ b/app.js @@ -293,7 +293,8 @@ $(document).bind('remotestreamadded.jingle', function (event, data, sid) { data.stream.onended = function () { console.log('stream ended', this.id); - // Mark video as removed to cancel waiting loop(if video is removed before has started) + // Mark video as removed to cancel waiting loop(if video is removed + // before has started) sel.removed = true; sel.remove(); @@ -455,9 +456,8 @@ function statsUpdated(statsCollector) var peerStats = statsCollector.jid2stats[jid]; Object.keys(peerStats.ssrc2AudioLevel).forEach(function (ssrc) { - if (jid !== connection.emuc.myRoomJid) - AudioLevels.updateAudioLevel( Strophe.getResourceFromJid(jid), - peerStats.ssrc2AudioLevel[ssrc]); + AudioLevels.updateAudioLevel( Strophe.getResourceFromJid(jid), + peerStats.ssrc2AudioLevel[ssrc]); }); }); } @@ -469,10 +469,9 @@ function statsUpdated(statsCollector) */ function localStatsUpdated(statsCollector) { - if (connection.emuc.myRoomJid) - AudioLevels.updateAudioLevel( - Strophe.getResourceFromJid(connection.emuc.myRoomJid), - statsCollector.audioLevel); + AudioLevels.updateAudioLevel( + AudioLevels.LOCAL_LEVEL, + statsCollector.audioLevel); } /** diff --git a/audio_levels.js b/audio_levels.js index a25da0116..3a94fff22 100644 --- a/audio_levels.js +++ b/audio_levels.js @@ -7,6 +7,8 @@ var AudioLevels = (function(my) { var SHADOW_COLOR = '#00ccff'; var audioLevelCanvasCache = {}; + my.LOCAL_LEVEL = 'local'; + /** * Updates the audio level canvas for the given peerJid. If the canvas * didn't exist we create it. @@ -71,17 +73,12 @@ var AudioLevels = (function(my) { my.updateAudioLevel = function (resourceJid, audioLevel) { drawAudioLevelCanvas(resourceJid, audioLevel); - var videoSpanId = null; - if (resourceJid - === Strophe.getResourceFromJid(connection.emuc.myroomjid)) - videoSpanId = 'localVideoContainer'; - else - videoSpanId = 'participant_' + resourceJid; + var videoSpanId = getVideoSpanId(resourceJid); var audioLevelCanvas = $('#' + videoSpanId + '>canvas').get(0); if (!audioLevelCanvas) - return ; + return; var drawContext = audioLevelCanvas.getContext('2d'); @@ -92,6 +89,9 @@ var AudioLevels = (function(my) { drawContext.drawImage(canvasCache, 0, 0); }; + /** + * Resizes the given audio level canvas to match the given thumbnail size. + */ function resizeAudioLevelCanvas(audioLevelCanvas, thumbnailWidth, thumbnailHeight) { @@ -108,12 +108,8 @@ var AudioLevels = (function(my) { */ function drawAudioLevelCanvas(resourceJid, audioLevel) { if (!audioLevelCanvasCache[resourceJid]) { - var videoSpanId = null; - if (resourceJid - === Strophe.getResourceFromJid(connection.emuc.myroomjid)) - videoSpanId = 'localVideoContainer'; - else - videoSpanId = 'participant_' + resourceJid; + + var videoSpanId = getVideoSpanId(resourceJid); var audioLevelCanvasOrig = $('#' + videoSpanId + '>canvas').get(0); @@ -173,6 +169,22 @@ var AudioLevels = (function(my) { return shadowLevel; }; + /** + * Returns the video span id corresponding to the given resourceJid or local + * user. + */ + function getVideoSpanId(resourceJid) { + var videoSpanId = null; + if (resourceJid === AudioLevels.LOCAL_LEVEL + || (connection.emuc.myroomjid && resourceJid + === Strophe.getResourceFromJid(connection.emuc.myroomjid))) + videoSpanId = 'localVideoContainer'; + else + videoSpanId = 'participant_' + resourceJid; + + return videoSpanId; + }; + /** * Indicates that the remote video has been resized. */ From 83b4ee96d3e7e3e01cceae257947fc07f1456653 Mon Sep 17 00:00:00 2001 From: hristoterezov Date: Sun, 27 Jul 2014 15:24:27 +0300 Subject: [PATCH 051/107] Enables LocalStatsCollector for the local audio levels instead of rtp stats. Fixes issue with local audio level calculations. --- app.js | 49 +++++++++++++--------------------------- local_stats.js | 61 ++++++++++++++++++++++++++++++++++++++++---------- rtp_stats.js | 4 ++-- 3 files changed, 67 insertions(+), 47 deletions(-) diff --git a/app.js b/app.js index c34a0605c..9cacb7b37 100644 --- a/app.js +++ b/app.js @@ -445,33 +445,23 @@ function muteVideo(pc, unmute) { } /** - * Callback called by {@link StatsCollector} in intervals supplied to it's - * constructor. - * @param statsCollector {@link StatsCollector} source of the event. + * Callback for audio levels changed. + * @param jid JID of the user + * @param audioLevel the audio level value */ -function statsUpdated(statsCollector) +function audioLevelUpdated(jid, audioLevel) { - Object.keys(statsCollector.jid2stats).forEach(function (jid) + var resourceJid; + if(jid === LocalStatsCollector.LOCAL_JID) { - var peerStats = statsCollector.jid2stats[jid]; - Object.keys(peerStats.ssrc2AudioLevel).forEach(function (ssrc) - { - AudioLevels.updateAudioLevel( Strophe.getResourceFromJid(jid), - peerStats.ssrc2AudioLevel[ssrc]); - }); - }); -} + resourceJid = AudioLevels.LOCAL_LEVEL; + } + else + { + resourceJid = Strophe.getResourceFromJid(jid); + } -/** - * Callback called by {@link LocalStatsCollector} in intervals supplied to it's - * constructor. - * @param statsCollector {@link LocalStatsCollector} source of the event. - */ -function localStatsUpdated(statsCollector) -{ - AudioLevels.updateAudioLevel( - AudioLevels.LOCAL_LEVEL, - statsCollector.audioLevel); + AudioLevels.updateAudioLevel(resourceJid, audioLevel); } /** @@ -483,10 +473,7 @@ function startRtpStatsCollector() if (config.enableRtpStats) { statsCollector = new StatsCollector( - getConferenceHandler().peerconnection, 200, statsUpdated); - - stopLocalRtpStatsCollector(); - + getConferenceHandler().peerconnection, 200, audioLevelUpdated); statsCollector.start(); } } @@ -511,7 +498,7 @@ function startLocalRtpStatsCollector(stream) { if(config.enableRtpStats) { - localStatsCollector = new LocalStatsCollector(stream, 200, localStatsUpdated); + localStatsCollector = new LocalStatsCollector(stream, 100, audioLevelUpdated); localStatsCollector.start(); } } @@ -1123,11 +1110,7 @@ function disposeConference(onUnload) { handler.peerconnection.close(); } stopRTPStatsCollector(); - if(!onUnload) { - startLocalRtpStatsCollector(connection.jingle.localAudio); - } - else - { + if(onUnload) { stopLocalRtpStatsCollector(); } focus = null; diff --git a/local_stats.js b/local_stats.js index 6b2c44277..fbefa98c8 100644 --- a/local_stats.js +++ b/local_stats.js @@ -6,13 +6,13 @@ var LocalStatsCollector = (function() { * Size of the webaudio analizer buffer. * @type {number} */ - var WEBAUDIO_ANALIZER_FFT_SIZE = 512; + var WEBAUDIO_ANALIZER_FFT_SIZE = 2048; /** * Value of the webaudio analizer smoothing time parameter. * @type {number} */ - var WEBAUDIO_ANALIZER_SMOOTING_TIME = 0.1; + var WEBAUDIO_ANALIZER_SMOOTING_TIME = 0.8; /** * LocalStatsCollector calculates statistics for the local stream. @@ -54,12 +54,16 @@ var LocalStatsCollector = (function() { this.intervalId = setInterval( function () { var array = new Uint8Array(analyser.frequencyBinCount); - analyser.getByteFrequencyData(array); - self.audioLevel = FrequencyDataToAudioLevel(array); - self.updateCallback(self); + analyser.getByteTimeDomainData(array); + var audioLevel = TimeDomainDataToAudioLevel(array); + if(audioLevel != self.audioLevel) { + self.audioLevel = animateLevel(audioLevel, self.audioLevel); + self.updateCallback(LocalStatsCollectorProto.LOCAL_JID, self.audioLevel); + } }, this.intervalMilis ); + }; /** @@ -73,22 +77,55 @@ var LocalStatsCollector = (function() { }; /** - * Converts frequency data array to audio level. - * @param array the frequency data array. + * Converts time domain data array to audio level. + * @param array the time domain data array. * @returns {number} the audio level */ - var FrequencyDataToAudioLevel = function (array) { + var TimeDomainDataToAudioLevel = function (samples) { + var maxVolume = 0; - var length = array.length; + var length = samples.length; for (var i = 0; i < length; i++) { - if (maxVolume < array[i]) - maxVolume = array[i]; + if (maxVolume < samples[i]) + maxVolume = samples[i]; } - return maxVolume / 255; + return parseFloat(((maxVolume - 127) / 128).toFixed(3)); }; + /** + * Animates audio level change + * @param newLevel the new audio level + * @param lastLevel the last audio level + * @returns {Number} the audio level to be set + */ + function animateLevel(newLevel, lastLevel) + { + var value = 0; + var diff = lastLevel - newLevel; + if(diff > 0.2) + { + value = lastLevel - 0.2; + } + else if(diff < -0.4) + { + value = lastLevel + 0.4; + } + else + { + value = newLevel; + } + + return parseFloat(value.toFixed(3)); + } + + /** + * Indicates that this audio level is for local jid. + * @type {string} + */ + LocalStatsCollectorProto.LOCAL_JID = 'local'; + return LocalStatsCollectorProto; })(); \ No newline at end of file diff --git a/rtp_stats.js b/rtp_stats.js index 8bfdcbc38..dafc04832 100644 --- a/rtp_stats.js +++ b/rtp_stats.js @@ -213,6 +213,8 @@ StatsCollector.prototype.processReport = function () // but it seems to vary between 0 and around 32k. audioLevel = audioLevel / 32767; jidStats.setSsrcAudioLevel(ssrc, audioLevel); + if(jid != connection.emuc.myroomjid) + this.updateCallback(jid, audioLevel); } var key = 'packetsReceived'; @@ -281,7 +283,5 @@ StatsCollector.prototype.processReport = function () // bar indicator //console.info("Loss SMA3: " + outputAvg + " Q: " + quality); } - - self.updateCallback(self); }; From 0e7c1ed9a9f86a6516dabd281915a5f142d83da0 Mon Sep 17 00:00:00 2001 From: hristoterezov Date: Mon, 28 Jul 2014 14:31:32 +0300 Subject: [PATCH 052/107] Fixes the local audio levels when the user is muted. --- app.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app.js b/app.js index 9cacb7b37..f7ac1bd53 100644 --- a/app.js +++ b/app.js @@ -455,6 +455,8 @@ function audioLevelUpdated(jid, audioLevel) if(jid === LocalStatsCollector.LOCAL_JID) { resourceJid = AudioLevels.LOCAL_LEVEL; + if(isAudioMuted()) + return; } else { @@ -901,6 +903,20 @@ function toggleAudio() { buttonClick("#mute", "icon-microphone icon-mic-disabled"); } +/** + * Checks whether the audio is muted or not. + * @returns {boolean} true if audio is muted and false if not. + */ +function isAudioMuted() +{ + var localAudio = connection.jingle.localAudio; + for (var idx = 0; idx < localAudio.getAudioTracks().length; idx++) { + if(localAudio.getAudioTracks()[idx].enabled === true) + return false; + } + return true; +} + // Starts or stops the recording for the conference. function toggleRecording() { if (focus === null || focus.confid === null) { From 215ee92eb51f635c2a1cd5c1a58f92382e99bfc6 Mon Sep 17 00:00:00 2001 From: yanas Date: Mon, 28 Jul 2014 15:39:22 +0200 Subject: [PATCH 053/107] Adds rollover effect for focused videos. --- css/videolayout_default.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/css/videolayout_default.css b/css/videolayout_default.css index 145fcf616..3a33e0269 100644 --- a/css/videolayout_default.css +++ b/css/videolayout_default.css @@ -62,6 +62,11 @@ border: 2px solid #006d91; } +#remoteVideos .videocontainer.videoContainerFocused:hover { + -webkit-box-shadow: inset 0 0 5px #FFFFFF, 0 0 10px #FFFFFF, inset 0 0 60px #006d91; + border: 2px solid #FFFFFF; +} + #localVideoWrapper { display:inline-block; -webkit-mask-box-image: url(../images/videomask.svg); From 794296198c10e956b249ad390aa5fab53f1ffd94 Mon Sep 17 00:00:00 2001 From: Philipp Hancke Date: Wed, 30 Jul 2014 18:54:21 +0200 Subject: [PATCH 054/107] secret sauce GUM flags --- libs/strophe/strophe.jingle.adapter.js | 31 ++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/libs/strophe/strophe.jingle.adapter.js b/libs/strophe/strophe.jingle.adapter.js index 05c69bcd6..30eaff65e 100644 --- a/libs/strophe/strophe.jingle.adapter.js +++ b/libs/strophe/strophe.jingle.adapter.js @@ -513,7 +513,7 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res constraints.video = { mandatory: {}, optional: [] };// same behaviour as true } if (um.indexOf('audio') >= 0) { - constraints.audio = {};// same behaviour as true + constraints.audio = { mandatory: {}, optional: []};// same behaviour as true } if (um.indexOf('screen') >= 0) { constraints.video = { @@ -541,6 +541,29 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res } } + if (constraints.audio) { + // if it is good enough for hangouts... + constraints.audio.optional.push( + {googEchoCancellation: true}, + {googAutoGainControl: true}, + {googNoiseSupression: true}, + {googHighpassFilter: true}, + {googNoisesuppression2: true}, + {googEchoCancellation2: true}, + {googAutoGainControl2: true} + ); + } + if (constraints.video) { + constraints.video.optional.push( + {googNoiseReduction: true} + ); + if (um.indexOf('video') >= 0) { + constraints.video.optional.push( + {googLeakyBucket: true} + ); + } + } + // Check if we are running on Android device var isAndroid = navigator.userAgent.indexOf('Android') != -1; @@ -596,12 +619,12 @@ function getUserMediaWithConstraints(um, success_callback, failure_callback, res } if (bandwidth) { // doesn't work currently, see webrtc issue 1846 - if (!constraints.video) constraints.video = {mandatory: {}};//same behaviour as true - constraints.video.optional = [{bandwidth: bandwidth}]; + if (!constraints.video) constraints.video = {mandatory: {}, optional: []};//same behaviour as true + constraints.video.optional.push({bandwidth: bandwidth}); } if (fps) { // for some cameras it might be necessary to request 30fps // so they choose 30fps mjpg over 10fps yuy2 - if (!constraints.video) constraints.video = {mandatory: {}};// same behaviour as tru; + if (!constraints.video) constraints.video = {mandatory: {}, optional: []};// same behaviour as true; constraints.video.mandatory.minFrameRate = fps; } From 943f0d3db1318600a016cb897968aac612b7ca3b Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Thu, 31 Jul 2014 07:09:38 +0300 Subject: [PATCH 055/107] Adds a config option to enable rtcp-mux. --- config.js | 1 + libs/colibri/colibri.focus.js | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/config.js b/config.js index db9c3b195..1074b03c7 100644 --- a/config.js +++ b/config.js @@ -15,5 +15,6 @@ var config = { enableRtpStats: true, // Enables RTP stats processing openSctp: true, // Toggle to enable/disable SCTP channels // channelLastN: -1, // The default value of the channel attribute last-n. +// useRtcpMux: true, enableRecording: false }; diff --git a/libs/colibri/colibri.focus.js b/libs/colibri/colibri.focus.js index 20a55a53e..62c5b4bb9 100644 --- a/libs/colibri/colibri.focus.js +++ b/libs/colibri/colibri.focus.js @@ -303,6 +303,9 @@ ColibriFocus.prototype._makeConference = function () { elem.c(elemName, elemAttrs); elem.attrs({ endpoint: peer.substr(1 + peer.lastIndexOf('/')) }); + if ('channel' === elemName && config.useRtcpMux) { + elem.c('rtcp-mux').up(); + } elem.up(); // end of channel/sctpconnection } elem.up(); // end of content @@ -407,6 +410,7 @@ ColibriFocus.prototype.createdConference = function (result) { 'a=rtpmap:13 CN/8000\r\n' + 'a=rtpmap:126 telephone-event/8000\r\n' + 'a=maxptime:60\r\n' + + (config.useRtcpMux ? 'a=rtcp-mux\r\n' : '') + /* Video */ 'm=video 1 RTP/SAVPF 100 116 117\r\n' + 'c=IN IP4 0.0.0.0\r\n' + @@ -421,6 +425,7 @@ ColibriFocus.prototype.createdConference = function (result) { 'a=rtcp-fb:100 goog-remb\r\n' + 'a=rtpmap:116 red/90000\r\n' + 'a=rtpmap:117 ulpfec/90000\r\n' + + (config.useRtcpMux ? 'a=rtcp-mux\r\n' : '') + /* Data SCTP */ (config.openSctp ? 'm=application 1 DTLS/SCTP 5000\r\n' + @@ -617,7 +622,9 @@ ColibriFocus.prototype.initiate = function (peer, isInitiator) { sdp.removeSessionLines('a=group:'); sdp.removeSessionLines('a=msid-semantic:'); // FIXME: not mapped over jingle anyway... for (var i = 0; i < sdp.media.length; i++) { - sdp.removeMediaLines(i, 'a=rtcp-mux'); + if (!config.useRtcpMux){ + sdp.removeMediaLines(i, 'a=rtcp-mux'); + } sdp.removeMediaLines(i, 'a=ssrc:'); sdp.removeMediaLines(i, 'a=crypto:'); sdp.removeMediaLines(i, 'a=candidate:'); @@ -774,6 +781,9 @@ ColibriFocus.prototype.addNewParticipant = function (peer) { elem.c('content', { name: name }); elem.c(elemName, elemAttrs); + if ('channel' === elemName && config.useRtcpMux) { + elem.c('rtcp-mux').up(); + } elem.up(); // end of channel/sctpconnection elem.up(); // end of content }); @@ -821,6 +831,9 @@ ColibriFocus.prototype.updateChannel = function (remoteSDP, participant) { endpoint: $(this.channels[participant][channel]).attr('endpoint'), expire: self.channelExpire }); + if (config.useRtcpMux) { + change.c('rtcp-mux').up(); + } var rtpmap = SDPUtil.find_lines(remoteSDP.media[channel], 'a=rtpmap:'); rtpmap.forEach(function (val) { @@ -1031,6 +1044,10 @@ ColibriFocus.prototype.addIceCandidate = function (session, elem) { endpoint: $(self.channels[participant][channel]).attr('endpoint'), expire: self.channelExpire }); + + if (config.useRtcpMux) { + change.c('rtcp-mux').up(); + } } else { @@ -1114,6 +1131,10 @@ ColibriFocus.prototype.sendIceCandidates = function (candidates) { endpoint: $(this.mychannel[cands[0].sdpMLineIndex]).attr('endpoint'), expire: self.channelExpire }); + if (config.useRtcpMux) { + mycands.c('rtcp-mux').up(); + } + } else { From e62b2d129a3b10d1c77f4bf64045519d3fb3161d Mon Sep 17 00:00:00 2001 From: turint Date: Thu, 31 Jul 2014 14:58:57 +0300 Subject: [PATCH 056/107] Adds dependency on jitsi-meet-prosody. --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index b11ee6ede..7fc2e231e 100644 --- a/debian/control +++ b/debian/control @@ -10,7 +10,7 @@ Homepage: https://jitsi.org/meet Package: jitsi-meet Architecture: all Pre-Depends: adduser, openssl, jitsi-videobridge -Depends: ${shlibs:Depends}, ${misc:Depends}, nginx +Depends: ${shlibs:Depends}, ${misc:Depends}, nginx, jitsi-meet-prosody Description: WebRTC JavaScript video conferences Jitsi Meet is a WebRTC JavaScript application that uses Jitsi Videobridge to provide high quality, scalable video conferences. From e9374048d0258e09626173c4d62c41509bd84e52 Mon Sep 17 00:00:00 2001 From: Boris Grozev Date: Fri, 1 Aug 2014 05:02:30 +0300 Subject: [PATCH 057/107] Adds the jquery libs and uses the local versions (to avoid issues with filtering). --- index.html | 4 +- libs/jquery-ui.js | 15008 +++++++++++++++++++++++++++++++++++++++++++ libs/jquery.min.js | 6 + 3 files changed, 15016 insertions(+), 2 deletions(-) create mode 100644 libs/jquery-ui.js create mode 100644 libs/jquery.min.js diff --git a/index.html b/index.html index ef310d7a7..79d91363b 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ - + @@ -19,7 +19,7 @@ - + diff --git a/libs/jquery-ui.js b/libs/jquery-ui.js new file mode 100644 index 000000000..eb4ec7236 --- /dev/null +++ b/libs/jquery-ui.js @@ -0,0 +1,15008 @@ +/*! jQuery UI - v1.10.4 - 2014-01-17 +* http://jqueryui.com +* Includes: jquery.ui.core.js, jquery.ui.widget.js, jquery.ui.mouse.js, jquery.ui.position.js, jquery.ui.accordion.js, jquery.ui.autocomplete.js, jquery.ui.button.js, jquery.ui.datepicker.js, jquery.ui.dialog.js, jquery.ui.draggable.js, jquery.ui.droppable.js, jquery.ui.effect.js, jquery.ui.effect-blind.js, jquery.ui.effect-bounce.js, jquery.ui.effect-clip.js, jquery.ui.effect-drop.js, jquery.ui.effect-explode.js, jquery.ui.effect-fade.js, jquery.ui.effect-fold.js, jquery.ui.effect-highlight.js, jquery.ui.effect-pulsate.js, jquery.ui.effect-scale.js, jquery.ui.effect-shake.js, jquery.ui.effect-slide.js, jquery.ui.effect-transfer.js, jquery.ui.menu.js, jquery.ui.progressbar.js, jquery.ui.resizable.js, jquery.ui.selectable.js, jquery.ui.slider.js, jquery.ui.sortable.js, jquery.ui.spinner.js, jquery.ui.tabs.js, jquery.ui.tooltip.js +* Copyright 2014 jQuery Foundation and other contributors; Licensed MIT */ + +(function( $, undefined ) { + +var uuid = 0, + runiqueId = /^ui-id-\d+$/; + +// $.ui might exist from components with no dependencies, e.g., $.ui.position +$.ui = $.ui || {}; + +$.extend( $.ui, { + version: "1.10.4", + + keyCode: { + BACKSPACE: 8, + COMMA: 188, + DELETE: 46, + DOWN: 40, + END: 35, + ENTER: 13, + ESCAPE: 27, + HOME: 36, + LEFT: 37, + NUMPAD_ADD: 107, + NUMPAD_DECIMAL: 110, + NUMPAD_DIVIDE: 111, + NUMPAD_ENTER: 108, + NUMPAD_MULTIPLY: 106, + NUMPAD_SUBTRACT: 109, + PAGE_DOWN: 34, + PAGE_UP: 33, + PERIOD: 190, + RIGHT: 39, + SPACE: 32, + TAB: 9, + UP: 38 + } +}); + +// plugins +$.fn.extend({ + focus: (function( orig ) { + return function( delay, fn ) { + return typeof delay === "number" ? + this.each(function() { + var elem = this; + setTimeout(function() { + $( elem ).focus(); + if ( fn ) { + fn.call( elem ); + } + }, delay ); + }) : + orig.apply( this, arguments ); + }; + })( $.fn.focus ), + + scrollParent: function() { + var scrollParent; + if (($.ui.ie && (/(static|relative)/).test(this.css("position"))) || (/absolute/).test(this.css("position"))) { + scrollParent = this.parents().filter(function() { + return (/(relative|absolute|fixed)/).test($.css(this,"position")) && (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); + }).eq(0); + } else { + scrollParent = this.parents().filter(function() { + return (/(auto|scroll)/).test($.css(this,"overflow")+$.css(this,"overflow-y")+$.css(this,"overflow-x")); + }).eq(0); + } + + return (/fixed/).test(this.css("position")) || !scrollParent.length ? $(document) : scrollParent; + }, + + zIndex: function( zIndex ) { + if ( zIndex !== undefined ) { + return this.css( "zIndex", zIndex ); + } + + if ( this.length ) { + var elem = $( this[ 0 ] ), position, value; + while ( elem.length && elem[ 0 ] !== document ) { + // Ignore z-index if position is set to a value where z-index is ignored by the browser + // This makes behavior of this function consistent across browsers + // WebKit always returns auto if the element is positioned + position = elem.css( "position" ); + if ( position === "absolute" || position === "relative" || position === "fixed" ) { + // IE returns 0 when zIndex is not specified + // other browsers return a string + // we ignore the case of nested elements with an explicit value of 0 + //
+ value = parseInt( elem.css( "zIndex" ), 10 ); + if ( !isNaN( value ) && value !== 0 ) { + return value; + } + } + elem = elem.parent(); + } + } + + return 0; + }, + + uniqueId: function() { + return this.each(function() { + if ( !this.id ) { + this.id = "ui-id-" + (++uuid); + } + }); + }, + + removeUniqueId: function() { + return this.each(function() { + if ( runiqueId.test( this.id ) ) { + $( this ).removeAttr( "id" ); + } + }); + } +}); + +// selectors +function focusable( element, isTabIndexNotNaN ) { + var map, mapName, img, + nodeName = element.nodeName.toLowerCase(); + if ( "area" === nodeName ) { + map = element.parentNode; + mapName = map.name; + if ( !element.href || !mapName || map.nodeName.toLowerCase() !== "map" ) { + return false; + } + img = $( "img[usemap=#" + mapName + "]" )[0]; + return !!img && visible( img ); + } + return ( /input|select|textarea|button|object/.test( nodeName ) ? + !element.disabled : + "a" === nodeName ? + element.href || isTabIndexNotNaN : + isTabIndexNotNaN) && + // the element and all of its ancestors must be visible + visible( element ); +} + +function visible( element ) { + return $.expr.filters.visible( element ) && + !$( element ).parents().addBack().filter(function() { + return $.css( this, "visibility" ) === "hidden"; + }).length; +} + +$.extend( $.expr[ ":" ], { + data: $.expr.createPseudo ? + $.expr.createPseudo(function( dataName ) { + return function( elem ) { + return !!$.data( elem, dataName ); + }; + }) : + // support: jQuery <1.8 + function( elem, i, match ) { + return !!$.data( elem, match[ 3 ] ); + }, + + focusable: function( element ) { + return focusable( element, !isNaN( $.attr( element, "tabindex" ) ) ); + }, + + tabbable: function( element ) { + var tabIndex = $.attr( element, "tabindex" ), + isTabIndexNaN = isNaN( tabIndex ); + return ( isTabIndexNaN || tabIndex >= 0 ) && focusable( element, !isTabIndexNaN ); + } +}); + +// support: jQuery <1.8 +if ( !$( "" ).outerWidth( 1 ).jquery ) { + $.each( [ "Width", "Height" ], function( i, name ) { + var side = name === "Width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ], + type = name.toLowerCase(), + orig = { + innerWidth: $.fn.innerWidth, + innerHeight: $.fn.innerHeight, + outerWidth: $.fn.outerWidth, + outerHeight: $.fn.outerHeight + }; + + function reduce( elem, size, border, margin ) { + $.each( side, function() { + size -= parseFloat( $.css( elem, "padding" + this ) ) || 0; + if ( border ) { + size -= parseFloat( $.css( elem, "border" + this + "Width" ) ) || 0; + } + if ( margin ) { + size -= parseFloat( $.css( elem, "margin" + this ) ) || 0; + } + }); + return size; + } + + $.fn[ "inner" + name ] = function( size ) { + if ( size === undefined ) { + return orig[ "inner" + name ].call( this ); + } + + return this.each(function() { + $( this ).css( type, reduce( this, size ) + "px" ); + }); + }; + + $.fn[ "outer" + name] = function( size, margin ) { + if ( typeof size !== "number" ) { + return orig[ "outer" + name ].call( this, size ); + } + + return this.each(function() { + $( this).css( type, reduce( this, size, true, margin ) + "px" ); + }); + }; + }); +} + +// support: jQuery <1.8 +if ( !$.fn.addBack ) { + $.fn.addBack = function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter( selector ) + ); + }; +} + +// support: jQuery 1.6.1, 1.6.2 (http://bugs.jquery.com/ticket/9413) +if ( $( "" ).data( "a-b", "a" ).removeData( "a-b" ).data( "a-b" ) ) { + $.fn.removeData = (function( removeData ) { + return function( key ) { + if ( arguments.length ) { + return removeData.call( this, $.camelCase( key ) ); + } else { + return removeData.call( this ); + } + }; + })( $.fn.removeData ); +} + + + + + +// deprecated +$.ui.ie = !!/msie [\w.]+/.exec( navigator.userAgent.toLowerCase() ); + +$.support.selectstart = "onselectstart" in document.createElement( "div" ); +$.fn.extend({ + disableSelection: function() { + return this.bind( ( $.support.selectstart ? "selectstart" : "mousedown" ) + + ".ui-disableSelection", function( event ) { + event.preventDefault(); + }); + }, + + enableSelection: function() { + return this.unbind( ".ui-disableSelection" ); + } +}); + +$.extend( $.ui, { + // $.ui.plugin is deprecated. Use $.widget() extensions instead. + plugin: { + add: function( module, option, set ) { + var i, + proto = $.ui[ module ].prototype; + for ( i in set ) { + proto.plugins[ i ] = proto.plugins[ i ] || []; + proto.plugins[ i ].push( [ option, set[ i ] ] ); + } + }, + call: function( instance, name, args ) { + var i, + set = instance.plugins[ name ]; + if ( !set || !instance.element[ 0 ].parentNode || instance.element[ 0 ].parentNode.nodeType === 11 ) { + return; + } + + for ( i = 0; i < set.length; i++ ) { + if ( instance.options[ set[ i ][ 0 ] ] ) { + set[ i ][ 1 ].apply( instance.element, args ); + } + } + } + }, + + // only used by resizable + hasScroll: function( el, a ) { + + //If overflow is hidden, the element might have extra content, but the user wants to hide it + if ( $( el ).css( "overflow" ) === "hidden") { + return false; + } + + var scroll = ( a && a === "left" ) ? "scrollLeft" : "scrollTop", + has = false; + + if ( el[ scroll ] > 0 ) { + return true; + } + + // TODO: determine which cases actually cause this to happen + // if the element doesn't have the scroll set, see if it's possible to + // set the scroll + el[ scroll ] = 1; + has = ( el[ scroll ] > 0 ); + el[ scroll ] = 0; + return has; + } +}); + +})( jQuery ); +(function( $, undefined ) { + +var uuid = 0, + slice = Array.prototype.slice, + _cleanData = $.cleanData; +$.cleanData = function( elems ) { + for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { + try { + $( elem ).triggerHandler( "remove" ); + // http://bugs.jquery.com/ticket/8235 + } catch( e ) {} + } + _cleanData( elems ); +}; + +$.widget = function( name, base, prototype ) { + var fullName, existingConstructor, constructor, basePrototype, + // proxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + proxiedPrototype = {}, + namespace = name.split( "." )[ 0 ]; + + name = name.split( "." )[ 1 ]; + fullName = namespace + "-" + name; + + if ( !prototype ) { + prototype = base; + base = $.Widget; + } + + // create selector for plugin + $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { + return !!$.data( elem, fullName ); + }; + + $[ namespace ] = $[ namespace ] || {}; + existingConstructor = $[ namespace ][ name ]; + constructor = $[ namespace ][ name ] = function( options, element ) { + // allow instantiation without "new" keyword + if ( !this._createWidget ) { + return new constructor( options, element ); + } + + // allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) + if ( arguments.length ) { + this._createWidget( options, element ); + } + }; + // extend with the existing constructor to carry over any static properties + $.extend( constructor, existingConstructor, { + version: prototype.version, + // copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend( {}, prototype ), + // track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + }); + + basePrototype = new base(); + // we need to make the options hash a property directly on the new instance + // otherwise we'll modify the options hash on the prototype that we're + // inheriting from + basePrototype.options = $.widget.extend( {}, basePrototype.options ); + $.each( prototype, function( prop, value ) { + if ( !$.isFunction( value ) ) { + proxiedPrototype[ prop ] = value; + return; + } + proxiedPrototype[ prop ] = (function() { + var _super = function() { + return base.prototype[ prop ].apply( this, arguments ); + }, + _superApply = function( args ) { + return base.prototype[ prop ].apply( this, args ); + }; + return function() { + var __super = this._super, + __superApply = this._superApply, + returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply( this, arguments ); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + })(); + }); + constructor.prototype = $.widget.extend( basePrototype, { + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor ? (basePrototype.widgetEventPrefix || name) : name + }, proxiedPrototype, { + constructor: constructor, + namespace: namespace, + widgetName: name, + widgetFullName: fullName + }); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if ( existingConstructor ) { + $.each( existingConstructor._childConstructors, function( i, child ) { + var childPrototype = child.prototype; + + // redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto ); + }); + // remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; + } else { + base._childConstructors.push( constructor ); + } + + $.widget.bridge( name, constructor ); +}; + +$.widget.extend = function( target ) { + var input = slice.call( arguments, 1 ), + inputIndex = 0, + inputLength = input.length, + key, + value; + for ( ; inputIndex < inputLength; inputIndex++ ) { + for ( key in input[ inputIndex ] ) { + value = input[ inputIndex ][ key ]; + if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { + // Clone objects + if ( $.isPlainObject( value ) ) { + target[ key ] = $.isPlainObject( target[ key ] ) ? + $.widget.extend( {}, target[ key ], value ) : + // Don't extend strings, arrays, etc. with objects + $.widget.extend( {}, value ); + // Copy everything else by reference + } else { + target[ key ] = value; + } + } + } + } + return target; +}; + +$.widget.bridge = function( name, object ) { + var fullName = object.prototype.widgetFullName || name; + $.fn[ name ] = function( options ) { + var isMethodCall = typeof options === "string", + args = slice.call( arguments, 1 ), + returnValue = this; + + // allow multiple hashes to be passed on init + options = !isMethodCall && args.length ? + $.widget.extend.apply( null, [ options ].concat(args) ) : + options; + + if ( isMethodCall ) { + this.each(function() { + var methodValue, + instance = $.data( this, fullName ); + if ( !instance ) { + return $.error( "cannot call methods on " + name + " prior to initialization; " + + "attempted to call method '" + options + "'" ); + } + if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) { + return $.error( "no such method '" + options + "' for " + name + " widget instance" ); + } + methodValue = instance[ options ].apply( instance, args ); + if ( methodValue !== instance && methodValue !== undefined ) { + returnValue = methodValue && methodValue.jquery ? + returnValue.pushStack( methodValue.get() ) : + methodValue; + return false; + } + }); + } else { + this.each(function() { + var instance = $.data( this, fullName ); + if ( instance ) { + instance.option( options || {} )._init(); + } else { + $.data( this, fullName, new object( options, this ) ); + } + }); + } + + return returnValue; + }; +}; + +$.Widget = function( /* options, element */ ) {}; +$.Widget._childConstructors = []; + +$.Widget.prototype = { + widgetName: "widget", + widgetEventPrefix: "", + defaultElement: "
", + options: { + disabled: false, + + // callbacks + create: null + }, + _createWidget: function( options, element ) { + element = $( element || this.defaultElement || this )[ 0 ]; + this.element = $( element ); + this.uuid = uuid++; + this.eventNamespace = "." + this.widgetName + this.uuid; + this.options = $.widget.extend( {}, + this.options, + this._getCreateOptions(), + options ); + + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + + if ( element !== this ) { + $.data( element, this.widgetFullName, this ); + this._on( true, this.element, { + remove: function( event ) { + if ( event.target === element ) { + this.destroy(); + } + } + }); + this.document = $( element.style ? + // element within the document + element.ownerDocument : + // element is window or document + element.document || element ); + this.window = $( this.document[0].defaultView || this.document[0].parentWindow ); + } + + this._create(); + this._trigger( "create", null, this._getCreateEventData() ); + this._init(); + }, + _getCreateOptions: $.noop, + _getCreateEventData: $.noop, + _create: $.noop, + _init: $.noop, + + destroy: function() { + this._destroy(); + // we can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() + this.element + .unbind( this.eventNamespace ) + // 1.9 BC for #7810 + // TODO remove dual storage + .removeData( this.widgetName ) + .removeData( this.widgetFullName ) + // support: jquery <1.6.3 + // http://bugs.jquery.com/ticket/9413 + .removeData( $.camelCase( this.widgetFullName ) ); + this.widget() + .unbind( this.eventNamespace ) + .removeAttr( "aria-disabled" ) + .removeClass( + this.widgetFullName + "-disabled " + + "ui-state-disabled" ); + + // clean up events and states + this.bindings.unbind( this.eventNamespace ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); + }, + _destroy: $.noop, + + widget: function() { + return this.element; + }, + + option: function( key, value ) { + var options = key, + parts, + curOption, + i; + + if ( arguments.length === 0 ) { + // don't return a reference to the internal hash + return $.widget.extend( {}, this.options ); + } + + if ( typeof key === "string" ) { + // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } + options = {}; + parts = key.split( "." ); + key = parts.shift(); + if ( parts.length ) { + curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); + for ( i = 0; i < parts.length - 1; i++ ) { + curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; + curOption = curOption[ parts[ i ] ]; + } + key = parts.pop(); + if ( arguments.length === 1 ) { + return curOption[ key ] === undefined ? null : curOption[ key ]; + } + curOption[ key ] = value; + } else { + if ( arguments.length === 1 ) { + return this.options[ key ] === undefined ? null : this.options[ key ]; + } + options[ key ] = value; + } + } + + this._setOptions( options ); + + return this; + }, + _setOptions: function( options ) { + var key; + + for ( key in options ) { + this._setOption( key, options[ key ] ); + } + + return this; + }, + _setOption: function( key, value ) { + this.options[ key ] = value; + + if ( key === "disabled" ) { + this.widget() + .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value ) + .attr( "aria-disabled", value ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); + } + + return this; + }, + + enable: function() { + return this._setOption( "disabled", false ); + }, + disable: function() { + return this._setOption( "disabled", true ); + }, + + _on: function( suppressDisabledCheck, element, handlers ) { + var delegateElement, + instance = this; + + // no suppressDisabledCheck flag, shuffle arguments + if ( typeof suppressDisabledCheck !== "boolean" ) { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // no element argument, shuffle and use this.element + if ( !handlers ) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + // accept selectors, DOM elements + element = delegateElement = $( element ); + this.bindings = this.bindings.add( element ); + } + + $.each( handlers, function( event, handler ) { + function handlerProxy() { + // allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( !suppressDisabledCheck && + ( instance.options.disabled === true || + $( this ).hasClass( "ui-state-disabled" ) ) ) { + return; + } + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + + // copy the guid so direct unbinding works + if ( typeof handler !== "string" ) { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match( /^(\w+)\s*(.*)$/ ), + eventName = match[1] + instance.eventNamespace, + selector = match[2]; + if ( selector ) { + delegateElement.delegate( selector, eventName, handlerProxy ); + } else { + element.bind( eventName, handlerProxy ); + } + }); + }, + + _off: function( element, eventName ) { + eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace; + element.unbind( eventName ).undelegate( eventName ); + }, + + _delay: function( handler, delay ) { + function handlerProxy() { + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + var instance = this; + return setTimeout( handlerProxy, delay || 0 ); + }, + + _hoverable: function( element ) { + this.hoverable = this.hoverable.add( element ); + this._on( element, { + mouseenter: function( event ) { + $( event.currentTarget ).addClass( "ui-state-hover" ); + }, + mouseleave: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-hover" ); + } + }); + }, + + _focusable: function( element ) { + this.focusable = this.focusable.add( element ); + this._on( element, { + focusin: function( event ) { + $( event.currentTarget ).addClass( "ui-state-focus" ); + }, + focusout: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-focus" ); + } + }); + }, + + _trigger: function( type, event, data ) { + var prop, orig, + callback = this.options[ type ]; + + data = data || {}; + event = $.Event( event ); + event.type = ( type === this.widgetEventPrefix ? + type : + this.widgetEventPrefix + type ).toLowerCase(); + // the original event may come from any element + // so we need to reset the target on the new event + event.target = this.element[ 0 ]; + + // copy original event properties over to the new event + orig = event.originalEvent; + if ( orig ) { + for ( prop in orig ) { + if ( !( prop in event ) ) { + event[ prop ] = orig[ prop ]; + } + } + } + + this.element.trigger( event, data ); + return !( $.isFunction( callback ) && + callback.apply( this.element[0], [ event ].concat( data ) ) === false || + event.isDefaultPrevented() ); + } +}; + +$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { + $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { + if ( typeof options === "string" ) { + options = { effect: options }; + } + var hasOptions, + effectName = !options ? + method : + options === true || typeof options === "number" ? + defaultEffect : + options.effect || defaultEffect; + options = options || {}; + if ( typeof options === "number" ) { + options = { duration: options }; + } + hasOptions = !$.isEmptyObject( options ); + options.complete = callback; + if ( options.delay ) { + element.delay( options.delay ); + } + if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { + element[ method ]( options ); + } else if ( effectName !== method && element[ effectName ] ) { + element[ effectName ]( options.duration, options.easing, callback ); + } else { + element.queue(function( next ) { + $( this )[ method ](); + if ( callback ) { + callback.call( element[ 0 ] ); + } + next(); + }); + } + }; +}); + +})( jQuery ); +(function( $, undefined ) { + +var mouseHandled = false; +$( document ).mouseup( function() { + mouseHandled = false; +}); + +$.widget("ui.mouse", { + version: "1.10.4", + options: { + cancel: "input,textarea,button,select,option", + distance: 1, + delay: 0 + }, + _mouseInit: function() { + var that = this; + + this.element + .bind("mousedown."+this.widgetName, function(event) { + return that._mouseDown(event); + }) + .bind("click."+this.widgetName, function(event) { + if (true === $.data(event.target, that.widgetName + ".preventClickEvent")) { + $.removeData(event.target, that.widgetName + ".preventClickEvent"); + event.stopImmediatePropagation(); + return false; + } + }); + + this.started = false; + }, + + // TODO: make sure destroying one instance of mouse doesn't mess with + // other instances of mouse + _mouseDestroy: function() { + this.element.unbind("."+this.widgetName); + if ( this._mouseMoveDelegate ) { + $(document) + .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + } + }, + + _mouseDown: function(event) { + // don't let more than one widget handle mouseStart + if( mouseHandled ) { return; } + + // we may have missed mouseup (out of window) + (this._mouseStarted && this._mouseUp(event)); + + this._mouseDownEvent = event; + + var that = this, + btnIsLeft = (event.which === 1), + // event.target.nodeName works around a bug in IE 8 with + // disabled inputs (#7620) + elIsCancel = (typeof this.options.cancel === "string" && event.target.nodeName ? $(event.target).closest(this.options.cancel).length : false); + if (!btnIsLeft || elIsCancel || !this._mouseCapture(event)) { + return true; + } + + this.mouseDelayMet = !this.options.delay; + if (!this.mouseDelayMet) { + this._mouseDelayTimer = setTimeout(function() { + that.mouseDelayMet = true; + }, this.options.delay); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = (this._mouseStart(event) !== false); + if (!this._mouseStarted) { + event.preventDefault(); + return true; + } + } + + // Click event may never have fired (Gecko & Opera) + if (true === $.data(event.target, this.widgetName + ".preventClickEvent")) { + $.removeData(event.target, this.widgetName + ".preventClickEvent"); + } + + // these delegates are required to keep context + this._mouseMoveDelegate = function(event) { + return that._mouseMove(event); + }; + this._mouseUpDelegate = function(event) { + return that._mouseUp(event); + }; + $(document) + .bind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .bind("mouseup."+this.widgetName, this._mouseUpDelegate); + + event.preventDefault(); + + mouseHandled = true; + return true; + }, + + _mouseMove: function(event) { + // IE mouseup check - mouseup happened when mouse was out of window + if ($.ui.ie && ( !document.documentMode || document.documentMode < 9 ) && !event.button) { + return this._mouseUp(event); + } + + if (this._mouseStarted) { + this._mouseDrag(event); + return event.preventDefault(); + } + + if (this._mouseDistanceMet(event) && this._mouseDelayMet(event)) { + this._mouseStarted = + (this._mouseStart(this._mouseDownEvent, event) !== false); + (this._mouseStarted ? this._mouseDrag(event) : this._mouseUp(event)); + } + + return !this._mouseStarted; + }, + + _mouseUp: function(event) { + $(document) + .unbind("mousemove."+this.widgetName, this._mouseMoveDelegate) + .unbind("mouseup."+this.widgetName, this._mouseUpDelegate); + + if (this._mouseStarted) { + this._mouseStarted = false; + + if (event.target === this._mouseDownEvent.target) { + $.data(event.target, this.widgetName + ".preventClickEvent", true); + } + + this._mouseStop(event); + } + + return false; + }, + + _mouseDistanceMet: function(event) { + return (Math.max( + Math.abs(this._mouseDownEvent.pageX - event.pageX), + Math.abs(this._mouseDownEvent.pageY - event.pageY) + ) >= this.options.distance + ); + }, + + _mouseDelayMet: function(/* event */) { + return this.mouseDelayMet; + }, + + // These are placeholder methods, to be overriden by extending plugin + _mouseStart: function(/* event */) {}, + _mouseDrag: function(/* event */) {}, + _mouseStop: function(/* event */) {}, + _mouseCapture: function(/* event */) { return true; } +}); + +})(jQuery); +(function( $, undefined ) { + +$.ui = $.ui || {}; + +var cachedScrollbarWidth, + max = Math.max, + abs = Math.abs, + round = Math.round, + rhorizontal = /left|center|right/, + rvertical = /top|center|bottom/, + roffset = /[\+\-]\d+(\.[\d]+)?%?/, + rposition = /^\w+/, + rpercent = /%$/, + _position = $.fn.position; + +function getOffsets( offsets, width, height ) { + return [ + parseFloat( offsets[ 0 ] ) * ( rpercent.test( offsets[ 0 ] ) ? width / 100 : 1 ), + parseFloat( offsets[ 1 ] ) * ( rpercent.test( offsets[ 1 ] ) ? height / 100 : 1 ) + ]; +} + +function parseCss( element, property ) { + return parseInt( $.css( element, property ), 10 ) || 0; +} + +function getDimensions( elem ) { + var raw = elem[0]; + if ( raw.nodeType === 9 ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: 0, left: 0 } + }; + } + if ( $.isWindow( raw ) ) { + return { + width: elem.width(), + height: elem.height(), + offset: { top: elem.scrollTop(), left: elem.scrollLeft() } + }; + } + if ( raw.preventDefault ) { + return { + width: 0, + height: 0, + offset: { top: raw.pageY, left: raw.pageX } + }; + } + return { + width: elem.outerWidth(), + height: elem.outerHeight(), + offset: elem.offset() + }; +} + +$.position = { + scrollbarWidth: function() { + if ( cachedScrollbarWidth !== undefined ) { + return cachedScrollbarWidth; + } + var w1, w2, + div = $( "
" ), + innerDiv = div.children()[0]; + + $( "body" ).append( div ); + w1 = innerDiv.offsetWidth; + div.css( "overflow", "scroll" ); + + w2 = innerDiv.offsetWidth; + + if ( w1 === w2 ) { + w2 = div[0].clientWidth; + } + + div.remove(); + + return (cachedScrollbarWidth = w1 - w2); + }, + getScrollInfo: function( within ) { + var overflowX = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-x" ), + overflowY = within.isWindow || within.isDocument ? "" : + within.element.css( "overflow-y" ), + hasOverflowX = overflowX === "scroll" || + ( overflowX === "auto" && within.width < within.element[0].scrollWidth ), + hasOverflowY = overflowY === "scroll" || + ( overflowY === "auto" && within.height < within.element[0].scrollHeight ); + return { + width: hasOverflowY ? $.position.scrollbarWidth() : 0, + height: hasOverflowX ? $.position.scrollbarWidth() : 0 + }; + }, + getWithinInfo: function( element ) { + var withinElement = $( element || window ), + isWindow = $.isWindow( withinElement[0] ), + isDocument = !!withinElement[ 0 ] && withinElement[ 0 ].nodeType === 9; + return { + element: withinElement, + isWindow: isWindow, + isDocument: isDocument, + offset: withinElement.offset() || { left: 0, top: 0 }, + scrollLeft: withinElement.scrollLeft(), + scrollTop: withinElement.scrollTop(), + width: isWindow ? withinElement.width() : withinElement.outerWidth(), + height: isWindow ? withinElement.height() : withinElement.outerHeight() + }; + } +}; + +$.fn.position = function( options ) { + if ( !options || !options.of ) { + return _position.apply( this, arguments ); + } + + // make a copy, we don't want to modify arguments + options = $.extend( {}, options ); + + var atOffset, targetWidth, targetHeight, targetOffset, basePosition, dimensions, + target = $( options.of ), + within = $.position.getWithinInfo( options.within ), + scrollInfo = $.position.getScrollInfo( within ), + collision = ( options.collision || "flip" ).split( " " ), + offsets = {}; + + dimensions = getDimensions( target ); + if ( target[0].preventDefault ) { + // force left top to allow flipping + options.at = "left top"; + } + targetWidth = dimensions.width; + targetHeight = dimensions.height; + targetOffset = dimensions.offset; + // clone to reuse original targetOffset later + basePosition = $.extend( {}, targetOffset ); + + // force my and at to have valid horizontal and vertical positions + // if a value is missing or invalid, it will be converted to center + $.each( [ "my", "at" ], function() { + var pos = ( options[ this ] || "" ).split( " " ), + horizontalOffset, + verticalOffset; + + if ( pos.length === 1) { + pos = rhorizontal.test( pos[ 0 ] ) ? + pos.concat( [ "center" ] ) : + rvertical.test( pos[ 0 ] ) ? + [ "center" ].concat( pos ) : + [ "center", "center" ]; + } + pos[ 0 ] = rhorizontal.test( pos[ 0 ] ) ? pos[ 0 ] : "center"; + pos[ 1 ] = rvertical.test( pos[ 1 ] ) ? pos[ 1 ] : "center"; + + // calculate offsets + horizontalOffset = roffset.exec( pos[ 0 ] ); + verticalOffset = roffset.exec( pos[ 1 ] ); + offsets[ this ] = [ + horizontalOffset ? horizontalOffset[ 0 ] : 0, + verticalOffset ? verticalOffset[ 0 ] : 0 + ]; + + // reduce to just the positions without the offsets + options[ this ] = [ + rposition.exec( pos[ 0 ] )[ 0 ], + rposition.exec( pos[ 1 ] )[ 0 ] + ]; + }); + + // normalize collision option + if ( collision.length === 1 ) { + collision[ 1 ] = collision[ 0 ]; + } + + if ( options.at[ 0 ] === "right" ) { + basePosition.left += targetWidth; + } else if ( options.at[ 0 ] === "center" ) { + basePosition.left += targetWidth / 2; + } + + if ( options.at[ 1 ] === "bottom" ) { + basePosition.top += targetHeight; + } else if ( options.at[ 1 ] === "center" ) { + basePosition.top += targetHeight / 2; + } + + atOffset = getOffsets( offsets.at, targetWidth, targetHeight ); + basePosition.left += atOffset[ 0 ]; + basePosition.top += atOffset[ 1 ]; + + return this.each(function() { + var collisionPosition, using, + elem = $( this ), + elemWidth = elem.outerWidth(), + elemHeight = elem.outerHeight(), + marginLeft = parseCss( this, "marginLeft" ), + marginTop = parseCss( this, "marginTop" ), + collisionWidth = elemWidth + marginLeft + parseCss( this, "marginRight" ) + scrollInfo.width, + collisionHeight = elemHeight + marginTop + parseCss( this, "marginBottom" ) + scrollInfo.height, + position = $.extend( {}, basePosition ), + myOffset = getOffsets( offsets.my, elem.outerWidth(), elem.outerHeight() ); + + if ( options.my[ 0 ] === "right" ) { + position.left -= elemWidth; + } else if ( options.my[ 0 ] === "center" ) { + position.left -= elemWidth / 2; + } + + if ( options.my[ 1 ] === "bottom" ) { + position.top -= elemHeight; + } else if ( options.my[ 1 ] === "center" ) { + position.top -= elemHeight / 2; + } + + position.left += myOffset[ 0 ]; + position.top += myOffset[ 1 ]; + + // if the browser doesn't support fractions, then round for consistent results + if ( !$.support.offsetFractions ) { + position.left = round( position.left ); + position.top = round( position.top ); + } + + collisionPosition = { + marginLeft: marginLeft, + marginTop: marginTop + }; + + $.each( [ "left", "top" ], function( i, dir ) { + if ( $.ui.position[ collision[ i ] ] ) { + $.ui.position[ collision[ i ] ][ dir ]( position, { + targetWidth: targetWidth, + targetHeight: targetHeight, + elemWidth: elemWidth, + elemHeight: elemHeight, + collisionPosition: collisionPosition, + collisionWidth: collisionWidth, + collisionHeight: collisionHeight, + offset: [ atOffset[ 0 ] + myOffset[ 0 ], atOffset [ 1 ] + myOffset[ 1 ] ], + my: options.my, + at: options.at, + within: within, + elem : elem + }); + } + }); + + if ( options.using ) { + // adds feedback as second argument to using callback, if present + using = function( props ) { + var left = targetOffset.left - position.left, + right = left + targetWidth - elemWidth, + top = targetOffset.top - position.top, + bottom = top + targetHeight - elemHeight, + feedback = { + target: { + element: target, + left: targetOffset.left, + top: targetOffset.top, + width: targetWidth, + height: targetHeight + }, + element: { + element: elem, + left: position.left, + top: position.top, + width: elemWidth, + height: elemHeight + }, + horizontal: right < 0 ? "left" : left > 0 ? "right" : "center", + vertical: bottom < 0 ? "top" : top > 0 ? "bottom" : "middle" + }; + if ( targetWidth < elemWidth && abs( left + right ) < targetWidth ) { + feedback.horizontal = "center"; + } + if ( targetHeight < elemHeight && abs( top + bottom ) < targetHeight ) { + feedback.vertical = "middle"; + } + if ( max( abs( left ), abs( right ) ) > max( abs( top ), abs( bottom ) ) ) { + feedback.important = "horizontal"; + } else { + feedback.important = "vertical"; + } + options.using.call( this, props, feedback ); + }; + } + + elem.offset( $.extend( position, { using: using } ) ); + }); +}; + +$.ui.position = { + fit: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollLeft : within.offset.left, + outerWidth = within.width, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = withinOffset - collisionPosLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - withinOffset, + newOverRight; + + // element is wider than within + if ( data.collisionWidth > outerWidth ) { + // element is initially over the left side of within + if ( overLeft > 0 && overRight <= 0 ) { + newOverRight = position.left + overLeft + data.collisionWidth - outerWidth - withinOffset; + position.left += overLeft - newOverRight; + // element is initially over right side of within + } else if ( overRight > 0 && overLeft <= 0 ) { + position.left = withinOffset; + // element is initially over both left and right sides of within + } else { + if ( overLeft > overRight ) { + position.left = withinOffset + outerWidth - data.collisionWidth; + } else { + position.left = withinOffset; + } + } + // too far left -> align with left edge + } else if ( overLeft > 0 ) { + position.left += overLeft; + // too far right -> align with right edge + } else if ( overRight > 0 ) { + position.left -= overRight; + // adjust based on position and margin + } else { + position.left = max( position.left - collisionPosLeft, position.left ); + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.isWindow ? within.scrollTop : within.offset.top, + outerHeight = data.within.height, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = withinOffset - collisionPosTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - withinOffset, + newOverBottom; + + // element is taller than within + if ( data.collisionHeight > outerHeight ) { + // element is initially over the top of within + if ( overTop > 0 && overBottom <= 0 ) { + newOverBottom = position.top + overTop + data.collisionHeight - outerHeight - withinOffset; + position.top += overTop - newOverBottom; + // element is initially over bottom of within + } else if ( overBottom > 0 && overTop <= 0 ) { + position.top = withinOffset; + // element is initially over both top and bottom of within + } else { + if ( overTop > overBottom ) { + position.top = withinOffset + outerHeight - data.collisionHeight; + } else { + position.top = withinOffset; + } + } + // too far up -> align with top + } else if ( overTop > 0 ) { + position.top += overTop; + // too far down -> align with bottom edge + } else if ( overBottom > 0 ) { + position.top -= overBottom; + // adjust based on position and margin + } else { + position.top = max( position.top - collisionPosTop, position.top ); + } + } + }, + flip: { + left: function( position, data ) { + var within = data.within, + withinOffset = within.offset.left + within.scrollLeft, + outerWidth = within.width, + offsetLeft = within.isWindow ? within.scrollLeft : within.offset.left, + collisionPosLeft = position.left - data.collisionPosition.marginLeft, + overLeft = collisionPosLeft - offsetLeft, + overRight = collisionPosLeft + data.collisionWidth - outerWidth - offsetLeft, + myOffset = data.my[ 0 ] === "left" ? + -data.elemWidth : + data.my[ 0 ] === "right" ? + data.elemWidth : + 0, + atOffset = data.at[ 0 ] === "left" ? + data.targetWidth : + data.at[ 0 ] === "right" ? + -data.targetWidth : + 0, + offset = -2 * data.offset[ 0 ], + newOverRight, + newOverLeft; + + if ( overLeft < 0 ) { + newOverRight = position.left + myOffset + atOffset + offset + data.collisionWidth - outerWidth - withinOffset; + if ( newOverRight < 0 || newOverRight < abs( overLeft ) ) { + position.left += myOffset + atOffset + offset; + } + } + else if ( overRight > 0 ) { + newOverLeft = position.left - data.collisionPosition.marginLeft + myOffset + atOffset + offset - offsetLeft; + if ( newOverLeft > 0 || abs( newOverLeft ) < overRight ) { + position.left += myOffset + atOffset + offset; + } + } + }, + top: function( position, data ) { + var within = data.within, + withinOffset = within.offset.top + within.scrollTop, + outerHeight = within.height, + offsetTop = within.isWindow ? within.scrollTop : within.offset.top, + collisionPosTop = position.top - data.collisionPosition.marginTop, + overTop = collisionPosTop - offsetTop, + overBottom = collisionPosTop + data.collisionHeight - outerHeight - offsetTop, + top = data.my[ 1 ] === "top", + myOffset = top ? + -data.elemHeight : + data.my[ 1 ] === "bottom" ? + data.elemHeight : + 0, + atOffset = data.at[ 1 ] === "top" ? + data.targetHeight : + data.at[ 1 ] === "bottom" ? + -data.targetHeight : + 0, + offset = -2 * data.offset[ 1 ], + newOverTop, + newOverBottom; + if ( overTop < 0 ) { + newOverBottom = position.top + myOffset + atOffset + offset + data.collisionHeight - outerHeight - withinOffset; + if ( ( position.top + myOffset + atOffset + offset) > overTop && ( newOverBottom < 0 || newOverBottom < abs( overTop ) ) ) { + position.top += myOffset + atOffset + offset; + } + } + else if ( overBottom > 0 ) { + newOverTop = position.top - data.collisionPosition.marginTop + myOffset + atOffset + offset - offsetTop; + if ( ( position.top + myOffset + atOffset + offset) > overBottom && ( newOverTop > 0 || abs( newOverTop ) < overBottom ) ) { + position.top += myOffset + atOffset + offset; + } + } + } + }, + flipfit: { + left: function() { + $.ui.position.flip.left.apply( this, arguments ); + $.ui.position.fit.left.apply( this, arguments ); + }, + top: function() { + $.ui.position.flip.top.apply( this, arguments ); + $.ui.position.fit.top.apply( this, arguments ); + } + } +}; + +// fraction support test +(function () { + var testElement, testElementParent, testElementStyle, offsetLeft, i, + body = document.getElementsByTagName( "body" )[ 0 ], + div = document.createElement( "div" ); + + //Create a "fake body" for testing based on method used in jQuery.support + testElement = document.createElement( body ? "div" : "body" ); + testElementStyle = { + visibility: "hidden", + width: 0, + height: 0, + border: 0, + margin: 0, + background: "none" + }; + if ( body ) { + $.extend( testElementStyle, { + position: "absolute", + left: "-1000px", + top: "-1000px" + }); + } + for ( i in testElementStyle ) { + testElement.style[ i ] = testElementStyle[ i ]; + } + testElement.appendChild( div ); + testElementParent = body || document.documentElement; + testElementParent.insertBefore( testElement, testElementParent.firstChild ); + + div.style.cssText = "position: absolute; left: 10.7432222px;"; + + offsetLeft = $( div ).offset().left; + $.support.offsetFractions = offsetLeft > 10 && offsetLeft < 11; + + testElement.innerHTML = ""; + testElementParent.removeChild( testElement ); +})(); + +}( jQuery ) ); +(function( $, undefined ) { + +var uid = 0, + hideProps = {}, + showProps = {}; + +hideProps.height = hideProps.paddingTop = hideProps.paddingBottom = + hideProps.borderTopWidth = hideProps.borderBottomWidth = "hide"; +showProps.height = showProps.paddingTop = showProps.paddingBottom = + showProps.borderTopWidth = showProps.borderBottomWidth = "show"; + +$.widget( "ui.accordion", { + version: "1.10.4", + options: { + active: 0, + animate: {}, + collapsible: false, + event: "click", + header: "> li > :first-child,> :not(li):even", + heightStyle: "auto", + icons: { + activeHeader: "ui-icon-triangle-1-s", + header: "ui-icon-triangle-1-e" + }, + + // callbacks + activate: null, + beforeActivate: null + }, + + _create: function() { + var options = this.options; + this.prevShow = this.prevHide = $(); + this.element.addClass( "ui-accordion ui-widget ui-helper-reset" ) + // ARIA + .attr( "role", "tablist" ); + + // don't allow collapsible: false and active: false / null + if ( !options.collapsible && (options.active === false || options.active == null) ) { + options.active = 0; + } + + this._processPanels(); + // handle negative values + if ( options.active < 0 ) { + options.active += this.headers.length; + } + this._refresh(); + }, + + _getCreateEventData: function() { + return { + header: this.active, + panel: !this.active.length ? $() : this.active.next(), + content: !this.active.length ? $() : this.active.next() + }; + }, + + _createIcons: function() { + var icons = this.options.icons; + if ( icons ) { + $( "" ) + .addClass( "ui-accordion-header-icon ui-icon " + icons.header ) + .prependTo( this.headers ); + this.active.children( ".ui-accordion-header-icon" ) + .removeClass( icons.header ) + .addClass( icons.activeHeader ); + this.headers.addClass( "ui-accordion-icons" ); + } + }, + + _destroyIcons: function() { + this.headers + .removeClass( "ui-accordion-icons" ) + .children( ".ui-accordion-header-icon" ) + .remove(); + }, + + _destroy: function() { + var contents; + + // clean up main element + this.element + .removeClass( "ui-accordion ui-widget ui-helper-reset" ) + .removeAttr( "role" ); + + // clean up headers + this.headers + .removeClass( "ui-accordion-header ui-accordion-header-active ui-helper-reset ui-state-default ui-corner-all ui-state-active ui-state-disabled ui-corner-top" ) + .removeAttr( "role" ) + .removeAttr( "aria-expanded" ) + .removeAttr( "aria-selected" ) + .removeAttr( "aria-controls" ) + .removeAttr( "tabIndex" ) + .each(function() { + if ( /^ui-accordion/.test( this.id ) ) { + this.removeAttribute( "id" ); + } + }); + this._destroyIcons(); + + // clean up content panels + contents = this.headers.next() + .css( "display", "" ) + .removeAttr( "role" ) + .removeAttr( "aria-hidden" ) + .removeAttr( "aria-labelledby" ) + .removeClass( "ui-helper-reset ui-widget-content ui-corner-bottom ui-accordion-content ui-accordion-content-active ui-state-disabled" ) + .each(function() { + if ( /^ui-accordion/.test( this.id ) ) { + this.removeAttribute( "id" ); + } + }); + if ( this.options.heightStyle !== "content" ) { + contents.css( "height", "" ); + } + }, + + _setOption: function( key, value ) { + if ( key === "active" ) { + // _activate() will handle invalid values and update this.options + this._activate( value ); + return; + } + + if ( key === "event" ) { + if ( this.options.event ) { + this._off( this.headers, this.options.event ); + } + this._setupEvents( value ); + } + + this._super( key, value ); + + // setting collapsible: false while collapsed; open first panel + if ( key === "collapsible" && !value && this.options.active === false ) { + this._activate( 0 ); + } + + if ( key === "icons" ) { + this._destroyIcons(); + if ( value ) { + this._createIcons(); + } + } + + // #5332 - opacity doesn't cascade to positioned elements in IE + // so we need to add the disabled class to the headers and panels + if ( key === "disabled" ) { + this.headers.add( this.headers.next() ) + .toggleClass( "ui-state-disabled", !!value ); + } + }, + + _keydown: function( event ) { + if ( event.altKey || event.ctrlKey ) { + return; + } + + var keyCode = $.ui.keyCode, + length = this.headers.length, + currentIndex = this.headers.index( event.target ), + toFocus = false; + + switch ( event.keyCode ) { + case keyCode.RIGHT: + case keyCode.DOWN: + toFocus = this.headers[ ( currentIndex + 1 ) % length ]; + break; + case keyCode.LEFT: + case keyCode.UP: + toFocus = this.headers[ ( currentIndex - 1 + length ) % length ]; + break; + case keyCode.SPACE: + case keyCode.ENTER: + this._eventHandler( event ); + break; + case keyCode.HOME: + toFocus = this.headers[ 0 ]; + break; + case keyCode.END: + toFocus = this.headers[ length - 1 ]; + break; + } + + if ( toFocus ) { + $( event.target ).attr( "tabIndex", -1 ); + $( toFocus ).attr( "tabIndex", 0 ); + toFocus.focus(); + event.preventDefault(); + } + }, + + _panelKeyDown : function( event ) { + if ( event.keyCode === $.ui.keyCode.UP && event.ctrlKey ) { + $( event.currentTarget ).prev().focus(); + } + }, + + refresh: function() { + var options = this.options; + this._processPanels(); + + // was collapsed or no panel + if ( ( options.active === false && options.collapsible === true ) || !this.headers.length ) { + options.active = false; + this.active = $(); + // active false only when collapsible is true + } else if ( options.active === false ) { + this._activate( 0 ); + // was active, but active panel is gone + } else if ( this.active.length && !$.contains( this.element[ 0 ], this.active[ 0 ] ) ) { + // all remaining panel are disabled + if ( this.headers.length === this.headers.find(".ui-state-disabled").length ) { + options.active = false; + this.active = $(); + // activate previous panel + } else { + this._activate( Math.max( 0, options.active - 1 ) ); + } + // was active, active panel still exists + } else { + // make sure active index is correct + options.active = this.headers.index( this.active ); + } + + this._destroyIcons(); + + this._refresh(); + }, + + _processPanels: function() { + this.headers = this.element.find( this.options.header ) + .addClass( "ui-accordion-header ui-helper-reset ui-state-default ui-corner-all" ); + + this.headers.next() + .addClass( "ui-accordion-content ui-helper-reset ui-widget-content ui-corner-bottom" ) + .filter(":not(.ui-accordion-content-active)") + .hide(); + }, + + _refresh: function() { + var maxHeight, + options = this.options, + heightStyle = options.heightStyle, + parent = this.element.parent(), + accordionId = this.accordionId = "ui-accordion-" + + (this.element.attr( "id" ) || ++uid); + + this.active = this._findActive( options.active ) + .addClass( "ui-accordion-header-active ui-state-active ui-corner-top" ) + .removeClass( "ui-corner-all" ); + this.active.next() + .addClass( "ui-accordion-content-active" ) + .show(); + + this.headers + .attr( "role", "tab" ) + .each(function( i ) { + var header = $( this ), + headerId = header.attr( "id" ), + panel = header.next(), + panelId = panel.attr( "id" ); + if ( !headerId ) { + headerId = accordionId + "-header-" + i; + header.attr( "id", headerId ); + } + if ( !panelId ) { + panelId = accordionId + "-panel-" + i; + panel.attr( "id", panelId ); + } + header.attr( "aria-controls", panelId ); + panel.attr( "aria-labelledby", headerId ); + }) + .next() + .attr( "role", "tabpanel" ); + + this.headers + .not( this.active ) + .attr({ + "aria-selected": "false", + "aria-expanded": "false", + tabIndex: -1 + }) + .next() + .attr({ + "aria-hidden": "true" + }) + .hide(); + + // make sure at least one header is in the tab order + if ( !this.active.length ) { + this.headers.eq( 0 ).attr( "tabIndex", 0 ); + } else { + this.active.attr({ + "aria-selected": "true", + "aria-expanded": "true", + tabIndex: 0 + }) + .next() + .attr({ + "aria-hidden": "false" + }); + } + + this._createIcons(); + + this._setupEvents( options.event ); + + if ( heightStyle === "fill" ) { + maxHeight = parent.height(); + this.element.siblings( ":visible" ).each(function() { + var elem = $( this ), + position = elem.css( "position" ); + + if ( position === "absolute" || position === "fixed" ) { + return; + } + maxHeight -= elem.outerHeight( true ); + }); + + this.headers.each(function() { + maxHeight -= $( this ).outerHeight( true ); + }); + + this.headers.next() + .each(function() { + $( this ).height( Math.max( 0, maxHeight - + $( this ).innerHeight() + $( this ).height() ) ); + }) + .css( "overflow", "auto" ); + } else if ( heightStyle === "auto" ) { + maxHeight = 0; + this.headers.next() + .each(function() { + maxHeight = Math.max( maxHeight, $( this ).css( "height", "" ).height() ); + }) + .height( maxHeight ); + } + }, + + _activate: function( index ) { + var active = this._findActive( index )[ 0 ]; + + // trying to activate the already active panel + if ( active === this.active[ 0 ] ) { + return; + } + + // trying to collapse, simulate a click on the currently active header + active = active || this.active[ 0 ]; + + this._eventHandler({ + target: active, + currentTarget: active, + preventDefault: $.noop + }); + }, + + _findActive: function( selector ) { + return typeof selector === "number" ? this.headers.eq( selector ) : $(); + }, + + _setupEvents: function( event ) { + var events = { + keydown: "_keydown" + }; + if ( event ) { + $.each( event.split(" "), function( index, eventName ) { + events[ eventName ] = "_eventHandler"; + }); + } + + this._off( this.headers.add( this.headers.next() ) ); + this._on( this.headers, events ); + this._on( this.headers.next(), { keydown: "_panelKeyDown" }); + this._hoverable( this.headers ); + this._focusable( this.headers ); + }, + + _eventHandler: function( event ) { + var options = this.options, + active = this.active, + clicked = $( event.currentTarget ), + clickedIsActive = clicked[ 0 ] === active[ 0 ], + collapsing = clickedIsActive && options.collapsible, + toShow = collapsing ? $() : clicked.next(), + toHide = active.next(), + eventData = { + oldHeader: active, + oldPanel: toHide, + newHeader: collapsing ? $() : clicked, + newPanel: toShow + }; + + event.preventDefault(); + + if ( + // click on active header, but not collapsible + ( clickedIsActive && !options.collapsible ) || + // allow canceling activation + ( this._trigger( "beforeActivate", event, eventData ) === false ) ) { + return; + } + + options.active = collapsing ? false : this.headers.index( clicked ); + + // when the call to ._toggle() comes after the class changes + // it causes a very odd bug in IE 8 (see #6720) + this.active = clickedIsActive ? $() : clicked; + this._toggle( eventData ); + + // switch classes + // corner classes on the previously active header stay after the animation + active.removeClass( "ui-accordion-header-active ui-state-active" ); + if ( options.icons ) { + active.children( ".ui-accordion-header-icon" ) + .removeClass( options.icons.activeHeader ) + .addClass( options.icons.header ); + } + + if ( !clickedIsActive ) { + clicked + .removeClass( "ui-corner-all" ) + .addClass( "ui-accordion-header-active ui-state-active ui-corner-top" ); + if ( options.icons ) { + clicked.children( ".ui-accordion-header-icon" ) + .removeClass( options.icons.header ) + .addClass( options.icons.activeHeader ); + } + + clicked + .next() + .addClass( "ui-accordion-content-active" ); + } + }, + + _toggle: function( data ) { + var toShow = data.newPanel, + toHide = this.prevShow.length ? this.prevShow : data.oldPanel; + + // handle activating a panel during the animation for another activation + this.prevShow.add( this.prevHide ).stop( true, true ); + this.prevShow = toShow; + this.prevHide = toHide; + + if ( this.options.animate ) { + this._animate( toShow, toHide, data ); + } else { + toHide.hide(); + toShow.show(); + this._toggleComplete( data ); + } + + toHide.attr({ + "aria-hidden": "true" + }); + toHide.prev().attr( "aria-selected", "false" ); + // if we're switching panels, remove the old header from the tab order + // if we're opening from collapsed state, remove the previous header from the tab order + // if we're collapsing, then keep the collapsing header in the tab order + if ( toShow.length && toHide.length ) { + toHide.prev().attr({ + "tabIndex": -1, + "aria-expanded": "false" + }); + } else if ( toShow.length ) { + this.headers.filter(function() { + return $( this ).attr( "tabIndex" ) === 0; + }) + .attr( "tabIndex", -1 ); + } + + toShow + .attr( "aria-hidden", "false" ) + .prev() + .attr({ + "aria-selected": "true", + tabIndex: 0, + "aria-expanded": "true" + }); + }, + + _animate: function( toShow, toHide, data ) { + var total, easing, duration, + that = this, + adjust = 0, + down = toShow.length && + ( !toHide.length || ( toShow.index() < toHide.index() ) ), + animate = this.options.animate || {}, + options = down && animate.down || animate, + complete = function() { + that._toggleComplete( data ); + }; + + if ( typeof options === "number" ) { + duration = options; + } + if ( typeof options === "string" ) { + easing = options; + } + // fall back from options to animation in case of partial down settings + easing = easing || options.easing || animate.easing; + duration = duration || options.duration || animate.duration; + + if ( !toHide.length ) { + return toShow.animate( showProps, duration, easing, complete ); + } + if ( !toShow.length ) { + return toHide.animate( hideProps, duration, easing, complete ); + } + + total = toShow.show().outerHeight(); + toHide.animate( hideProps, { + duration: duration, + easing: easing, + step: function( now, fx ) { + fx.now = Math.round( now ); + } + }); + toShow + .hide() + .animate( showProps, { + duration: duration, + easing: easing, + complete: complete, + step: function( now, fx ) { + fx.now = Math.round( now ); + if ( fx.prop !== "height" ) { + adjust += fx.now; + } else if ( that.options.heightStyle !== "content" ) { + fx.now = Math.round( total - toHide.outerHeight() - adjust ); + adjust = 0; + } + } + }); + }, + + _toggleComplete: function( data ) { + var toHide = data.oldPanel; + + toHide + .removeClass( "ui-accordion-content-active" ) + .prev() + .removeClass( "ui-corner-top" ) + .addClass( "ui-corner-all" ); + + // Work around for rendering bug in IE (#5421) + if ( toHide.length ) { + toHide.parent()[0].className = toHide.parent()[0].className; + } + this._trigger( "activate", null, data ); + } +}); + +})( jQuery ); +(function( $, undefined ) { + +$.widget( "ui.autocomplete", { + version: "1.10.4", + defaultElement: "", + options: { + appendTo: null, + autoFocus: false, + delay: 300, + minLength: 1, + position: { + my: "left top", + at: "left bottom", + collision: "none" + }, + source: null, + + // callbacks + change: null, + close: null, + focus: null, + open: null, + response: null, + search: null, + select: null + }, + + requestIndex: 0, + pending: 0, + + _create: function() { + // Some browsers only repeat keydown events, not keypress events, + // so we use the suppressKeyPress flag to determine if we've already + // handled the keydown event. #7269 + // Unfortunately the code for & in keypress is the same as the up arrow, + // so we use the suppressKeyPressRepeat flag to avoid handling keypress + // events when we know the keydown event was used to modify the + // search term. #7799 + var suppressKeyPress, suppressKeyPressRepeat, suppressInput, + nodeName = this.element[0].nodeName.toLowerCase(), + isTextarea = nodeName === "textarea", + isInput = nodeName === "input"; + + this.isMultiLine = + // Textareas are always multi-line + isTextarea ? true : + // Inputs are always single-line, even if inside a contentEditable element + // IE also treats inputs as contentEditable + isInput ? false : + // All other element types are determined by whether or not they're contentEditable + this.element.prop( "isContentEditable" ); + + this.valueMethod = this.element[ isTextarea || isInput ? "val" : "text" ]; + this.isNewMenu = true; + + this.element + .addClass( "ui-autocomplete-input" ) + .attr( "autocomplete", "off" ); + + this._on( this.element, { + keydown: function( event ) { + if ( this.element.prop( "readOnly" ) ) { + suppressKeyPress = true; + suppressInput = true; + suppressKeyPressRepeat = true; + return; + } + + suppressKeyPress = false; + suppressInput = false; + suppressKeyPressRepeat = false; + var keyCode = $.ui.keyCode; + switch( event.keyCode ) { + case keyCode.PAGE_UP: + suppressKeyPress = true; + this._move( "previousPage", event ); + break; + case keyCode.PAGE_DOWN: + suppressKeyPress = true; + this._move( "nextPage", event ); + break; + case keyCode.UP: + suppressKeyPress = true; + this._keyEvent( "previous", event ); + break; + case keyCode.DOWN: + suppressKeyPress = true; + this._keyEvent( "next", event ); + break; + case keyCode.ENTER: + case keyCode.NUMPAD_ENTER: + // when menu is open and has focus + if ( this.menu.active ) { + // #6055 - Opera still allows the keypress to occur + // which causes forms to submit + suppressKeyPress = true; + event.preventDefault(); + this.menu.select( event ); + } + break; + case keyCode.TAB: + if ( this.menu.active ) { + this.menu.select( event ); + } + break; + case keyCode.ESCAPE: + if ( this.menu.element.is( ":visible" ) ) { + this._value( this.term ); + this.close( event ); + // Different browsers have different default behavior for escape + // Single press can mean undo or clear + // Double press in IE means clear the whole form + event.preventDefault(); + } + break; + default: + suppressKeyPressRepeat = true; + // search timeout should be triggered before the input value is changed + this._searchTimeout( event ); + break; + } + }, + keypress: function( event ) { + if ( suppressKeyPress ) { + suppressKeyPress = false; + if ( !this.isMultiLine || this.menu.element.is( ":visible" ) ) { + event.preventDefault(); + } + return; + } + if ( suppressKeyPressRepeat ) { + return; + } + + // replicate some key handlers to allow them to repeat in Firefox and Opera + var keyCode = $.ui.keyCode; + switch( event.keyCode ) { + case keyCode.PAGE_UP: + this._move( "previousPage", event ); + break; + case keyCode.PAGE_DOWN: + this._move( "nextPage", event ); + break; + case keyCode.UP: + this._keyEvent( "previous", event ); + break; + case keyCode.DOWN: + this._keyEvent( "next", event ); + break; + } + }, + input: function( event ) { + if ( suppressInput ) { + suppressInput = false; + event.preventDefault(); + return; + } + this._searchTimeout( event ); + }, + focus: function() { + this.selectedItem = null; + this.previous = this._value(); + }, + blur: function( event ) { + if ( this.cancelBlur ) { + delete this.cancelBlur; + return; + } + + clearTimeout( this.searching ); + this.close( event ); + this._change( event ); + } + }); + + this._initSource(); + this.menu = $( "
    " ) + .addClass( "ui-autocomplete ui-front" ) + .appendTo( this._appendTo() ) + .menu({ + // disable ARIA support, the live region takes care of that + role: null + }) + .hide() + .data( "ui-menu" ); + + this._on( this.menu.element, { + mousedown: function( event ) { + // prevent moving focus out of the text field + event.preventDefault(); + + // IE doesn't prevent moving focus even with event.preventDefault() + // so we set a flag to know when we should ignore the blur event + this.cancelBlur = true; + this._delay(function() { + delete this.cancelBlur; + }); + + // clicking on the scrollbar causes focus to shift to the body + // but we can't detect a mouseup or a click immediately afterward + // so we have to track the next mousedown and close the menu if + // the user clicks somewhere outside of the autocomplete + var menuElement = this.menu.element[ 0 ]; + if ( !$( event.target ).closest( ".ui-menu-item" ).length ) { + this._delay(function() { + var that = this; + this.document.one( "mousedown", function( event ) { + if ( event.target !== that.element[ 0 ] && + event.target !== menuElement && + !$.contains( menuElement, event.target ) ) { + that.close(); + } + }); + }); + } + }, + menufocus: function( event, ui ) { + // support: Firefox + // Prevent accidental activation of menu items in Firefox (#7024 #9118) + if ( this.isNewMenu ) { + this.isNewMenu = false; + if ( event.originalEvent && /^mouse/.test( event.originalEvent.type ) ) { + this.menu.blur(); + + this.document.one( "mousemove", function() { + $( event.target ).trigger( event.originalEvent ); + }); + + return; + } + } + + var item = ui.item.data( "ui-autocomplete-item" ); + if ( false !== this._trigger( "focus", event, { item: item } ) ) { + // use value to match what will end up in the input, if it was a key event + if ( event.originalEvent && /^key/.test( event.originalEvent.type ) ) { + this._value( item.value ); + } + } else { + // Normally the input is populated with the item's value as the + // menu is navigated, causing screen readers to notice a change and + // announce the item. Since the focus event was canceled, this doesn't + // happen, so we update the live region so that screen readers can + // still notice the change and announce it. + this.liveRegion.text( item.value ); + } + }, + menuselect: function( event, ui ) { + var item = ui.item.data( "ui-autocomplete-item" ), + previous = this.previous; + + // only trigger when focus was lost (click on menu) + if ( this.element[0] !== this.document[0].activeElement ) { + this.element.focus(); + this.previous = previous; + // #6109 - IE triggers two focus events and the second + // is asynchronous, so we need to reset the previous + // term synchronously and asynchronously :-( + this._delay(function() { + this.previous = previous; + this.selectedItem = item; + }); + } + + if ( false !== this._trigger( "select", event, { item: item } ) ) { + this._value( item.value ); + } + // reset the term after the select event + // this allows custom select handling to work properly + this.term = this._value(); + + this.close( event ); + this.selectedItem = item; + } + }); + + this.liveRegion = $( "", { + role: "status", + "aria-live": "polite" + }) + .addClass( "ui-helper-hidden-accessible" ) + .insertBefore( this.element ); + + // turning off autocomplete prevents the browser from remembering the + // value when navigating through history, so we re-enable autocomplete + // if the page is unloaded before the widget is destroyed. #7790 + this._on( this.window, { + beforeunload: function() { + this.element.removeAttr( "autocomplete" ); + } + }); + }, + + _destroy: function() { + clearTimeout( this.searching ); + this.element + .removeClass( "ui-autocomplete-input" ) + .removeAttr( "autocomplete" ); + this.menu.element.remove(); + this.liveRegion.remove(); + }, + + _setOption: function( key, value ) { + this._super( key, value ); + if ( key === "source" ) { + this._initSource(); + } + if ( key === "appendTo" ) { + this.menu.element.appendTo( this._appendTo() ); + } + if ( key === "disabled" && value && this.xhr ) { + this.xhr.abort(); + } + }, + + _appendTo: function() { + var element = this.options.appendTo; + + if ( element ) { + element = element.jquery || element.nodeType ? + $( element ) : + this.document.find( element ).eq( 0 ); + } + + if ( !element ) { + element = this.element.closest( ".ui-front" ); + } + + if ( !element.length ) { + element = this.document[0].body; + } + + return element; + }, + + _initSource: function() { + var array, url, + that = this; + if ( $.isArray(this.options.source) ) { + array = this.options.source; + this.source = function( request, response ) { + response( $.ui.autocomplete.filter( array, request.term ) ); + }; + } else if ( typeof this.options.source === "string" ) { + url = this.options.source; + this.source = function( request, response ) { + if ( that.xhr ) { + that.xhr.abort(); + } + that.xhr = $.ajax({ + url: url, + data: request, + dataType: "json", + success: function( data ) { + response( data ); + }, + error: function() { + response( [] ); + } + }); + }; + } else { + this.source = this.options.source; + } + }, + + _searchTimeout: function( event ) { + clearTimeout( this.searching ); + this.searching = this._delay(function() { + // only search if the value has changed + if ( this.term !== this._value() ) { + this.selectedItem = null; + this.search( null, event ); + } + }, this.options.delay ); + }, + + search: function( value, event ) { + value = value != null ? value : this._value(); + + // always save the actual value, not the one passed as an argument + this.term = this._value(); + + if ( value.length < this.options.minLength ) { + return this.close( event ); + } + + if ( this._trigger( "search", event ) === false ) { + return; + } + + return this._search( value ); + }, + + _search: function( value ) { + this.pending++; + this.element.addClass( "ui-autocomplete-loading" ); + this.cancelSearch = false; + + this.source( { term: value }, this._response() ); + }, + + _response: function() { + var index = ++this.requestIndex; + + return $.proxy(function( content ) { + if ( index === this.requestIndex ) { + this.__response( content ); + } + + this.pending--; + if ( !this.pending ) { + this.element.removeClass( "ui-autocomplete-loading" ); + } + }, this ); + }, + + __response: function( content ) { + if ( content ) { + content = this._normalize( content ); + } + this._trigger( "response", null, { content: content } ); + if ( !this.options.disabled && content && content.length && !this.cancelSearch ) { + this._suggest( content ); + this._trigger( "open" ); + } else { + // use ._close() instead of .close() so we don't cancel future searches + this._close(); + } + }, + + close: function( event ) { + this.cancelSearch = true; + this._close( event ); + }, + + _close: function( event ) { + if ( this.menu.element.is( ":visible" ) ) { + this.menu.element.hide(); + this.menu.blur(); + this.isNewMenu = true; + this._trigger( "close", event ); + } + }, + + _change: function( event ) { + if ( this.previous !== this._value() ) { + this._trigger( "change", event, { item: this.selectedItem } ); + } + }, + + _normalize: function( items ) { + // assume all items have the right format when the first item is complete + if ( items.length && items[0].label && items[0].value ) { + return items; + } + return $.map( items, function( item ) { + if ( typeof item === "string" ) { + return { + label: item, + value: item + }; + } + return $.extend({ + label: item.label || item.value, + value: item.value || item.label + }, item ); + }); + }, + + _suggest: function( items ) { + var ul = this.menu.element.empty(); + this._renderMenu( ul, items ); + this.isNewMenu = true; + this.menu.refresh(); + + // size and position menu + ul.show(); + this._resizeMenu(); + ul.position( $.extend({ + of: this.element + }, this.options.position )); + + if ( this.options.autoFocus ) { + this.menu.next(); + } + }, + + _resizeMenu: function() { + var ul = this.menu.element; + ul.outerWidth( Math.max( + // Firefox wraps long text (possibly a rounding bug) + // so we add 1px to avoid the wrapping (#7513) + ul.width( "" ).outerWidth() + 1, + this.element.outerWidth() + ) ); + }, + + _renderMenu: function( ul, items ) { + var that = this; + $.each( items, function( index, item ) { + that._renderItemData( ul, item ); + }); + }, + + _renderItemData: function( ul, item ) { + return this._renderItem( ul, item ).data( "ui-autocomplete-item", item ); + }, + + _renderItem: function( ul, item ) { + return $( "
  • " ) + .append( $( "" ).text( item.label ) ) + .appendTo( ul ); + }, + + _move: function( direction, event ) { + if ( !this.menu.element.is( ":visible" ) ) { + this.search( null, event ); + return; + } + if ( this.menu.isFirstItem() && /^previous/.test( direction ) || + this.menu.isLastItem() && /^next/.test( direction ) ) { + this._value( this.term ); + this.menu.blur(); + return; + } + this.menu[ direction ]( event ); + }, + + widget: function() { + return this.menu.element; + }, + + _value: function() { + return this.valueMethod.apply( this.element, arguments ); + }, + + _keyEvent: function( keyEvent, event ) { + if ( !this.isMultiLine || this.menu.element.is( ":visible" ) ) { + this._move( keyEvent, event ); + + // prevents moving cursor to beginning/end of the text field in some browsers + event.preventDefault(); + } + } +}); + +$.extend( $.ui.autocomplete, { + escapeRegex: function( value ) { + return value.replace(/[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&"); + }, + filter: function(array, term) { + var matcher = new RegExp( $.ui.autocomplete.escapeRegex(term), "i" ); + return $.grep( array, function(value) { + return matcher.test( value.label || value.value || value ); + }); + } +}); + + +// live region extension, adding a `messages` option +// NOTE: This is an experimental API. We are still investigating +// a full solution for string manipulation and internationalization. +$.widget( "ui.autocomplete", $.ui.autocomplete, { + options: { + messages: { + noResults: "No search results.", + results: function( amount ) { + return amount + ( amount > 1 ? " results are" : " result is" ) + + " available, use up and down arrow keys to navigate."; + } + } + }, + + __response: function( content ) { + var message; + this._superApply( arguments ); + if ( this.options.disabled || this.cancelSearch ) { + return; + } + if ( content && content.length ) { + message = this.options.messages.results( content.length ); + } else { + message = this.options.messages.noResults; + } + this.liveRegion.text( message ); + } +}); + +}( jQuery )); +(function( $, undefined ) { + +var lastActive, + baseClasses = "ui-button ui-widget ui-state-default ui-corner-all", + typeClasses = "ui-button-icons-only ui-button-icon-only ui-button-text-icons ui-button-text-icon-primary ui-button-text-icon-secondary ui-button-text-only", + formResetHandler = function() { + var form = $( this ); + setTimeout(function() { + form.find( ":ui-button" ).button( "refresh" ); + }, 1 ); + }, + radioGroup = function( radio ) { + var name = radio.name, + form = radio.form, + radios = $( [] ); + if ( name ) { + name = name.replace( /'/g, "\\'" ); + if ( form ) { + radios = $( form ).find( "[name='" + name + "']" ); + } else { + radios = $( "[name='" + name + "']", radio.ownerDocument ) + .filter(function() { + return !this.form; + }); + } + } + return radios; + }; + +$.widget( "ui.button", { + version: "1.10.4", + defaultElement: "").addClass(this._triggerClass). + html(!buttonImage ? buttonText : $("").attr( + { src:buttonImage, alt:buttonText, title:buttonText }))); + input[isRTL ? "before" : "after"](inst.trigger); + inst.trigger.click(function() { + if ($.datepicker._datepickerShowing && $.datepicker._lastInput === input[0]) { + $.datepicker._hideDatepicker(); + } else if ($.datepicker._datepickerShowing && $.datepicker._lastInput !== input[0]) { + $.datepicker._hideDatepicker(); + $.datepicker._showDatepicker(input[0]); + } else { + $.datepicker._showDatepicker(input[0]); + } + return false; + }); + } + }, + + /* Apply the maximum length for the date format. */ + _autoSize: function(inst) { + if (this._get(inst, "autoSize") && !inst.inline) { + var findMax, max, maxI, i, + date = new Date(2009, 12 - 1, 20), // Ensure double digits + dateFormat = this._get(inst, "dateFormat"); + + if (dateFormat.match(/[DM]/)) { + findMax = function(names) { + max = 0; + maxI = 0; + for (i = 0; i < names.length; i++) { + if (names[i].length > max) { + max = names[i].length; + maxI = i; + } + } + return maxI; + }; + date.setMonth(findMax(this._get(inst, (dateFormat.match(/MM/) ? + "monthNames" : "monthNamesShort")))); + date.setDate(findMax(this._get(inst, (dateFormat.match(/DD/) ? + "dayNames" : "dayNamesShort"))) + 20 - date.getDay()); + } + inst.input.attr("size", this._formatDate(inst, date).length); + } + }, + + /* Attach an inline date picker to a div. */ + _inlineDatepicker: function(target, inst) { + var divSpan = $(target); + if (divSpan.hasClass(this.markerClassName)) { + return; + } + divSpan.addClass(this.markerClassName).append(inst.dpDiv); + $.data(target, PROP_NAME, inst); + this._setDate(inst, this._getDefaultDate(inst), true); + this._updateDatepicker(inst); + this._updateAlternate(inst); + //If disabled option is true, disable the datepicker before showing it (see ticket #5665) + if( inst.settings.disabled ) { + this._disableDatepicker( target ); + } + // Set display:block in place of inst.dpDiv.show() which won't work on disconnected elements + // http://bugs.jqueryui.com/ticket/7552 - A Datepicker created on a detached div has zero height + inst.dpDiv.css( "display", "block" ); + }, + + /* Pop-up the date picker in a "dialog" box. + * @param input element - ignored + * @param date string or Date - the initial date to display + * @param onSelect function - the function to call when a date is selected + * @param settings object - update the dialog date picker instance's settings (anonymous object) + * @param pos int[2] - coordinates for the dialog's position within the screen or + * event - with x/y coordinates or + * leave empty for default (screen centre) + * @return the manager object + */ + _dialogDatepicker: function(input, date, onSelect, settings, pos) { + var id, browserWidth, browserHeight, scrollX, scrollY, + inst = this._dialogInst; // internal instance + + if (!inst) { + this.uuid += 1; + id = "dp" + this.uuid; + this._dialogInput = $(""); + this._dialogInput.keydown(this._doKeyDown); + $("body").append(this._dialogInput); + inst = this._dialogInst = this._newInst(this._dialogInput, false); + inst.settings = {}; + $.data(this._dialogInput[0], PROP_NAME, inst); + } + extendRemove(inst.settings, settings || {}); + date = (date && date.constructor === Date ? this._formatDate(inst, date) : date); + this._dialogInput.val(date); + + this._pos = (pos ? (pos.length ? pos : [pos.pageX, pos.pageY]) : null); + if (!this._pos) { + browserWidth = document.documentElement.clientWidth; + browserHeight = document.documentElement.clientHeight; + scrollX = document.documentElement.scrollLeft || document.body.scrollLeft; + scrollY = document.documentElement.scrollTop || document.body.scrollTop; + this._pos = // should use actual width/height below + [(browserWidth / 2) - 100 + scrollX, (browserHeight / 2) - 150 + scrollY]; + } + + // move input on screen for focus, but hidden behind dialog + this._dialogInput.css("left", (this._pos[0] + 20) + "px").css("top", this._pos[1] + "px"); + inst.settings.onSelect = onSelect; + this._inDialog = true; + this.dpDiv.addClass(this._dialogClass); + this._showDatepicker(this._dialogInput[0]); + if ($.blockUI) { + $.blockUI(this.dpDiv); + } + $.data(this._dialogInput[0], PROP_NAME, inst); + return this; + }, + + /* Detach a datepicker from its control. + * @param target element - the target input field or division or span + */ + _destroyDatepicker: function(target) { + var nodeName, + $target = $(target), + inst = $.data(target, PROP_NAME); + + if (!$target.hasClass(this.markerClassName)) { + return; + } + + nodeName = target.nodeName.toLowerCase(); + $.removeData(target, PROP_NAME); + if (nodeName === "input") { + inst.append.remove(); + inst.trigger.remove(); + $target.removeClass(this.markerClassName). + unbind("focus", this._showDatepicker). + unbind("keydown", this._doKeyDown). + unbind("keypress", this._doKeyPress). + unbind("keyup", this._doKeyUp); + } else if (nodeName === "div" || nodeName === "span") { + $target.removeClass(this.markerClassName).empty(); + } + }, + + /* Enable the date picker to a jQuery selection. + * @param target element - the target input field or division or span + */ + _enableDatepicker: function(target) { + var nodeName, inline, + $target = $(target), + inst = $.data(target, PROP_NAME); + + if (!$target.hasClass(this.markerClassName)) { + return; + } + + nodeName = target.nodeName.toLowerCase(); + if (nodeName === "input") { + target.disabled = false; + inst.trigger.filter("button"). + each(function() { this.disabled = false; }).end(). + filter("img").css({opacity: "1.0", cursor: ""}); + } else if (nodeName === "div" || nodeName === "span") { + inline = $target.children("." + this._inlineClass); + inline.children().removeClass("ui-state-disabled"); + inline.find("select.ui-datepicker-month, select.ui-datepicker-year"). + prop("disabled", false); + } + this._disabledInputs = $.map(this._disabledInputs, + function(value) { return (value === target ? null : value); }); // delete entry + }, + + /* Disable the date picker to a jQuery selection. + * @param target element - the target input field or division or span + */ + _disableDatepicker: function(target) { + var nodeName, inline, + $target = $(target), + inst = $.data(target, PROP_NAME); + + if (!$target.hasClass(this.markerClassName)) { + return; + } + + nodeName = target.nodeName.toLowerCase(); + if (nodeName === "input") { + target.disabled = true; + inst.trigger.filter("button"). + each(function() { this.disabled = true; }).end(). + filter("img").css({opacity: "0.5", cursor: "default"}); + } else if (nodeName === "div" || nodeName === "span") { + inline = $target.children("." + this._inlineClass); + inline.children().addClass("ui-state-disabled"); + inline.find("select.ui-datepicker-month, select.ui-datepicker-year"). + prop("disabled", true); + } + this._disabledInputs = $.map(this._disabledInputs, + function(value) { return (value === target ? null : value); }); // delete entry + this._disabledInputs[this._disabledInputs.length] = target; + }, + + /* Is the first field in a jQuery collection disabled as a datepicker? + * @param target element - the target input field or division or span + * @return boolean - true if disabled, false if enabled + */ + _isDisabledDatepicker: function(target) { + if (!target) { + return false; + } + for (var i = 0; i < this._disabledInputs.length; i++) { + if (this._disabledInputs[i] === target) { + return true; + } + } + return false; + }, + + /* Retrieve the instance data for the target control. + * @param target element - the target input field or division or span + * @return object - the associated instance data + * @throws error if a jQuery problem getting data + */ + _getInst: function(target) { + try { + return $.data(target, PROP_NAME); + } + catch (err) { + throw "Missing instance data for this datepicker"; + } + }, + + /* Update or retrieve the settings for a date picker attached to an input field or division. + * @param target element - the target input field or division or span + * @param name object - the new settings to update or + * string - the name of the setting to change or retrieve, + * when retrieving also "all" for all instance settings or + * "defaults" for all global defaults + * @param value any - the new value for the setting + * (omit if above is an object or to retrieve a value) + */ + _optionDatepicker: function(target, name, value) { + var settings, date, minDate, maxDate, + inst = this._getInst(target); + + if (arguments.length === 2 && typeof name === "string") { + return (name === "defaults" ? $.extend({}, $.datepicker._defaults) : + (inst ? (name === "all" ? $.extend({}, inst.settings) : + this._get(inst, name)) : null)); + } + + settings = name || {}; + if (typeof name === "string") { + settings = {}; + settings[name] = value; + } + + if (inst) { + if (this._curInst === inst) { + this._hideDatepicker(); + } + + date = this._getDateDatepicker(target, true); + minDate = this._getMinMaxDate(inst, "min"); + maxDate = this._getMinMaxDate(inst, "max"); + extendRemove(inst.settings, settings); + // reformat the old minDate/maxDate values if dateFormat changes and a new minDate/maxDate isn't provided + if (minDate !== null && settings.dateFormat !== undefined && settings.minDate === undefined) { + inst.settings.minDate = this._formatDate(inst, minDate); + } + if (maxDate !== null && settings.dateFormat !== undefined && settings.maxDate === undefined) { + inst.settings.maxDate = this._formatDate(inst, maxDate); + } + if ( "disabled" in settings ) { + if ( settings.disabled ) { + this._disableDatepicker(target); + } else { + this._enableDatepicker(target); + } + } + this._attachments($(target), inst); + this._autoSize(inst); + this._setDate(inst, date); + this._updateAlternate(inst); + this._updateDatepicker(inst); + } + }, + + // change method deprecated + _changeDatepicker: function(target, name, value) { + this._optionDatepicker(target, name, value); + }, + + /* Redraw the date picker attached to an input field or division. + * @param target element - the target input field or division or span + */ + _refreshDatepicker: function(target) { + var inst = this._getInst(target); + if (inst) { + this._updateDatepicker(inst); + } + }, + + /* Set the dates for a jQuery selection. + * @param target element - the target input field or division or span + * @param date Date - the new date + */ + _setDateDatepicker: function(target, date) { + var inst = this._getInst(target); + if (inst) { + this._setDate(inst, date); + this._updateDatepicker(inst); + this._updateAlternate(inst); + } + }, + + /* Get the date(s) for the first entry in a jQuery selection. + * @param target element - the target input field or division or span + * @param noDefault boolean - true if no default date is to be used + * @return Date - the current date + */ + _getDateDatepicker: function(target, noDefault) { + var inst = this._getInst(target); + if (inst && !inst.inline) { + this._setDateFromField(inst, noDefault); + } + return (inst ? this._getDate(inst) : null); + }, + + /* Handle keystrokes. */ + _doKeyDown: function(event) { + var onSelect, dateStr, sel, + inst = $.datepicker._getInst(event.target), + handled = true, + isRTL = inst.dpDiv.is(".ui-datepicker-rtl"); + + inst._keyEvent = true; + if ($.datepicker._datepickerShowing) { + switch (event.keyCode) { + case 9: $.datepicker._hideDatepicker(); + handled = false; + break; // hide on tab out + case 13: sel = $("td." + $.datepicker._dayOverClass + ":not(." + + $.datepicker._currentClass + ")", inst.dpDiv); + if (sel[0]) { + $.datepicker._selectDay(event.target, inst.selectedMonth, inst.selectedYear, sel[0]); + } + + onSelect = $.datepicker._get(inst, "onSelect"); + if (onSelect) { + dateStr = $.datepicker._formatDate(inst); + + // trigger custom callback + onSelect.apply((inst.input ? inst.input[0] : null), [dateStr, inst]); + } else { + $.datepicker._hideDatepicker(); + } + + return false; // don't submit the form + case 27: $.datepicker._hideDatepicker(); + break; // hide on escape + case 33: $.datepicker._adjustDate(event.target, (event.ctrlKey ? + -$.datepicker._get(inst, "stepBigMonths") : + -$.datepicker._get(inst, "stepMonths")), "M"); + break; // previous month/year on page up/+ ctrl + case 34: $.datepicker._adjustDate(event.target, (event.ctrlKey ? + +$.datepicker._get(inst, "stepBigMonths") : + +$.datepicker._get(inst, "stepMonths")), "M"); + break; // next month/year on page down/+ ctrl + case 35: if (event.ctrlKey || event.metaKey) { + $.datepicker._clearDate(event.target); + } + handled = event.ctrlKey || event.metaKey; + break; // clear on ctrl or command +end + case 36: if (event.ctrlKey || event.metaKey) { + $.datepicker._gotoToday(event.target); + } + handled = event.ctrlKey || event.metaKey; + break; // current on ctrl or command +home + case 37: if (event.ctrlKey || event.metaKey) { + $.datepicker._adjustDate(event.target, (isRTL ? +1 : -1), "D"); + } + handled = event.ctrlKey || event.metaKey; + // -1 day on ctrl or command +left + if (event.originalEvent.altKey) { + $.datepicker._adjustDate(event.target, (event.ctrlKey ? + -$.datepicker._get(inst, "stepBigMonths") : + -$.datepicker._get(inst, "stepMonths")), "M"); + } + // next month/year on alt +left on Mac + break; + case 38: if (event.ctrlKey || event.metaKey) { + $.datepicker._adjustDate(event.target, -7, "D"); + } + handled = event.ctrlKey || event.metaKey; + break; // -1 week on ctrl or command +up + case 39: if (event.ctrlKey || event.metaKey) { + $.datepicker._adjustDate(event.target, (isRTL ? -1 : +1), "D"); + } + handled = event.ctrlKey || event.metaKey; + // +1 day on ctrl or command +right + if (event.originalEvent.altKey) { + $.datepicker._adjustDate(event.target, (event.ctrlKey ? + +$.datepicker._get(inst, "stepBigMonths") : + +$.datepicker._get(inst, "stepMonths")), "M"); + } + // next month/year on alt +right + break; + case 40: if (event.ctrlKey || event.metaKey) { + $.datepicker._adjustDate(event.target, +7, "D"); + } + handled = event.ctrlKey || event.metaKey; + break; // +1 week on ctrl or command +down + default: handled = false; + } + } else if (event.keyCode === 36 && event.ctrlKey) { // display the date picker on ctrl+home + $.datepicker._showDatepicker(this); + } else { + handled = false; + } + + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + }, + + /* Filter entered characters - based on date format. */ + _doKeyPress: function(event) { + var chars, chr, + inst = $.datepicker._getInst(event.target); + + if ($.datepicker._get(inst, "constrainInput")) { + chars = $.datepicker._possibleChars($.datepicker._get(inst, "dateFormat")); + chr = String.fromCharCode(event.charCode == null ? event.keyCode : event.charCode); + return event.ctrlKey || event.metaKey || (chr < " " || !chars || chars.indexOf(chr) > -1); + } + }, + + /* Synchronise manual entry and field/alternate field. */ + _doKeyUp: function(event) { + var date, + inst = $.datepicker._getInst(event.target); + + if (inst.input.val() !== inst.lastVal) { + try { + date = $.datepicker.parseDate($.datepicker._get(inst, "dateFormat"), + (inst.input ? inst.input.val() : null), + $.datepicker._getFormatConfig(inst)); + + if (date) { // only if valid + $.datepicker._setDateFromField(inst); + $.datepicker._updateAlternate(inst); + $.datepicker._updateDatepicker(inst); + } + } + catch (err) { + } + } + return true; + }, + + /* Pop-up the date picker for a given input field. + * If false returned from beforeShow event handler do not show. + * @param input element - the input field attached to the date picker or + * event - if triggered by focus + */ + _showDatepicker: function(input) { + input = input.target || input; + if (input.nodeName.toLowerCase() !== "input") { // find from button/image trigger + input = $("input", input.parentNode)[0]; + } + + if ($.datepicker._isDisabledDatepicker(input) || $.datepicker._lastInput === input) { // already here + return; + } + + var inst, beforeShow, beforeShowSettings, isFixed, + offset, showAnim, duration; + + inst = $.datepicker._getInst(input); + if ($.datepicker._curInst && $.datepicker._curInst !== inst) { + $.datepicker._curInst.dpDiv.stop(true, true); + if ( inst && $.datepicker._datepickerShowing ) { + $.datepicker._hideDatepicker( $.datepicker._curInst.input[0] ); + } + } + + beforeShow = $.datepicker._get(inst, "beforeShow"); + beforeShowSettings = beforeShow ? beforeShow.apply(input, [input, inst]) : {}; + if(beforeShowSettings === false){ + return; + } + extendRemove(inst.settings, beforeShowSettings); + + inst.lastVal = null; + $.datepicker._lastInput = input; + $.datepicker._setDateFromField(inst); + + if ($.datepicker._inDialog) { // hide cursor + input.value = ""; + } + if (!$.datepicker._pos) { // position below input + $.datepicker._pos = $.datepicker._findPos(input); + $.datepicker._pos[1] += input.offsetHeight; // add the height + } + + isFixed = false; + $(input).parents().each(function() { + isFixed |= $(this).css("position") === "fixed"; + return !isFixed; + }); + + offset = {left: $.datepicker._pos[0], top: $.datepicker._pos[1]}; + $.datepicker._pos = null; + //to avoid flashes on Firefox + inst.dpDiv.empty(); + // determine sizing offscreen + inst.dpDiv.css({position: "absolute", display: "block", top: "-1000px"}); + $.datepicker._updateDatepicker(inst); + // fix width for dynamic number of date pickers + // and adjust position before showing + offset = $.datepicker._checkOffset(inst, offset, isFixed); + inst.dpDiv.css({position: ($.datepicker._inDialog && $.blockUI ? + "static" : (isFixed ? "fixed" : "absolute")), display: "none", + left: offset.left + "px", top: offset.top + "px"}); + + if (!inst.inline) { + showAnim = $.datepicker._get(inst, "showAnim"); + duration = $.datepicker._get(inst, "duration"); + inst.dpDiv.zIndex($(input).zIndex()+1); + $.datepicker._datepickerShowing = true; + + if ( $.effects && $.effects.effect[ showAnim ] ) { + inst.dpDiv.show(showAnim, $.datepicker._get(inst, "showOptions"), duration); + } else { + inst.dpDiv[showAnim || "show"](showAnim ? duration : null); + } + + if ( $.datepicker._shouldFocusInput( inst ) ) { + inst.input.focus(); + } + + $.datepicker._curInst = inst; + } + }, + + /* Generate the date picker content. */ + _updateDatepicker: function(inst) { + this.maxRows = 4; //Reset the max number of rows being displayed (see #7043) + instActive = inst; // for delegate hover events + inst.dpDiv.empty().append(this._generateHTML(inst)); + this._attachHandlers(inst); + inst.dpDiv.find("." + this._dayOverClass + " a").mouseover(); + + var origyearshtml, + numMonths = this._getNumberOfMonths(inst), + cols = numMonths[1], + width = 17; + + inst.dpDiv.removeClass("ui-datepicker-multi-2 ui-datepicker-multi-3 ui-datepicker-multi-4").width(""); + if (cols > 1) { + inst.dpDiv.addClass("ui-datepicker-multi-" + cols).css("width", (width * cols) + "em"); + } + inst.dpDiv[(numMonths[0] !== 1 || numMonths[1] !== 1 ? "add" : "remove") + + "Class"]("ui-datepicker-multi"); + inst.dpDiv[(this._get(inst, "isRTL") ? "add" : "remove") + + "Class"]("ui-datepicker-rtl"); + + if (inst === $.datepicker._curInst && $.datepicker._datepickerShowing && $.datepicker._shouldFocusInput( inst ) ) { + inst.input.focus(); + } + + // deffered render of the years select (to avoid flashes on Firefox) + if( inst.yearshtml ){ + origyearshtml = inst.yearshtml; + setTimeout(function(){ + //assure that inst.yearshtml didn't change. + if( origyearshtml === inst.yearshtml && inst.yearshtml ){ + inst.dpDiv.find("select.ui-datepicker-year:first").replaceWith(inst.yearshtml); + } + origyearshtml = inst.yearshtml = null; + }, 0); + } + }, + + // #6694 - don't focus the input if it's already focused + // this breaks the change event in IE + // Support: IE and jQuery <1.9 + _shouldFocusInput: function( inst ) { + return inst.input && inst.input.is( ":visible" ) && !inst.input.is( ":disabled" ) && !inst.input.is( ":focus" ); + }, + + /* Check positioning to remain on screen. */ + _checkOffset: function(inst, offset, isFixed) { + var dpWidth = inst.dpDiv.outerWidth(), + dpHeight = inst.dpDiv.outerHeight(), + inputWidth = inst.input ? inst.input.outerWidth() : 0, + inputHeight = inst.input ? inst.input.outerHeight() : 0, + viewWidth = document.documentElement.clientWidth + (isFixed ? 0 : $(document).scrollLeft()), + viewHeight = document.documentElement.clientHeight + (isFixed ? 0 : $(document).scrollTop()); + + offset.left -= (this._get(inst, "isRTL") ? (dpWidth - inputWidth) : 0); + offset.left -= (isFixed && offset.left === inst.input.offset().left) ? $(document).scrollLeft() : 0; + offset.top -= (isFixed && offset.top === (inst.input.offset().top + inputHeight)) ? $(document).scrollTop() : 0; + + // now check if datepicker is showing outside window viewport - move to a better place if so. + offset.left -= Math.min(offset.left, (offset.left + dpWidth > viewWidth && viewWidth > dpWidth) ? + Math.abs(offset.left + dpWidth - viewWidth) : 0); + offset.top -= Math.min(offset.top, (offset.top + dpHeight > viewHeight && viewHeight > dpHeight) ? + Math.abs(dpHeight + inputHeight) : 0); + + return offset; + }, + + /* Find an object's position on the screen. */ + _findPos: function(obj) { + var position, + inst = this._getInst(obj), + isRTL = this._get(inst, "isRTL"); + + while (obj && (obj.type === "hidden" || obj.nodeType !== 1 || $.expr.filters.hidden(obj))) { + obj = obj[isRTL ? "previousSibling" : "nextSibling"]; + } + + position = $(obj).offset(); + return [position.left, position.top]; + }, + + /* Hide the date picker from view. + * @param input element - the input field attached to the date picker + */ + _hideDatepicker: function(input) { + var showAnim, duration, postProcess, onClose, + inst = this._curInst; + + if (!inst || (input && inst !== $.data(input, PROP_NAME))) { + return; + } + + if (this._datepickerShowing) { + showAnim = this._get(inst, "showAnim"); + duration = this._get(inst, "duration"); + postProcess = function() { + $.datepicker._tidyDialog(inst); + }; + + // DEPRECATED: after BC for 1.8.x $.effects[ showAnim ] is not needed + if ( $.effects && ( $.effects.effect[ showAnim ] || $.effects[ showAnim ] ) ) { + inst.dpDiv.hide(showAnim, $.datepicker._get(inst, "showOptions"), duration, postProcess); + } else { + inst.dpDiv[(showAnim === "slideDown" ? "slideUp" : + (showAnim === "fadeIn" ? "fadeOut" : "hide"))]((showAnim ? duration : null), postProcess); + } + + if (!showAnim) { + postProcess(); + } + this._datepickerShowing = false; + + onClose = this._get(inst, "onClose"); + if (onClose) { + onClose.apply((inst.input ? inst.input[0] : null), [(inst.input ? inst.input.val() : ""), inst]); + } + + this._lastInput = null; + if (this._inDialog) { + this._dialogInput.css({ position: "absolute", left: "0", top: "-100px" }); + if ($.blockUI) { + $.unblockUI(); + $("body").append(this.dpDiv); + } + } + this._inDialog = false; + } + }, + + /* Tidy up after a dialog display. */ + _tidyDialog: function(inst) { + inst.dpDiv.removeClass(this._dialogClass).unbind(".ui-datepicker-calendar"); + }, + + /* Close date picker if clicked elsewhere. */ + _checkExternalClick: function(event) { + if (!$.datepicker._curInst) { + return; + } + + var $target = $(event.target), + inst = $.datepicker._getInst($target[0]); + + if ( ( ( $target[0].id !== $.datepicker._mainDivId && + $target.parents("#" + $.datepicker._mainDivId).length === 0 && + !$target.hasClass($.datepicker.markerClassName) && + !$target.closest("." + $.datepicker._triggerClass).length && + $.datepicker._datepickerShowing && !($.datepicker._inDialog && $.blockUI) ) ) || + ( $target.hasClass($.datepicker.markerClassName) && $.datepicker._curInst !== inst ) ) { + $.datepicker._hideDatepicker(); + } + }, + + /* Adjust one of the date sub-fields. */ + _adjustDate: function(id, offset, period) { + var target = $(id), + inst = this._getInst(target[0]); + + if (this._isDisabledDatepicker(target[0])) { + return; + } + this._adjustInstDate(inst, offset + + (period === "M" ? this._get(inst, "showCurrentAtPos") : 0), // undo positioning + period); + this._updateDatepicker(inst); + }, + + /* Action for current link. */ + _gotoToday: function(id) { + var date, + target = $(id), + inst = this._getInst(target[0]); + + if (this._get(inst, "gotoCurrent") && inst.currentDay) { + inst.selectedDay = inst.currentDay; + inst.drawMonth = inst.selectedMonth = inst.currentMonth; + inst.drawYear = inst.selectedYear = inst.currentYear; + } else { + date = new Date(); + inst.selectedDay = date.getDate(); + inst.drawMonth = inst.selectedMonth = date.getMonth(); + inst.drawYear = inst.selectedYear = date.getFullYear(); + } + this._notifyChange(inst); + this._adjustDate(target); + }, + + /* Action for selecting a new month/year. */ + _selectMonthYear: function(id, select, period) { + var target = $(id), + inst = this._getInst(target[0]); + + inst["selected" + (period === "M" ? "Month" : "Year")] = + inst["draw" + (period === "M" ? "Month" : "Year")] = + parseInt(select.options[select.selectedIndex].value,10); + + this._notifyChange(inst); + this._adjustDate(target); + }, + + /* Action for selecting a day. */ + _selectDay: function(id, month, year, td) { + var inst, + target = $(id); + + if ($(td).hasClass(this._unselectableClass) || this._isDisabledDatepicker(target[0])) { + return; + } + + inst = this._getInst(target[0]); + inst.selectedDay = inst.currentDay = $("a", td).html(); + inst.selectedMonth = inst.currentMonth = month; + inst.selectedYear = inst.currentYear = year; + this._selectDate(id, this._formatDate(inst, + inst.currentDay, inst.currentMonth, inst.currentYear)); + }, + + /* Erase the input field and hide the date picker. */ + _clearDate: function(id) { + var target = $(id); + this._selectDate(target, ""); + }, + + /* Update the input field with the selected date. */ + _selectDate: function(id, dateStr) { + var onSelect, + target = $(id), + inst = this._getInst(target[0]); + + dateStr = (dateStr != null ? dateStr : this._formatDate(inst)); + if (inst.input) { + inst.input.val(dateStr); + } + this._updateAlternate(inst); + + onSelect = this._get(inst, "onSelect"); + if (onSelect) { + onSelect.apply((inst.input ? inst.input[0] : null), [dateStr, inst]); // trigger custom callback + } else if (inst.input) { + inst.input.trigger("change"); // fire the change event + } + + if (inst.inline){ + this._updateDatepicker(inst); + } else { + this._hideDatepicker(); + this._lastInput = inst.input[0]; + if (typeof(inst.input[0]) !== "object") { + inst.input.focus(); // restore focus + } + this._lastInput = null; + } + }, + + /* Update any alternate field to synchronise with the main field. */ + _updateAlternate: function(inst) { + var altFormat, date, dateStr, + altField = this._get(inst, "altField"); + + if (altField) { // update alternate field too + altFormat = this._get(inst, "altFormat") || this._get(inst, "dateFormat"); + date = this._getDate(inst); + dateStr = this.formatDate(altFormat, date, this._getFormatConfig(inst)); + $(altField).each(function() { $(this).val(dateStr); }); + } + }, + + /* Set as beforeShowDay function to prevent selection of weekends. + * @param date Date - the date to customise + * @return [boolean, string] - is this date selectable?, what is its CSS class? + */ + noWeekends: function(date) { + var day = date.getDay(); + return [(day > 0 && day < 6), ""]; + }, + + /* Set as calculateWeek to determine the week of the year based on the ISO 8601 definition. + * @param date Date - the date to get the week for + * @return number - the number of the week within the year that contains this date + */ + iso8601Week: function(date) { + var time, + checkDate = new Date(date.getTime()); + + // Find Thursday of this week starting on Monday + checkDate.setDate(checkDate.getDate() + 4 - (checkDate.getDay() || 7)); + + time = checkDate.getTime(); + checkDate.setMonth(0); // Compare with Jan 1 + checkDate.setDate(1); + return Math.floor(Math.round((time - checkDate) / 86400000) / 7) + 1; + }, + + /* Parse a string value into a date object. + * See formatDate below for the possible formats. + * + * @param format string - the expected format of the date + * @param value string - the date in the above format + * @param settings Object - attributes include: + * shortYearCutoff number - the cutoff year for determining the century (optional) + * dayNamesShort string[7] - abbreviated names of the days from Sunday (optional) + * dayNames string[7] - names of the days from Sunday (optional) + * monthNamesShort string[12] - abbreviated names of the months (optional) + * monthNames string[12] - names of the months (optional) + * @return Date - the extracted date value or null if value is blank + */ + parseDate: function (format, value, settings) { + if (format == null || value == null) { + throw "Invalid arguments"; + } + + value = (typeof value === "object" ? value.toString() : value + ""); + if (value === "") { + return null; + } + + var iFormat, dim, extra, + iValue = 0, + shortYearCutoffTemp = (settings ? settings.shortYearCutoff : null) || this._defaults.shortYearCutoff, + shortYearCutoff = (typeof shortYearCutoffTemp !== "string" ? shortYearCutoffTemp : + new Date().getFullYear() % 100 + parseInt(shortYearCutoffTemp, 10)), + dayNamesShort = (settings ? settings.dayNamesShort : null) || this._defaults.dayNamesShort, + dayNames = (settings ? settings.dayNames : null) || this._defaults.dayNames, + monthNamesShort = (settings ? settings.monthNamesShort : null) || this._defaults.monthNamesShort, + monthNames = (settings ? settings.monthNames : null) || this._defaults.monthNames, + year = -1, + month = -1, + day = -1, + doy = -1, + literal = false, + date, + // Check whether a format character is doubled + lookAhead = function(match) { + var matches = (iFormat + 1 < format.length && format.charAt(iFormat + 1) === match); + if (matches) { + iFormat++; + } + return matches; + }, + // Extract a number from the string value + getNumber = function(match) { + var isDoubled = lookAhead(match), + size = (match === "@" ? 14 : (match === "!" ? 20 : + (match === "y" && isDoubled ? 4 : (match === "o" ? 3 : 2)))), + digits = new RegExp("^\\d{1," + size + "}"), + num = value.substring(iValue).match(digits); + if (!num) { + throw "Missing number at position " + iValue; + } + iValue += num[0].length; + return parseInt(num[0], 10); + }, + // Extract a name from the string value and convert to an index + getName = function(match, shortNames, longNames) { + var index = -1, + names = $.map(lookAhead(match) ? longNames : shortNames, function (v, k) { + return [ [k, v] ]; + }).sort(function (a, b) { + return -(a[1].length - b[1].length); + }); + + $.each(names, function (i, pair) { + var name = pair[1]; + if (value.substr(iValue, name.length).toLowerCase() === name.toLowerCase()) { + index = pair[0]; + iValue += name.length; + return false; + } + }); + if (index !== -1) { + return index + 1; + } else { + throw "Unknown name at position " + iValue; + } + }, + // Confirm that a literal character matches the string value + checkLiteral = function() { + if (value.charAt(iValue) !== format.charAt(iFormat)) { + throw "Unexpected literal at position " + iValue; + } + iValue++; + }; + + for (iFormat = 0; iFormat < format.length; iFormat++) { + if (literal) { + if (format.charAt(iFormat) === "'" && !lookAhead("'")) { + literal = false; + } else { + checkLiteral(); + } + } else { + switch (format.charAt(iFormat)) { + case "d": + day = getNumber("d"); + break; + case "D": + getName("D", dayNamesShort, dayNames); + break; + case "o": + doy = getNumber("o"); + break; + case "m": + month = getNumber("m"); + break; + case "M": + month = getName("M", monthNamesShort, monthNames); + break; + case "y": + year = getNumber("y"); + break; + case "@": + date = new Date(getNumber("@")); + year = date.getFullYear(); + month = date.getMonth() + 1; + day = date.getDate(); + break; + case "!": + date = new Date((getNumber("!") - this._ticksTo1970) / 10000); + year = date.getFullYear(); + month = date.getMonth() + 1; + day = date.getDate(); + break; + case "'": + if (lookAhead("'")){ + checkLiteral(); + } else { + literal = true; + } + break; + default: + checkLiteral(); + } + } + } + + if (iValue < value.length){ + extra = value.substr(iValue); + if (!/^\s+/.test(extra)) { + throw "Extra/unparsed characters found in date: " + extra; + } + } + + if (year === -1) { + year = new Date().getFullYear(); + } else if (year < 100) { + year += new Date().getFullYear() - new Date().getFullYear() % 100 + + (year <= shortYearCutoff ? 0 : -100); + } + + if (doy > -1) { + month = 1; + day = doy; + do { + dim = this._getDaysInMonth(year, month - 1); + if (day <= dim) { + break; + } + month++; + day -= dim; + } while (true); + } + + date = this._daylightSavingAdjust(new Date(year, month - 1, day)); + if (date.getFullYear() !== year || date.getMonth() + 1 !== month || date.getDate() !== day) { + throw "Invalid date"; // E.g. 31/02/00 + } + return date; + }, + + /* Standard date formats. */ + ATOM: "yy-mm-dd", // RFC 3339 (ISO 8601) + COOKIE: "D, dd M yy", + ISO_8601: "yy-mm-dd", + RFC_822: "D, d M y", + RFC_850: "DD, dd-M-y", + RFC_1036: "D, d M y", + RFC_1123: "D, d M yy", + RFC_2822: "D, d M yy", + RSS: "D, d M y", // RFC 822 + TICKS: "!", + TIMESTAMP: "@", + W3C: "yy-mm-dd", // ISO 8601 + + _ticksTo1970: (((1970 - 1) * 365 + Math.floor(1970 / 4) - Math.floor(1970 / 100) + + Math.floor(1970 / 400)) * 24 * 60 * 60 * 10000000), + + /* Format a date object into a string value. + * The format can be combinations of the following: + * d - day of month (no leading zero) + * dd - day of month (two digit) + * o - day of year (no leading zeros) + * oo - day of year (three digit) + * D - day name short + * DD - day name long + * m - month of year (no leading zero) + * mm - month of year (two digit) + * M - month name short + * MM - month name long + * y - year (two digit) + * yy - year (four digit) + * @ - Unix timestamp (ms since 01/01/1970) + * ! - Windows ticks (100ns since 01/01/0001) + * "..." - literal text + * '' - single quote + * + * @param format string - the desired format of the date + * @param date Date - the date value to format + * @param settings Object - attributes include: + * dayNamesShort string[7] - abbreviated names of the days from Sunday (optional) + * dayNames string[7] - names of the days from Sunday (optional) + * monthNamesShort string[12] - abbreviated names of the months (optional) + * monthNames string[12] - names of the months (optional) + * @return string - the date in the above format + */ + formatDate: function (format, date, settings) { + if (!date) { + return ""; + } + + var iFormat, + dayNamesShort = (settings ? settings.dayNamesShort : null) || this._defaults.dayNamesShort, + dayNames = (settings ? settings.dayNames : null) || this._defaults.dayNames, + monthNamesShort = (settings ? settings.monthNamesShort : null) || this._defaults.monthNamesShort, + monthNames = (settings ? settings.monthNames : null) || this._defaults.monthNames, + // Check whether a format character is doubled + lookAhead = function(match) { + var matches = (iFormat + 1 < format.length && format.charAt(iFormat + 1) === match); + if (matches) { + iFormat++; + } + return matches; + }, + // Format a number, with leading zero if necessary + formatNumber = function(match, value, len) { + var num = "" + value; + if (lookAhead(match)) { + while (num.length < len) { + num = "0" + num; + } + } + return num; + }, + // Format a name, short or long as requested + formatName = function(match, value, shortNames, longNames) { + return (lookAhead(match) ? longNames[value] : shortNames[value]); + }, + output = "", + literal = false; + + if (date) { + for (iFormat = 0; iFormat < format.length; iFormat++) { + if (literal) { + if (format.charAt(iFormat) === "'" && !lookAhead("'")) { + literal = false; + } else { + output += format.charAt(iFormat); + } + } else { + switch (format.charAt(iFormat)) { + case "d": + output += formatNumber("d", date.getDate(), 2); + break; + case "D": + output += formatName("D", date.getDay(), dayNamesShort, dayNames); + break; + case "o": + output += formatNumber("o", + Math.round((new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() - new Date(date.getFullYear(), 0, 0).getTime()) / 86400000), 3); + break; + case "m": + output += formatNumber("m", date.getMonth() + 1, 2); + break; + case "M": + output += formatName("M", date.getMonth(), monthNamesShort, monthNames); + break; + case "y": + output += (lookAhead("y") ? date.getFullYear() : + (date.getYear() % 100 < 10 ? "0" : "") + date.getYear() % 100); + break; + case "@": + output += date.getTime(); + break; + case "!": + output += date.getTime() * 10000 + this._ticksTo1970; + break; + case "'": + if (lookAhead("'")) { + output += "'"; + } else { + literal = true; + } + break; + default: + output += format.charAt(iFormat); + } + } + } + } + return output; + }, + + /* Extract all possible characters from the date format. */ + _possibleChars: function (format) { + var iFormat, + chars = "", + literal = false, + // Check whether a format character is doubled + lookAhead = function(match) { + var matches = (iFormat + 1 < format.length && format.charAt(iFormat + 1) === match); + if (matches) { + iFormat++; + } + return matches; + }; + + for (iFormat = 0; iFormat < format.length; iFormat++) { + if (literal) { + if (format.charAt(iFormat) === "'" && !lookAhead("'")) { + literal = false; + } else { + chars += format.charAt(iFormat); + } + } else { + switch (format.charAt(iFormat)) { + case "d": case "m": case "y": case "@": + chars += "0123456789"; + break; + case "D": case "M": + return null; // Accept anything + case "'": + if (lookAhead("'")) { + chars += "'"; + } else { + literal = true; + } + break; + default: + chars += format.charAt(iFormat); + } + } + } + return chars; + }, + + /* Get a setting value, defaulting if necessary. */ + _get: function(inst, name) { + return inst.settings[name] !== undefined ? + inst.settings[name] : this._defaults[name]; + }, + + /* Parse existing date and initialise date picker. */ + _setDateFromField: function(inst, noDefault) { + if (inst.input.val() === inst.lastVal) { + return; + } + + var dateFormat = this._get(inst, "dateFormat"), + dates = inst.lastVal = inst.input ? inst.input.val() : null, + defaultDate = this._getDefaultDate(inst), + date = defaultDate, + settings = this._getFormatConfig(inst); + + try { + date = this.parseDate(dateFormat, dates, settings) || defaultDate; + } catch (event) { + dates = (noDefault ? "" : dates); + } + inst.selectedDay = date.getDate(); + inst.drawMonth = inst.selectedMonth = date.getMonth(); + inst.drawYear = inst.selectedYear = date.getFullYear(); + inst.currentDay = (dates ? date.getDate() : 0); + inst.currentMonth = (dates ? date.getMonth() : 0); + inst.currentYear = (dates ? date.getFullYear() : 0); + this._adjustInstDate(inst); + }, + + /* Retrieve the default date shown on opening. */ + _getDefaultDate: function(inst) { + return this._restrictMinMax(inst, + this._determineDate(inst, this._get(inst, "defaultDate"), new Date())); + }, + + /* A date may be specified as an exact value or a relative one. */ + _determineDate: function(inst, date, defaultDate) { + var offsetNumeric = function(offset) { + var date = new Date(); + date.setDate(date.getDate() + offset); + return date; + }, + offsetString = function(offset) { + try { + return $.datepicker.parseDate($.datepicker._get(inst, "dateFormat"), + offset, $.datepicker._getFormatConfig(inst)); + } + catch (e) { + // Ignore + } + + var date = (offset.toLowerCase().match(/^c/) ? + $.datepicker._getDate(inst) : null) || new Date(), + year = date.getFullYear(), + month = date.getMonth(), + day = date.getDate(), + pattern = /([+\-]?[0-9]+)\s*(d|D|w|W|m|M|y|Y)?/g, + matches = pattern.exec(offset); + + while (matches) { + switch (matches[2] || "d") { + case "d" : case "D" : + day += parseInt(matches[1],10); break; + case "w" : case "W" : + day += parseInt(matches[1],10) * 7; break; + case "m" : case "M" : + month += parseInt(matches[1],10); + day = Math.min(day, $.datepicker._getDaysInMonth(year, month)); + break; + case "y": case "Y" : + year += parseInt(matches[1],10); + day = Math.min(day, $.datepicker._getDaysInMonth(year, month)); + break; + } + matches = pattern.exec(offset); + } + return new Date(year, month, day); + }, + newDate = (date == null || date === "" ? defaultDate : (typeof date === "string" ? offsetString(date) : + (typeof date === "number" ? (isNaN(date) ? defaultDate : offsetNumeric(date)) : new Date(date.getTime())))); + + newDate = (newDate && newDate.toString() === "Invalid Date" ? defaultDate : newDate); + if (newDate) { + newDate.setHours(0); + newDate.setMinutes(0); + newDate.setSeconds(0); + newDate.setMilliseconds(0); + } + return this._daylightSavingAdjust(newDate); + }, + + /* Handle switch to/from daylight saving. + * Hours may be non-zero on daylight saving cut-over: + * > 12 when midnight changeover, but then cannot generate + * midnight datetime, so jump to 1AM, otherwise reset. + * @param date (Date) the date to check + * @return (Date) the corrected date + */ + _daylightSavingAdjust: function(date) { + if (!date) { + return null; + } + date.setHours(date.getHours() > 12 ? date.getHours() + 2 : 0); + return date; + }, + + /* Set the date(s) directly. */ + _setDate: function(inst, date, noChange) { + var clear = !date, + origMonth = inst.selectedMonth, + origYear = inst.selectedYear, + newDate = this._restrictMinMax(inst, this._determineDate(inst, date, new Date())); + + inst.selectedDay = inst.currentDay = newDate.getDate(); + inst.drawMonth = inst.selectedMonth = inst.currentMonth = newDate.getMonth(); + inst.drawYear = inst.selectedYear = inst.currentYear = newDate.getFullYear(); + if ((origMonth !== inst.selectedMonth || origYear !== inst.selectedYear) && !noChange) { + this._notifyChange(inst); + } + this._adjustInstDate(inst); + if (inst.input) { + inst.input.val(clear ? "" : this._formatDate(inst)); + } + }, + + /* Retrieve the date(s) directly. */ + _getDate: function(inst) { + var startDate = (!inst.currentYear || (inst.input && inst.input.val() === "") ? null : + this._daylightSavingAdjust(new Date( + inst.currentYear, inst.currentMonth, inst.currentDay))); + return startDate; + }, + + /* Attach the onxxx handlers. These are declared statically so + * they work with static code transformers like Caja. + */ + _attachHandlers: function(inst) { + var stepMonths = this._get(inst, "stepMonths"), + id = "#" + inst.id.replace( /\\\\/g, "\\" ); + inst.dpDiv.find("[data-handler]").map(function () { + var handler = { + prev: function () { + $.datepicker._adjustDate(id, -stepMonths, "M"); + }, + next: function () { + $.datepicker._adjustDate(id, +stepMonths, "M"); + }, + hide: function () { + $.datepicker._hideDatepicker(); + }, + today: function () { + $.datepicker._gotoToday(id); + }, + selectDay: function () { + $.datepicker._selectDay(id, +this.getAttribute("data-month"), +this.getAttribute("data-year"), this); + return false; + }, + selectMonth: function () { + $.datepicker._selectMonthYear(id, this, "M"); + return false; + }, + selectYear: function () { + $.datepicker._selectMonthYear(id, this, "Y"); + return false; + } + }; + $(this).bind(this.getAttribute("data-event"), handler[this.getAttribute("data-handler")]); + }); + }, + + /* Generate the HTML for the current state of the date picker. */ + _generateHTML: function(inst) { + var maxDraw, prevText, prev, nextText, next, currentText, gotoDate, + controls, buttonPanel, firstDay, showWeek, dayNames, dayNamesMin, + monthNames, monthNamesShort, beforeShowDay, showOtherMonths, + selectOtherMonths, defaultDate, html, dow, row, group, col, selectedDate, + cornerClass, calender, thead, day, daysInMonth, leadDays, curRows, numRows, + printDate, dRow, tbody, daySettings, otherMonth, unselectable, + tempDate = new Date(), + today = this._daylightSavingAdjust( + new Date(tempDate.getFullYear(), tempDate.getMonth(), tempDate.getDate())), // clear time + isRTL = this._get(inst, "isRTL"), + showButtonPanel = this._get(inst, "showButtonPanel"), + hideIfNoPrevNext = this._get(inst, "hideIfNoPrevNext"), + navigationAsDateFormat = this._get(inst, "navigationAsDateFormat"), + numMonths = this._getNumberOfMonths(inst), + showCurrentAtPos = this._get(inst, "showCurrentAtPos"), + stepMonths = this._get(inst, "stepMonths"), + isMultiMonth = (numMonths[0] !== 1 || numMonths[1] !== 1), + currentDate = this._daylightSavingAdjust((!inst.currentDay ? new Date(9999, 9, 9) : + new Date(inst.currentYear, inst.currentMonth, inst.currentDay))), + minDate = this._getMinMaxDate(inst, "min"), + maxDate = this._getMinMaxDate(inst, "max"), + drawMonth = inst.drawMonth - showCurrentAtPos, + drawYear = inst.drawYear; + + if (drawMonth < 0) { + drawMonth += 12; + drawYear--; + } + if (maxDate) { + maxDraw = this._daylightSavingAdjust(new Date(maxDate.getFullYear(), + maxDate.getMonth() - (numMonths[0] * numMonths[1]) + 1, maxDate.getDate())); + maxDraw = (minDate && maxDraw < minDate ? minDate : maxDraw); + while (this._daylightSavingAdjust(new Date(drawYear, drawMonth, 1)) > maxDraw) { + drawMonth--; + if (drawMonth < 0) { + drawMonth = 11; + drawYear--; + } + } + } + inst.drawMonth = drawMonth; + inst.drawYear = drawYear; + + prevText = this._get(inst, "prevText"); + prevText = (!navigationAsDateFormat ? prevText : this.formatDate(prevText, + this._daylightSavingAdjust(new Date(drawYear, drawMonth - stepMonths, 1)), + this._getFormatConfig(inst))); + + prev = (this._canAdjustMonth(inst, -1, drawYear, drawMonth) ? + "" + prevText + "" : + (hideIfNoPrevNext ? "" : "" + prevText + "")); + + nextText = this._get(inst, "nextText"); + nextText = (!navigationAsDateFormat ? nextText : this.formatDate(nextText, + this._daylightSavingAdjust(new Date(drawYear, drawMonth + stepMonths, 1)), + this._getFormatConfig(inst))); + + next = (this._canAdjustMonth(inst, +1, drawYear, drawMonth) ? + "" + nextText + "" : + (hideIfNoPrevNext ? "" : "" + nextText + "")); + + currentText = this._get(inst, "currentText"); + gotoDate = (this._get(inst, "gotoCurrent") && inst.currentDay ? currentDate : today); + currentText = (!navigationAsDateFormat ? currentText : + this.formatDate(currentText, gotoDate, this._getFormatConfig(inst))); + + controls = (!inst.inline ? "" : ""); + + buttonPanel = (showButtonPanel) ? "
    " + (isRTL ? controls : "") + + (this._isInRange(inst, gotoDate) ? "" : "") + (isRTL ? "" : controls) + "
    " : ""; + + firstDay = parseInt(this._get(inst, "firstDay"),10); + firstDay = (isNaN(firstDay) ? 0 : firstDay); + + showWeek = this._get(inst, "showWeek"); + dayNames = this._get(inst, "dayNames"); + dayNamesMin = this._get(inst, "dayNamesMin"); + monthNames = this._get(inst, "monthNames"); + monthNamesShort = this._get(inst, "monthNamesShort"); + beforeShowDay = this._get(inst, "beforeShowDay"); + showOtherMonths = this._get(inst, "showOtherMonths"); + selectOtherMonths = this._get(inst, "selectOtherMonths"); + defaultDate = this._getDefaultDate(inst); + html = ""; + dow; + for (row = 0; row < numMonths[0]; row++) { + group = ""; + this.maxRows = 4; + for (col = 0; col < numMonths[1]; col++) { + selectedDate = this._daylightSavingAdjust(new Date(drawYear, drawMonth, inst.selectedDay)); + cornerClass = " ui-corner-all"; + calender = ""; + if (isMultiMonth) { + calender += "
    "; + } + calender += "
    " + + (/all|left/.test(cornerClass) && row === 0 ? (isRTL ? next : prev) : "") + + (/all|right/.test(cornerClass) && row === 0 ? (isRTL ? prev : next) : "") + + this._generateMonthYearHeader(inst, drawMonth, drawYear, minDate, maxDate, + row > 0 || col > 0, monthNames, monthNamesShort) + // draw month headers + "
    " + + ""; + thead = (showWeek ? "" : ""); + for (dow = 0; dow < 7; dow++) { // days of the week + day = (dow + firstDay) % 7; + thead += "= 5 ? " class='ui-datepicker-week-end'" : "") + ">" + + "" + dayNamesMin[day] + ""; + } + calender += thead + ""; + daysInMonth = this._getDaysInMonth(drawYear, drawMonth); + if (drawYear === inst.selectedYear && drawMonth === inst.selectedMonth) { + inst.selectedDay = Math.min(inst.selectedDay, daysInMonth); + } + leadDays = (this._getFirstDayOfMonth(drawYear, drawMonth) - firstDay + 7) % 7; + curRows = Math.ceil((leadDays + daysInMonth) / 7); // calculate the number of rows to generate + numRows = (isMultiMonth ? this.maxRows > curRows ? this.maxRows : curRows : curRows); //If multiple months, use the higher number of rows (see #7043) + this.maxRows = numRows; + printDate = this._daylightSavingAdjust(new Date(drawYear, drawMonth, 1 - leadDays)); + for (dRow = 0; dRow < numRows; dRow++) { // create date picker rows + calender += ""; + tbody = (!showWeek ? "" : ""); + for (dow = 0; dow < 7; dow++) { // create date picker days + daySettings = (beforeShowDay ? + beforeShowDay.apply((inst.input ? inst.input[0] : null), [printDate]) : [true, ""]); + otherMonth = (printDate.getMonth() !== drawMonth); + unselectable = (otherMonth && !selectOtherMonths) || !daySettings[0] || + (minDate && printDate < minDate) || (maxDate && printDate > maxDate); + tbody += ""; // display selectable date + printDate.setDate(printDate.getDate() + 1); + printDate = this._daylightSavingAdjust(printDate); + } + calender += tbody + ""; + } + drawMonth++; + if (drawMonth > 11) { + drawMonth = 0; + drawYear++; + } + calender += "
    " + this._get(inst, "weekHeader") + "
    " + + this._get(inst, "calculateWeek")(printDate) + "" + // actions + (otherMonth && !showOtherMonths ? " " : // display for other months + (unselectable ? "" + printDate.getDate() + "" : "" + printDate.getDate() + "")) + "
    " + (isMultiMonth ? "
    " + + ((numMonths[0] > 0 && col === numMonths[1]-1) ? "
    " : "") : ""); + group += calender; + } + html += group; + } + html += buttonPanel; + inst._keyEvent = false; + return html; + }, + + /* Generate the month and year header. */ + _generateMonthYearHeader: function(inst, drawMonth, drawYear, minDate, maxDate, + secondary, monthNames, monthNamesShort) { + + var inMinYear, inMaxYear, month, years, thisYear, determineYear, year, endYear, + changeMonth = this._get(inst, "changeMonth"), + changeYear = this._get(inst, "changeYear"), + showMonthAfterYear = this._get(inst, "showMonthAfterYear"), + html = "
    ", + monthHtml = ""; + + // month selection + if (secondary || !changeMonth) { + monthHtml += "" + monthNames[drawMonth] + ""; + } else { + inMinYear = (minDate && minDate.getFullYear() === drawYear); + inMaxYear = (maxDate && maxDate.getFullYear() === drawYear); + monthHtml += ""; + } + + if (!showMonthAfterYear) { + html += monthHtml + (secondary || !(changeMonth && changeYear) ? " " : ""); + } + + // year selection + if ( !inst.yearshtml ) { + inst.yearshtml = ""; + if (secondary || !changeYear) { + html += "" + drawYear + ""; + } else { + // determine range of years to display + years = this._get(inst, "yearRange").split(":"); + thisYear = new Date().getFullYear(); + determineYear = function(value) { + var year = (value.match(/c[+\-].*/) ? drawYear + parseInt(value.substring(1), 10) : + (value.match(/[+\-].*/) ? thisYear + parseInt(value, 10) : + parseInt(value, 10))); + return (isNaN(year) ? thisYear : year); + }; + year = determineYear(years[0]); + endYear = Math.max(year, determineYear(years[1] || "")); + year = (minDate ? Math.max(year, minDate.getFullYear()) : year); + endYear = (maxDate ? Math.min(endYear, maxDate.getFullYear()) : endYear); + inst.yearshtml += ""; + + html += inst.yearshtml; + inst.yearshtml = null; + } + } + + html += this._get(inst, "yearSuffix"); + if (showMonthAfterYear) { + html += (secondary || !(changeMonth && changeYear) ? " " : "") + monthHtml; + } + html += "
    "; // Close datepicker_header + return html; + }, + + /* Adjust one of the date sub-fields. */ + _adjustInstDate: function(inst, offset, period) { + var year = inst.drawYear + (period === "Y" ? offset : 0), + month = inst.drawMonth + (period === "M" ? offset : 0), + day = Math.min(inst.selectedDay, this._getDaysInMonth(year, month)) + (period === "D" ? offset : 0), + date = this._restrictMinMax(inst, this._daylightSavingAdjust(new Date(year, month, day))); + + inst.selectedDay = date.getDate(); + inst.drawMonth = inst.selectedMonth = date.getMonth(); + inst.drawYear = inst.selectedYear = date.getFullYear(); + if (period === "M" || period === "Y") { + this._notifyChange(inst); + } + }, + + /* Ensure a date is within any min/max bounds. */ + _restrictMinMax: function(inst, date) { + var minDate = this._getMinMaxDate(inst, "min"), + maxDate = this._getMinMaxDate(inst, "max"), + newDate = (minDate && date < minDate ? minDate : date); + return (maxDate && newDate > maxDate ? maxDate : newDate); + }, + + /* Notify change of month/year. */ + _notifyChange: function(inst) { + var onChange = this._get(inst, "onChangeMonthYear"); + if (onChange) { + onChange.apply((inst.input ? inst.input[0] : null), + [inst.selectedYear, inst.selectedMonth + 1, inst]); + } + }, + + /* Determine the number of months to show. */ + _getNumberOfMonths: function(inst) { + var numMonths = this._get(inst, "numberOfMonths"); + return (numMonths == null ? [1, 1] : (typeof numMonths === "number" ? [1, numMonths] : numMonths)); + }, + + /* Determine the current maximum date - ensure no time components are set. */ + _getMinMaxDate: function(inst, minMax) { + return this._determineDate(inst, this._get(inst, minMax + "Date"), null); + }, + + /* Find the number of days in a given month. */ + _getDaysInMonth: function(year, month) { + return 32 - this._daylightSavingAdjust(new Date(year, month, 32)).getDate(); + }, + + /* Find the day of the week of the first of a month. */ + _getFirstDayOfMonth: function(year, month) { + return new Date(year, month, 1).getDay(); + }, + + /* Determines if we should allow a "next/prev" month display change. */ + _canAdjustMonth: function(inst, offset, curYear, curMonth) { + var numMonths = this._getNumberOfMonths(inst), + date = this._daylightSavingAdjust(new Date(curYear, + curMonth + (offset < 0 ? offset : numMonths[0] * numMonths[1]), 1)); + + if (offset < 0) { + date.setDate(this._getDaysInMonth(date.getFullYear(), date.getMonth())); + } + return this._isInRange(inst, date); + }, + + /* Is the given date in the accepted range? */ + _isInRange: function(inst, date) { + var yearSplit, currentYear, + minDate = this._getMinMaxDate(inst, "min"), + maxDate = this._getMinMaxDate(inst, "max"), + minYear = null, + maxYear = null, + years = this._get(inst, "yearRange"); + if (years){ + yearSplit = years.split(":"); + currentYear = new Date().getFullYear(); + minYear = parseInt(yearSplit[0], 10); + maxYear = parseInt(yearSplit[1], 10); + if ( yearSplit[0].match(/[+\-].*/) ) { + minYear += currentYear; + } + if ( yearSplit[1].match(/[+\-].*/) ) { + maxYear += currentYear; + } + } + + return ((!minDate || date.getTime() >= minDate.getTime()) && + (!maxDate || date.getTime() <= maxDate.getTime()) && + (!minYear || date.getFullYear() >= minYear) && + (!maxYear || date.getFullYear() <= maxYear)); + }, + + /* Provide the configuration settings for formatting/parsing. */ + _getFormatConfig: function(inst) { + var shortYearCutoff = this._get(inst, "shortYearCutoff"); + shortYearCutoff = (typeof shortYearCutoff !== "string" ? shortYearCutoff : + new Date().getFullYear() % 100 + parseInt(shortYearCutoff, 10)); + return {shortYearCutoff: shortYearCutoff, + dayNamesShort: this._get(inst, "dayNamesShort"), dayNames: this._get(inst, "dayNames"), + monthNamesShort: this._get(inst, "monthNamesShort"), monthNames: this._get(inst, "monthNames")}; + }, + + /* Format the given date for display. */ + _formatDate: function(inst, day, month, year) { + if (!day) { + inst.currentDay = inst.selectedDay; + inst.currentMonth = inst.selectedMonth; + inst.currentYear = inst.selectedYear; + } + var date = (day ? (typeof day === "object" ? day : + this._daylightSavingAdjust(new Date(year, month, day))) : + this._daylightSavingAdjust(new Date(inst.currentYear, inst.currentMonth, inst.currentDay))); + return this.formatDate(this._get(inst, "dateFormat"), date, this._getFormatConfig(inst)); + } +}); + +/* + * Bind hover events for datepicker elements. + * Done via delegate so the binding only occurs once in the lifetime of the parent div. + * Global instActive, set by _updateDatepicker allows the handlers to find their way back to the active picker. + */ +function bindHover(dpDiv) { + var selector = "button, .ui-datepicker-prev, .ui-datepicker-next, .ui-datepicker-calendar td a"; + return dpDiv.delegate(selector, "mouseout", function() { + $(this).removeClass("ui-state-hover"); + if (this.className.indexOf("ui-datepicker-prev") !== -1) { + $(this).removeClass("ui-datepicker-prev-hover"); + } + if (this.className.indexOf("ui-datepicker-next") !== -1) { + $(this).removeClass("ui-datepicker-next-hover"); + } + }) + .delegate(selector, "mouseover", function(){ + if (!$.datepicker._isDisabledDatepicker( instActive.inline ? dpDiv.parent()[0] : instActive.input[0])) { + $(this).parents(".ui-datepicker-calendar").find("a").removeClass("ui-state-hover"); + $(this).addClass("ui-state-hover"); + if (this.className.indexOf("ui-datepicker-prev") !== -1) { + $(this).addClass("ui-datepicker-prev-hover"); + } + if (this.className.indexOf("ui-datepicker-next") !== -1) { + $(this).addClass("ui-datepicker-next-hover"); + } + } + }); +} + +/* jQuery extend now ignores nulls! */ +function extendRemove(target, props) { + $.extend(target, props); + for (var name in props) { + if (props[name] == null) { + target[name] = props[name]; + } + } + return target; +} + +/* Invoke the datepicker functionality. + @param options string - a command, optionally followed by additional parameters or + Object - settings for attaching new datepicker functionality + @return jQuery object */ +$.fn.datepicker = function(options){ + + /* Verify an empty collection wasn't passed - Fixes #6976 */ + if ( !this.length ) { + return this; + } + + /* Initialise the date picker. */ + if (!$.datepicker.initialized) { + $(document).mousedown($.datepicker._checkExternalClick); + $.datepicker.initialized = true; + } + + /* Append datepicker main container to body if not exist. */ + if ($("#"+$.datepicker._mainDivId).length === 0) { + $("body").append($.datepicker.dpDiv); + } + + var otherArgs = Array.prototype.slice.call(arguments, 1); + if (typeof options === "string" && (options === "isDisabled" || options === "getDate" || options === "widget")) { + return $.datepicker["_" + options + "Datepicker"]. + apply($.datepicker, [this[0]].concat(otherArgs)); + } + if (options === "option" && arguments.length === 2 && typeof arguments[1] === "string") { + return $.datepicker["_" + options + "Datepicker"]. + apply($.datepicker, [this[0]].concat(otherArgs)); + } + return this.each(function() { + typeof options === "string" ? + $.datepicker["_" + options + "Datepicker"]. + apply($.datepicker, [this].concat(otherArgs)) : + $.datepicker._attachDatepicker(this, options); + }); +}; + +$.datepicker = new Datepicker(); // singleton instance +$.datepicker.initialized = false; +$.datepicker.uuid = new Date().getTime(); +$.datepicker.version = "1.10.4"; + +})(jQuery); +(function( $, undefined ) { + +var sizeRelatedOptions = { + buttons: true, + height: true, + maxHeight: true, + maxWidth: true, + minHeight: true, + minWidth: true, + width: true + }, + resizableRelatedOptions = { + maxHeight: true, + maxWidth: true, + minHeight: true, + minWidth: true + }; + +$.widget( "ui.dialog", { + version: "1.10.4", + options: { + appendTo: "body", + autoOpen: true, + buttons: [], + closeOnEscape: true, + closeText: "close", + dialogClass: "", + draggable: true, + hide: null, + height: "auto", + maxHeight: null, + maxWidth: null, + minHeight: 150, + minWidth: 150, + modal: false, + position: { + my: "center", + at: "center", + of: window, + collision: "fit", + // Ensure the titlebar is always visible + using: function( pos ) { + var topOffset = $( this ).css( pos ).offset().top; + if ( topOffset < 0 ) { + $( this ).css( "top", pos.top - topOffset ); + } + } + }, + resizable: true, + show: null, + title: null, + width: 300, + + // callbacks + beforeClose: null, + close: null, + drag: null, + dragStart: null, + dragStop: null, + focus: null, + open: null, + resize: null, + resizeStart: null, + resizeStop: null + }, + + _create: function() { + this.originalCss = { + display: this.element[0].style.display, + width: this.element[0].style.width, + minHeight: this.element[0].style.minHeight, + maxHeight: this.element[0].style.maxHeight, + height: this.element[0].style.height + }; + this.originalPosition = { + parent: this.element.parent(), + index: this.element.parent().children().index( this.element ) + }; + this.originalTitle = this.element.attr("title"); + this.options.title = this.options.title || this.originalTitle; + + this._createWrapper(); + + this.element + .show() + .removeAttr("title") + .addClass("ui-dialog-content ui-widget-content") + .appendTo( this.uiDialog ); + + this._createTitlebar(); + this._createButtonPane(); + + if ( this.options.draggable && $.fn.draggable ) { + this._makeDraggable(); + } + if ( this.options.resizable && $.fn.resizable ) { + this._makeResizable(); + } + + this._isOpen = false; + }, + + _init: function() { + if ( this.options.autoOpen ) { + this.open(); + } + }, + + _appendTo: function() { + var element = this.options.appendTo; + if ( element && (element.jquery || element.nodeType) ) { + return $( element ); + } + return this.document.find( element || "body" ).eq( 0 ); + }, + + _destroy: function() { + var next, + originalPosition = this.originalPosition; + + this._destroyOverlay(); + + this.element + .removeUniqueId() + .removeClass("ui-dialog-content ui-widget-content") + .css( this.originalCss ) + // Without detaching first, the following becomes really slow + .detach(); + + this.uiDialog.stop( true, true ).remove(); + + if ( this.originalTitle ) { + this.element.attr( "title", this.originalTitle ); + } + + next = originalPosition.parent.children().eq( originalPosition.index ); + // Don't try to place the dialog next to itself (#8613) + if ( next.length && next[0] !== this.element[0] ) { + next.before( this.element ); + } else { + originalPosition.parent.append( this.element ); + } + }, + + widget: function() { + return this.uiDialog; + }, + + disable: $.noop, + enable: $.noop, + + close: function( event ) { + var activeElement, + that = this; + + if ( !this._isOpen || this._trigger( "beforeClose", event ) === false ) { + return; + } + + this._isOpen = false; + this._destroyOverlay(); + + if ( !this.opener.filter(":focusable").focus().length ) { + + // support: IE9 + // IE9 throws an "Unspecified error" accessing document.activeElement from an