").append(x.parseHTML(e)).find(i):e)}).complete(r&&function(e,t){s.each(r,o||[e.responseText,t,e])}),this},x.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){x.fn[t]=function(e){return this.on(t,e)}}),x.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:yn,type:"GET",isLocal:Cn.test(mn[1]),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Dn,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/xml/,html:/html/,json:/json/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":x.parseJSON,"text xml":x.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(e,t){return t?_n(_n(e,x.ajaxSettings),t):_n(x.ajaxSettings,e)},ajaxPrefilter:Hn(An),ajaxTransport:Hn(jn),ajax:function(e,n){"object"==typeof e&&(n=e,e=t),n=n||{};var r,i,o,a,s,l,u,c,p=x.ajaxSetup({},n),f=p.context||p,d=p.context&&(f.nodeType||f.jquery)?x(f):x.event,h=x.Deferred(),g=x.Callbacks("once memory"),m=p.statusCode||{},y={},v={},b=0,w="canceled",C={readyState:0,getResponseHeader:function(e){var t;if(2===b){if(!c){c={};while(t=Tn.exec(a))c[t[1].toLowerCase()]=t[2]}t=c[e.toLowerCase()]}return null==t?null:t},getAllResponseHeaders:function(){return 2===b?a:null},setRequestHeader:function(e,t){var n=e.toLowerCase();return b||(e=v[n]=v[n]||e,y[e]=t),this},overrideMimeType:function(e){return b||(p.mimeType=e),this},statusCode:function(e){var t;if(e)if(2>b)for(t in e)m[t]=[m[t],e[t]];else C.always(e[C.status]);return this},abort:function(e){var t=e||w;return u&&u.abort(t),k(0,t),this}};if(h.promise(C).complete=g.add,C.success=C.done,C.error=C.fail,p.url=((e||p.url||yn)+"").replace(xn,"").replace(kn,mn[1]+"//"),p.type=n.method||n.type||p.method||p.type,p.dataTypes=x.trim(p.dataType||"*").toLowerCase().match(T)||[""],null==p.crossDomain&&(r=En.exec(p.url.toLowerCase()),p.crossDomain=!(!r||r[1]===mn[1]&&r[2]===mn[2]&&(r[3]||("http:"===r[1]?"80":"443"))===(mn[3]||("http:"===mn[1]?"80":"443")))),p.data&&p.processData&&"string"!=typeof p.data&&(p.data=x.param(p.data,p.traditional)),qn(An,p,n,C),2===b)return C;l=p.global,l&&0===x.active++&&x.event.trigger("ajaxStart"),p.type=p.type.toUpperCase(),p.hasContent=!Nn.test(p.type),o=p.url,p.hasContent||(p.data&&(o=p.url+=(bn.test(o)?"&":"?")+p.data,delete p.data),p.cache===!1&&(p.url=wn.test(o)?o.replace(wn,"$1_="+vn++):o+(bn.test(o)?"&":"?")+"_="+vn++)),p.ifModified&&(x.lastModified[o]&&C.setRequestHeader("If-Modified-Since",x.lastModified[o]),x.etag[o]&&C.setRequestHeader("If-None-Match",x.etag[o])),(p.data&&p.hasContent&&p.contentType!==!1||n.contentType)&&C.setRequestHeader("Content-Type",p.contentType),C.setRequestHeader("Accept",p.dataTypes[0]&&p.accepts[p.dataTypes[0]]?p.accepts[p.dataTypes[0]]+("*"!==p.dataTypes[0]?", "+Dn+"; q=0.01":""):p.accepts["*"]);for(i in p.headers)C.setRequestHeader(i,p.headers[i]);if(p.beforeSend&&(p.beforeSend.call(f,C,p)===!1||2===b))return C.abort();w="abort";for(i in{success:1,error:1,complete:1})C[i](p[i]);if(u=qn(jn,p,n,C)){C.readyState=1,l&&d.trigger("ajaxSend",[C,p]),p.async&&p.timeout>0&&(s=setTimeout(function(){C.abort("timeout")},p.timeout));try{b=1,u.send(y,k)}catch(N){if(!(2>b))throw N;k(-1,N)}}else k(-1,"No Transport");function k(e,n,r,i){var c,y,v,w,T,N=n;2!==b&&(b=2,s&&clearTimeout(s),u=t,a=i||"",C.readyState=e>0?4:0,c=e>=200&&300>e||304===e,r&&(w=Mn(p,C,r)),w=On(p,w,C,c),c?(p.ifModified&&(T=C.getResponseHeader("Last-Modified"),T&&(x.lastModified[o]=T),T=C.getResponseHeader("etag"),T&&(x.etag[o]=T)),204===e||"HEAD"===p.type?N="nocontent":304===e?N="notmodified":(N=w.state,y=w.data,v=w.error,c=!v)):(v=N,(e||!N)&&(N="error",0>e&&(e=0))),C.status=e,C.statusText=(n||N)+"",c?h.resolveWith(f,[y,N,C]):h.rejectWith(f,[C,N,v]),C.statusCode(m),m=t,l&&d.trigger(c?"ajaxSuccess":"ajaxError",[C,p,c?y:v]),g.fireWith(f,[C,N]),l&&(d.trigger("ajaxComplete",[C,p]),--x.active||x.event.trigger("ajaxStop")))}return C},getJSON:function(e,t,n){return x.get(e,t,n,"json")},getScript:function(e,n){return x.get(e,t,n,"script")}}),x.each(["get","post"],function(e,n){x[n]=function(e,r,i,o){return x.isFunction(r)&&(o=o||i,i=r,r=t),x.ajax({url:e,type:n,dataType:o,data:r,success:i})}});function Mn(e,n,r){var i,o,a,s,l=e.contents,u=e.dataTypes;while("*"===u[0])u.shift(),o===t&&(o=e.mimeType||n.getResponseHeader("Content-Type"));if(o)for(s in l)if(l[s]&&l[s].test(o)){u.unshift(s);break}if(u[0]in r)a=u[0];else{for(s in r){if(!u[0]||e.converters[s+" "+u[0]]){a=s;break}i||(i=s)}a=a||i}return a?(a!==u[0]&&u.unshift(a),r[a]):t}function On(e,t,n,r){var i,o,a,s,l,u={},c=e.dataTypes.slice();if(c[1])for(a in e.converters)u[a.toLowerCase()]=e.converters[a];o=c.shift();while(o)if(e.responseFields[o]&&(n[e.responseFields[o]]=t),!l&&r&&e.dataFilter&&(t=e.dataFilter(t,e.dataType)),l=o,o=c.shift())if("*"===o)o=l;else if("*"!==l&&l!==o){if(a=u[l+" "+o]||u["* "+o],!a)for(i in u)if(s=i.split(" "),s[1]===o&&(a=u[l+" "+s[0]]||u["* "+s[0]])){a===!0?a=u[i]:u[i]!==!0&&(o=s[0],c.unshift(s[1]));break}if(a!==!0)if(a&&e["throws"])t=a(t);else try{t=a(t)}catch(p){return{state:"parsererror",error:a?p:"No conversion from "+l+" to "+o}}}return{state:"success",data:t}}x.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/(?:java|ecma)script/},converters:{"text script":function(e){return x.globalEval(e),e}}}),x.ajaxPrefilter("script",function(e){e.cache===t&&(e.cache=!1),e.crossDomain&&(e.type="GET",e.global=!1)}),x.ajaxTransport("script",function(e){if(e.crossDomain){var n,r=a.head||x("head")[0]||a.documentElement;return{send:function(t,i){n=a.createElement("script"),n.async=!0,e.scriptCharset&&(n.charset=e.scriptCharset),n.src=e.url,n.onload=n.onreadystatechange=function(e,t){(t||!n.readyState||/loaded|complete/.test(n.readyState))&&(n.onload=n.onreadystatechange=null,n.parentNode&&n.parentNode.removeChild(n),n=null,t||i(200,"success"))},r.insertBefore(n,r.firstChild)},abort:function(){n&&n.onload(t,!0)}}}});var Fn=[],Bn=/(=)\?(?=&|$)|\?\?/;x.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Fn.pop()||x.expando+"_"+vn++;return this[e]=!0,e}}),x.ajaxPrefilter("json jsonp",function(n,r,i){var o,a,s,l=n.jsonp!==!1&&(Bn.test(n.url)?"url":"string"==typeof n.data&&!(n.contentType||"").indexOf("application/x-www-form-urlencoded")&&Bn.test(n.data)&&"data");return l||"jsonp"===n.dataTypes[0]?(o=n.jsonpCallback=x.isFunction(n.jsonpCallback)?n.jsonpCallback():n.jsonpCallback,l?n[l]=n[l].replace(Bn,"$1"+o):n.jsonp!==!1&&(n.url+=(bn.test(n.url)?"&":"?")+n.jsonp+"="+o),n.converters["script json"]=function(){return s||x.error(o+" was not called"),s[0]},n.dataTypes[0]="json",a=e[o],e[o]=function(){s=arguments},i.always(function(){e[o]=a,n[o]&&(n.jsonpCallback=r.jsonpCallback,Fn.push(o)),s&&x.isFunction(a)&&a(s[0]),s=a=t}),"script"):t});var Pn,Rn,Wn=0,$n=e.ActiveXObject&&function(){var e;for(e in Pn)Pn[e](t,!0)};function In(){try{return new e.XMLHttpRequest}catch(t){}}function zn(){try{return new e.ActiveXObject("Microsoft.XMLHTTP")}catch(t){}}x.ajaxSettings.xhr=e.ActiveXObject?function(){return!this.isLocal&&In()||zn()}:In,Rn=x.ajaxSettings.xhr(),x.support.cors=!!Rn&&"withCredentials"in Rn,Rn=x.support.ajax=!!Rn,Rn&&x.ajaxTransport(function(n){if(!n.crossDomain||x.support.cors){var r;return{send:function(i,o){var a,s,l=n.xhr();if(n.username?l.open(n.type,n.url,n.async,n.username,n.password):l.open(n.type,n.url,n.async),n.xhrFields)for(s in n.xhrFields)l[s]=n.xhrFields[s];n.mimeType&&l.overrideMimeType&&l.overrideMimeType(n.mimeType),n.crossDomain||i["X-Requested-With"]||(i["X-Requested-With"]="XMLHttpRequest");try{for(s in i)l.setRequestHeader(s,i[s])}catch(u){}l.send(n.hasContent&&n.data||null),r=function(e,i){var s,u,c,p;try{if(r&&(i||4===l.readyState))if(r=t,a&&(l.onreadystatechange=x.noop,$n&&delete Pn[a]),i)4!==l.readyState&&l.abort();else{p={},s=l.status,u=l.getAllResponseHeaders(),"string"==typeof l.responseText&&(p.text=l.responseText);try{c=l.statusText}catch(f){c=""}s||!n.isLocal||n.crossDomain?1223===s&&(s=204):s=p.text?200:404}}catch(d){i||o(-1,d)}p&&o(s,c,p,u)},n.async?4===l.readyState?setTimeout(r):(a=++Wn,$n&&(Pn||(Pn={},x(e).unload($n)),Pn[a]=r),l.onreadystatechange=r):r()},abort:function(){r&&r(t,!0)}}}});var Xn,Un,Vn=/^(?:toggle|show|hide)$/,Yn=RegExp("^(?:([+-])=|)("+w+")([a-z%]*)$","i"),Jn=/queueHooks$/,Gn=[nr],Qn={"*":[function(e,t){var n=this.createTween(e,t),r=n.cur(),i=Yn.exec(t),o=i&&i[3]||(x.cssNumber[e]?"":"px"),a=(x.cssNumber[e]||"px"!==o&&+r)&&Yn.exec(x.css(n.elem,e)),s=1,l=20;if(a&&a[3]!==o){o=o||a[3],i=i||[],a=+r||1;do s=s||".5",a/=s,x.style(n.elem,e,a+o);while(s!==(s=n.cur()/r)&&1!==s&&--l)}return i&&(a=n.start=+a||+r||0,n.unit=o,n.end=i[1]?a+(i[1]+1)*i[2]:+i[2]),n}]};function Kn(){return setTimeout(function(){Xn=t}),Xn=x.now()}function Zn(e,t,n){var r,i=(Qn[t]||[]).concat(Qn["*"]),o=0,a=i.length;for(;a>o;o++)if(r=i[o].call(n,t,e))return r}function er(e,t,n){var r,i,o=0,a=Gn.length,s=x.Deferred().always(function(){delete l.elem}),l=function(){if(i)return!1;var t=Xn||Kn(),n=Math.max(0,u.startTime+u.duration-t),r=n/u.duration||0,o=1-r,a=0,l=u.tweens.length;for(;l>a;a++)u.tweens[a].run(o);return s.notifyWith(e,[u,o,n]),1>o&&l?n:(s.resolveWith(e,[u]),!1)},u=s.promise({elem:e,props:x.extend({},t),opts:x.extend(!0,{specialEasing:{}},n),originalProperties:t,originalOptions:n,startTime:Xn||Kn(),duration:n.duration,tweens:[],createTween:function(t,n){var r=x.Tween(e,u.opts,t,n,u.opts.specialEasing[t]||u.opts.easing);return u.tweens.push(r),r},stop:function(t){var n=0,r=t?u.tweens.length:0;if(i)return this;for(i=!0;r>n;n++)u.tweens[n].run(1);return t?s.resolveWith(e,[u,t]):s.rejectWith(e,[u,t]),this}}),c=u.props;for(tr(c,u.opts.specialEasing);a>o;o++)if(r=Gn[o].call(u,e,c,u.opts))return r;return x.map(c,Zn,u),x.isFunction(u.opts.start)&&u.opts.start.call(e,u),x.fx.timer(x.extend(l,{elem:e,anim:u,queue:u.opts.queue})),u.progress(u.opts.progress).done(u.opts.done,u.opts.complete).fail(u.opts.fail).always(u.opts.always)}function tr(e,t){var n,r,i,o,a;for(n in e)if(r=x.camelCase(n),i=t[r],o=e[n],x.isArray(o)&&(i=o[1],o=e[n]=o[0]),n!==r&&(e[r]=o,delete e[n]),a=x.cssHooks[r],a&&"expand"in a){o=a.expand(o),delete e[r];for(n in o)n in e||(e[n]=o[n],t[n]=i)}else t[r]=i}x.Animation=x.extend(er,{tweener:function(e,t){x.isFunction(e)?(t=e,e=["*"]):e=e.split(" ");var n,r=0,i=e.length;for(;i>r;r++)n=e[r],Qn[n]=Qn[n]||[],Qn[n].unshift(t)},prefilter:function(e,t){t?Gn.unshift(e):Gn.push(e)}});function nr(e,t,n){var r,i,o,a,s,l,u=this,c={},p=e.style,f=e.nodeType&&nn(e),d=x._data(e,"fxshow");n.queue||(s=x._queueHooks(e,"fx"),null==s.unqueued&&(s.unqueued=0,l=s.empty.fire,s.empty.fire=function(){s.unqueued||l()}),s.unqueued++,u.always(function(){u.always(function(){s.unqueued--,x.queue(e,"fx").length||s.empty.fire()})})),1===e.nodeType&&("height"in t||"width"in t)&&(n.overflow=[p.overflow,p.overflowX,p.overflowY],"inline"===x.css(e,"display")&&"none"===x.css(e,"float")&&(x.support.inlineBlockNeedsLayout&&"inline"!==ln(e.nodeName)?p.zoom=1:p.display="inline-block")),n.overflow&&(p.overflow="hidden",x.support.shrinkWrapBlocks||u.always(function(){p.overflow=n.overflow[0],p.overflowX=n.overflow[1],p.overflowY=n.overflow[2]}));for(r in t)if(i=t[r],Vn.exec(i)){if(delete t[r],o=o||"toggle"===i,i===(f?"hide":"show"))continue;c[r]=d&&d[r]||x.style(e,r)}if(!x.isEmptyObject(c)){d?"hidden"in d&&(f=d.hidden):d=x._data(e,"fxshow",{}),o&&(d.hidden=!f),f?x(e).show():u.done(function(){x(e).hide()}),u.done(function(){var t;x._removeData(e,"fxshow");for(t in c)x.style(e,t,c[t])});for(r in c)a=Zn(f?d[r]:0,r,u),r in d||(d[r]=a.start,f&&(a.end=a.start,a.start="width"===r||"height"===r?1:0))}}function rr(e,t,n,r,i){return new rr.prototype.init(e,t,n,r,i)}x.Tween=rr,rr.prototype={constructor:rr,init:function(e,t,n,r,i,o){this.elem=e,this.prop=n,this.easing=i||"swing",this.options=t,this.start=this.now=this.cur(),this.end=r,this.unit=o||(x.cssNumber[n]?"":"px")},cur:function(){var e=rr.propHooks[this.prop];return e&&e.get?e.get(this):rr.propHooks._default.get(this)},run:function(e){var t,n=rr.propHooks[this.prop];return this.pos=t=this.options.duration?x.easing[this.easing](e,this.options.duration*e,0,1,this.options.duration):e,this.now=(this.end-this.start)*t+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),n&&n.set?n.set(this):rr.propHooks._default.set(this),this}},rr.prototype.init.prototype=rr.prototype,rr.propHooks={_default:{get:function(e){var t;return null==e.elem[e.prop]||e.elem.style&&null!=e.elem.style[e.prop]?(t=x.css(e.elem,e.prop,""),t&&"auto"!==t?t:0):e.elem[e.prop]},set:function(e){x.fx.step[e.prop]?x.fx.step[e.prop](e):e.elem.style&&(null!=e.elem.style[x.cssProps[e.prop]]||x.cssHooks[e.prop])?x.style(e.elem,e.prop,e.now+e.unit):e.elem[e.prop]=e.now}}},rr.propHooks.scrollTop=rr.propHooks.scrollLeft={set:function(e){e.elem.nodeType&&e.elem.parentNode&&(e.elem[e.prop]=e.now)}},x.each(["toggle","show","hide"],function(e,t){var n=x.fn[t];x.fn[t]=function(e,r,i){return null==e||"boolean"==typeof e?n.apply(this,arguments):this.animate(ir(t,!0),e,r,i)}}),x.fn.extend({fadeTo:function(e,t,n,r){return this.filter(nn).css("opacity",0).show().end().animate({opacity:t},e,n,r)},animate:function(e,t,n,r){var i=x.isEmptyObject(e),o=x.speed(t,n,r),a=function(){var t=er(this,x.extend({},e),o);(i||x._data(this,"finish"))&&t.stop(!0)};return a.finish=a,i||o.queue===!1?this.each(a):this.queue(o.queue,a)},stop:function(e,n,r){var i=function(e){var t=e.stop;delete e.stop,t(r)};return"string"!=typeof e&&(r=n,n=e,e=t),n&&e!==!1&&this.queue(e||"fx",[]),this.each(function(){var t=!0,n=null!=e&&e+"queueHooks",o=x.timers,a=x._data(this);if(n)a[n]&&a[n].stop&&i(a[n]);else for(n in a)a[n]&&a[n].stop&&Jn.test(n)&&i(a[n]);for(n=o.length;n--;)o[n].elem!==this||null!=e&&o[n].queue!==e||(o[n].anim.stop(r),t=!1,o.splice(n,1));(t||!r)&&x.dequeue(this,e)})},finish:function(e){return e!==!1&&(e=e||"fx"),this.each(function(){var t,n=x._data(this),r=n[e+"queue"],i=n[e+"queueHooks"],o=x.timers,a=r?r.length:0;for(n.finish=!0,x.queue(this,e,[]),i&&i.stop&&i.stop.call(this,!0),t=o.length;t--;)o[t].elem===this&&o[t].queue===e&&(o[t].anim.stop(!0),o.splice(t,1));for(t=0;a>t;t++)r[t]&&r[t].finish&&r[t].finish.call(this);delete n.finish})}});function ir(e,t){var n,r={height:e},i=0;for(t=t?1:0;4>i;i+=2-t)n=Zt[i],r["margin"+n]=r["padding"+n]=e;return t&&(r.opacity=r.width=e),r}x.each({slideDown:ir("show"),slideUp:ir("hide"),slideToggle:ir("toggle"),fadeIn:{opacity:"show"},fadeOut:{opacity:"hide"},fadeToggle:{opacity:"toggle"}},function(e,t){x.fn[e]=function(e,n,r){return this.animate(t,e,n,r)}}),x.speed=function(e,t,n){var r=e&&"object"==typeof e?x.extend({},e):{complete:n||!n&&t||x.isFunction(e)&&e,duration:e,easing:n&&t||t&&!x.isFunction(t)&&t};return r.duration=x.fx.off?0:"number"==typeof r.duration?r.duration:r.duration in x.fx.speeds?x.fx.speeds[r.duration]:x.fx.speeds._default,(null==r.queue||r.queue===!0)&&(r.queue="fx"),r.old=r.complete,r.complete=function(){x.isFunction(r.old)&&r.old.call(this),r.queue&&x.dequeue(this,r.queue)},r},x.easing={linear:function(e){return e},swing:function(e){return.5-Math.cos(e*Math.PI)/2}},x.timers=[],x.fx=rr.prototype.init,x.fx.tick=function(){var e,n=x.timers,r=0;for(Xn=x.now();n.length>r;r++)e=n[r],e()||n[r]!==e||n.splice(r--,1);n.length||x.fx.stop(),Xn=t},x.fx.timer=function(e){e()&&x.timers.push(e)&&x.fx.start()},x.fx.interval=13,x.fx.start=function(){Un||(Un=setInterval(x.fx.tick,x.fx.interval))},x.fx.stop=function(){clearInterval(Un),Un=null},x.fx.speeds={slow:600,fast:200,_default:400},x.fx.step={},x.expr&&x.expr.filters&&(x.expr.filters.animated=function(e){return x.grep(x.timers,function(t){return e===t.elem}).length}),x.fn.offset=function(e){if(arguments.length)return e===t?this:this.each(function(t){x.offset.setOffset(this,e,t)});var n,r,o={top:0,left:0},a=this[0],s=a&&a.ownerDocument;if(s)return n=s.documentElement,x.contains(n,a)?(typeof a.getBoundingClientRect!==i&&(o=a.getBoundingClientRect()),r=or(s),{top:o.top+(r.pageYOffset||n.scrollTop)-(n.clientTop||0),left:o.left+(r.pageXOffset||n.scrollLeft)-(n.clientLeft||0)}):o},x.offset={setOffset:function(e,t,n){var r=x.css(e,"position");"static"===r&&(e.style.position="relative");var i=x(e),o=i.offset(),a=x.css(e,"top"),s=x.css(e,"left"),l=("absolute"===r||"fixed"===r)&&x.inArray("auto",[a,s])>-1,u={},c={},p,f;l?(c=i.position(),p=c.top,f=c.left):(p=parseFloat(a)||0,f=parseFloat(s)||0),x.isFunction(t)&&(t=t.call(e,n,o)),null!=t.top&&(u.top=t.top-o.top+p),null!=t.left&&(u.left=t.left-o.left+f),"using"in t?t.using.call(e,u):i.css(u)}},x.fn.extend({position:function(){if(this[0]){var e,t,n={top:0,left:0},r=this[0];return"fixed"===x.css(r,"position")?t=r.getBoundingClientRect():(e=this.offsetParent(),t=this.offset(),x.nodeName(e[0],"html")||(n=e.offset()),n.top+=x.css(e[0],"borderTopWidth",!0),n.left+=x.css(e[0],"borderLeftWidth",!0)),{top:t.top-n.top-x.css(r,"marginTop",!0),left:t.left-n.left-x.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent||s;while(e&&!x.nodeName(e,"html")&&"static"===x.css(e,"position"))e=e.offsetParent;return e||s})}}),x.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(e,n){var r=/Y/.test(n);x.fn[e]=function(i){return x.access(this,function(e,i,o){var a=or(e);return o===t?a?n in a?a[n]:a.document.documentElement[i]:e[i]:(a?a.scrollTo(r?x(a).scrollLeft():o,r?o:x(a).scrollTop()):e[i]=o,t)},e,i,arguments.length,null)}});function or(e){return x.isWindow(e)?e:9===e.nodeType?e.defaultView||e.parentWindow:!1}x.each({Height:"height",Width:"width"},function(e,n){x.each({padding:"inner"+e,content:n,"":"outer"+e},function(r,i){x.fn[i]=function(i,o){var a=arguments.length&&(r||"boolean"!=typeof i),s=r||(i===!0||o===!0?"margin":"border");return x.access(this,function(n,r,i){var o;return x.isWindow(n)?n.document.documentElement["client"+e]:9===n.nodeType?(o=n.documentElement,Math.max(n.body["scroll"+e],o["scroll"+e],n.body["offset"+e],o["offset"+e],o["client"+e])):i===t?x.css(n,r,s):x.style(n,r,i,s)},n,a?i:t,a,null)}})}),x.fn.size=function(){return this.length},x.fn.andSelf=x.fn.addBack,"object"==typeof module&&module&&"object"==typeof module.exports?module.exports=x:(e.jQuery=e.$=x,"function"==typeof define&&define.amd&&define("jquery",[],function(){return x}))})(window);
7 |
--------------------------------------------------------------------------------
/public/main.js:
--------------------------------------------------------------------------------
1 | function isMobileOrTablet() {
2 | var check = false;
3 | (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera);
4 | return check;
5 | };
6 |
7 |
8 | function getCookie(cname) {
9 | var name = cname + "=";
10 | var ca = document.cookie.split(";");
11 | for (var i = 0; i < ca.length; i++) {
12 | var c = ca[i];
13 | while (c.charAt(0) == " ") {
14 | c = c.substring(1);
15 | }
16 | if (c.indexOf(name) == 0) {
17 | return c.substring(name.length, c.length);
18 | }
19 | }
20 | return "";
21 | }
22 |
23 | $(function() {
24 | var FADE_TIME = 150; // ms
25 | var TYPING_TIMER_LENGTH = 400; // ms
26 | var COLORS = [
27 | "#e21400",
28 | "#91580f",
29 | "#f8a700",
30 | "#f78b00",
31 | "#58dc00",
32 | "#287b00",
33 | "#a8f07a",
34 | "#4ae8c4",
35 | "#3b88eb",
36 | "#3824aa",
37 | "#a700ff",
38 | "#d300e7"
39 | ];
40 |
41 | // Initialize variables
42 | var $window = $(window);
43 | var $body = $('body');
44 | var $usernameInput = $(".js-usernameInput"); // Input for username
45 | var $passwordInput = $(".js-passwordInput"); // Input for username
46 | var $messages = $(".js-messages"); // Messages area
47 | var $inputMessage = $(".js-inputMessage"); // Input message input box
48 |
49 | var $page = $(".js-page");
50 | var $form = $(".js-form");
51 | var $videoCont = $("#videoContainer");
52 | var $adminTools = $("#adminTools");
53 | var $loginPage = $(".js-login"); // The login page
54 | var $chatPage = $(".js-chat"); // The chatroom page
55 | var $toolbar = $(".js-toolbar");
56 | var $toggleChat = $(".js-toggle-chat");
57 |
58 | // Prompt for setting a username
59 | var username;
60 | var connected = false;
61 | var typing = false;
62 | var lastTypingTime;
63 | var $currentInput = $usernameInput.focus();
64 |
65 | var socket = io();
66 |
67 | var addAdminView = function(data) {
68 | if (username !== "Admin") return;
69 |
70 | $videoCont.hide();
71 | $adminTools.addClass('visible');
72 |
73 | if (!data) return;
74 |
75 | const usersCount = data.usersCount;
76 |
77 | if (!usersCount) return;
78 |
79 | const html = Object.keys(usersCount).map(function(x) {
80 | if (usersCount[x]) return "
" + x + " ";
81 | else return "
" + x + "";
82 | });
83 |
84 | $adminTools.find('.content').html("
");
85 | };
86 |
87 | function addParticipantsMessage(data) {
88 | if (data.username === "Admin") return;
89 | var message = "";
90 | if (data.numUsers === 1) {
91 | message += "1 participant";
92 | } else {
93 | message += "" + data.numUsers + " participants";
94 | }
95 | log(message);
96 |
97 | addAdminView(data);
98 | }
99 | var user = localStorage.getItem("username") || "";
100 |
101 | $usernameInput.val(user);
102 |
103 |
104 | var addVideo = function() {
105 | const id = 'video' + (new Date()).getTime();
106 |
107 | $videoCont.html(
108 | '
' +
109 | '' +
110 | " "
111 | );
112 |
113 | if (!isMobileOrTablet()) {
114 | setTimeout(function() {
115 | videojs(id).ready(function() {
116 | this.play();
117 | });
118 | }, 200);
119 | }
120 | };
121 |
122 | function autoCheck(callback) {
123 | (function check() {
124 | $.ajax("videos/output.m3u8")
125 | .then(callback)
126 | .fail(function() {
127 | setTimeout(check, 1000);
128 | });
129 | })();
130 | }
131 |
132 | // Sets the client's username
133 | function setUsername() {
134 | if (!$form[0].checkValidity()) return;
135 |
136 | username = cleanInput($usernameInput.val().trim());
137 |
138 | var password = cleanInput($passwordInput.val().trim());
139 |
140 | if (!username) return;
141 |
142 | localStorage.setItem("username", $usernameInput.val());
143 |
144 | // If the username is valid
145 | $.post("/login", { username: username, password: password })
146 | .done(function() {
147 | if (username === "Admin") {
148 | addAdminView();
149 | $body.addClass('show-chat');
150 | } else {
151 | // Auto check to display video.
152 | autoCheck(addVideo);
153 | }
154 |
155 | $loginPage.hide();
156 | $chatPage.show();
157 | $toolbar.show();
158 | $loginPage.off("click");
159 | $currentInput = $inputMessage.focus();
160 |
161 | // Tell the server your username
162 | socket.emit("add user", username);
163 | })
164 | .fail(x => {
165 | username = "";
166 | alert("Unauthorized");
167 | });
168 | }
169 |
170 | setUsername();
171 |
172 | $form.submit(e => {
173 | e.preventDefault();
174 |
175 | setUsername();
176 | });
177 |
178 | // Sends a chat message
179 | function sendMessage() {
180 | var message = $inputMessage.val();
181 | // Prevent markup from being injected into the message
182 | message = cleanInput(message);
183 | // if there is a non-empty message and a socket connection
184 | if (message && connected) {
185 | $inputMessage.val("");
186 | addChatMessage({
187 | username: username,
188 | message: message
189 | });
190 | // tell server to execute 'new message' and send along one parameter
191 | socket.emit("new message", message);
192 | }
193 | }
194 |
195 | // Log a message
196 | function log(message, options) {
197 | var $el = $("
")
198 | .addClass("log")
199 | .text(message);
200 | addMessageElement($el, options);
201 | }
202 |
203 | // Adds the visual chat message to the message list
204 | function addChatMessage(data, options) {
205 | // Don't fade the message in if there is an 'X was typing'
206 | var $typingMessages = getTypingMessages(data);
207 |
208 | options = options || {};
209 | if ($typingMessages.length !== 0) {
210 | options.fade = false;
211 | $typingMessages.remove();
212 | }
213 |
214 | var $usernameDiv = $(' ')
215 | .text(data.username)
216 | .css("color", getUsernameColor(data.username));
217 | var $messageBodyDiv = $('').text(data.message);
218 |
219 | var typingClass = data.typing ? "typing" : "";
220 | var $messageDiv = $(' ')
221 | .data("username", data.username)
222 | .addClass(typingClass)
223 | .append($usernameDiv, $messageBodyDiv);
224 |
225 | addMessageElement($messageDiv, options);
226 | }
227 |
228 | // Adds the visual chat typing message
229 | function addChatTyping(data) {
230 | data.typing = true;
231 | data.message = "...";
232 | addChatMessage(data);
233 | }
234 |
235 | // Removes the visual chat typing message
236 | function removeChatTyping(data) {
237 | getTypingMessages(data).fadeOut(function() {
238 | $(this).remove();
239 | });
240 | }
241 |
242 | // Adds a message element to the messages and scrolls to the bottom
243 | // el - The element to add as a message
244 | // options.fade - If the element should fade-in (default = true)
245 | // options.prepend - If the element should prepend
246 | // all other messages (default = false)
247 | function addMessageElement(el, options) {
248 | var $el = $(el);
249 |
250 | // Setup default options
251 | if (!options) {
252 | options = {};
253 | }
254 | if (typeof options.fade === "undefined") {
255 | options.fade = true;
256 | }
257 | if (typeof options.prepend === "undefined") {
258 | options.prepend = false;
259 | }
260 |
261 | // Apply options
262 | if (options.fade) {
263 | $el.hide().fadeIn(FADE_TIME);
264 | }
265 | if (options.prepend) {
266 | $messages.prepend($el);
267 | } else {
268 | $messages.append($el);
269 | }
270 | $messages[0].scrollTop = $messages[0].scrollHeight;
271 | }
272 |
273 | // Prevents input from having injected markup
274 | function cleanInput(input) {
275 | return $("
")
276 | .text(input)
277 | .html();
278 | }
279 |
280 | // Updates the typing event
281 | function updateTyping() {
282 | if (connected) {
283 | if (!typing) {
284 | typing = true;
285 | socket.emit("typing");
286 | }
287 | lastTypingTime = new Date().getTime();
288 |
289 | setTimeout(function() {
290 | var typingTimer = new Date().getTime();
291 | var timeDiff = typingTimer - lastTypingTime;
292 | if (timeDiff >= TYPING_TIMER_LENGTH && typing) {
293 | socket.emit("stop typing");
294 | typing = false;
295 | }
296 | }, TYPING_TIMER_LENGTH);
297 | }
298 | }
299 |
300 | // Gets the 'X is typing' messages of a user
301 | function getTypingMessages(data) {
302 | return $(".typing.message").filter(function(i) {
303 | return $(this).data("username") === data.username;
304 | });
305 | }
306 |
307 | // Gets the color of a username through our hash function
308 | function getUsernameColor(username) {
309 | // Compute hash code
310 | var hash = 7;
311 | for (var i = 0; i < username.length; i++) {
312 | hash = username.charCodeAt(i) + (hash << 5) - hash;
313 | }
314 | // Calculate color
315 | var index = Math.abs(hash % COLORS.length);
316 | return COLORS[index];
317 | }
318 |
319 | // Keyboard events
320 |
321 | $window.keydown(function(event) {
322 | // Auto-focus the current input when a key is typed
323 | if (!(event.ctrlKey || event.metaKey || event.altKey)) {
324 | // $currentInput.focus();
325 | }
326 | // When the client hits ENTER on their keyboard
327 | if (event.which === 13) {
328 | if (username) {
329 | sendMessage();
330 | socket.emit("stop typing");
331 | typing = false;
332 | }
333 | }
334 | });
335 |
336 | $inputMessage.on("input", function() {
337 | updateTyping();
338 | });
339 |
340 | // Click events
341 | $toggleChat.click(function() {
342 | $body.toggleClass('show-chat');
343 | });
344 |
345 | // Focus input when clicking anywhere on login page
346 | $messages.click(function() {
347 | $inputMessage.focus();
348 | });
349 |
350 | // Focus input when clicking on the message input's border
351 | $inputMessage.click(function() {
352 | // $inputMessage.focus();
353 | });
354 |
355 | // Socket events
356 |
357 | // Whenever the server emits 'login', log the login message
358 | socket.on("login", function(data) {
359 | connected = true;
360 | // Display the welcome message
361 | var message = "Welcome – " + user;
362 | log(message, {
363 | prepend: true
364 | });
365 | addParticipantsMessage(data);
366 | });
367 |
368 | // Whenever the server emits 'new message', update the chat body
369 | socket.on("new message", function(data) {
370 | addChatMessage(data);
371 | });
372 |
373 | // Whenever the server emits 'restart', auto checks for new video
374 | socket.on("restart", function(data) {
375 | if (username === 'Admin') return;
376 | autoCheck(addVideo);
377 | });
378 |
379 | // Whenever the server emits 'user joined', log it in the chat body
380 | socket.on("user joined", function(data) {
381 | log(data.username + " joined");
382 | addParticipantsMessage(data);
383 | });
384 |
385 | // Whenever the server emits 'user left', log it in the chat body
386 | socket.on("user left", function(data) {
387 | log(data.username + " left");
388 | addParticipantsMessage(data);
389 | removeChatTyping(data);
390 | });
391 |
392 | // Whenever the server emits 'typing', show the typing message
393 | socket.on("typing", function(data) {
394 | addChatTyping(data);
395 | });
396 |
397 | // Whenever the server emits 'stop typing', kill the typing message
398 | socket.on("stop typing", function(data) {
399 | removeChatTyping(data);
400 | });
401 |
402 | socket.on("disconnect", function() {
403 | log("you have been disconnected");
404 | });
405 |
406 | socket.on("reconnect", function() {
407 | log("you have been reconnected");
408 | if (username) {
409 | socket.emit("add user", username);
410 | }
411 | });
412 |
413 | socket.on("reconnect_error", function() {
414 | log("attempt to reconnect has failed");
415 | });
416 | });
417 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "icons": [
3 | {
4 | "src": "/android-chrome-192x192.png",
5 | "sizes": "192x192",
6 | "type": "image/png"
7 | },
8 | {
9 | "src": "/android-chrome-512x512.png",
10 | "sizes": "512x512",
11 | "type": "image/png"
12 | }
13 | ],
14 | "theme_color": "#394039",
15 | "background_color": "#394039",
16 | "display": "standalone"
17 | }
18 |
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brunnolou/streaming-room/608e9651a7cffe569e56a39ddaffe5fa21e97316/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.11, written by Peter Selinger 2001-2013
9 |
10 |
12 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/public/style.css:
--------------------------------------------------------------------------------
1 | /* Fix user-agent */
2 |
3 | * {
4 | box-sizing: border-box;
5 | }
6 |
7 | html {
8 | font-weight: 300;
9 | -webkit-font-smoothing: antialiased;
10 | }
11 |
12 | html,
13 | input {
14 | font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue",
15 | Helvetica, Arial, "Lucida Grande", sans-serif;
16 | }
17 |
18 | html,
19 | body {
20 | height: 100%;
21 | margin: 0;
22 | padding: 0;
23 | }
24 |
25 | ul {
26 | list-style: none;
27 | word-wrap: break-word;
28 | }
29 |
30 | /* Pages */
31 |
32 | .pages {
33 | height: 100%;
34 | margin: 0;
35 | padding: 0;
36 | width: 100%;
37 | }
38 |
39 | .page {
40 | height: 100%;
41 | position: absolute;
42 | z-index: 2;
43 | width: 100%;
44 | min-width: 320px;
45 | }
46 |
47 | /* Login Page */
48 |
49 | .login.page {
50 | background-color: #000;
51 | }
52 |
53 | .loginForm {
54 | position: absolute;
55 | top: 50%;
56 | left: 50%;
57 | width: 100%;
58 | max-width: 50em;
59 | transform: translate(-60%, -50%);
60 | text-align: right;
61 | }
62 |
63 | .submit,
64 | .loginForm .usernameInput {
65 | background-color: rgba(255, 255, 255, 0.15);
66 | border: none;
67 | border-bottom: 2px solid #fff;
68 | outline: none;
69 | padding: 15px;
70 | text-align: left;
71 | margin-left: 2rem;
72 | max-width: 300px;
73 | width: 100%;
74 | }
75 |
76 | .submit {
77 | background-color: #fff;
78 | color: #000;
79 | width: auto;
80 | cursor: pointer;
81 | }
82 |
83 | .title {
84 | font-size: 200%;
85 | }
86 |
87 | .usernameInput {
88 | font-size: 200%;
89 | letter-spacing: 3px;
90 | }
91 |
92 | .title,
93 | .usernameInput {
94 | color: #fff;
95 | font-weight: 100;
96 | }
97 |
98 | /* Chat page */
99 | .chat.page {
100 | position: absolute;
101 | transform: translateX(-100%);
102 | transition: transform 0.3s ease-in-out;
103 | width: 320px;
104 | z-index: 3;
105 | }
106 |
107 | .show-chat .chat.page {
108 | transform: translateX(0%);
109 | }
110 |
111 | /* Font */
112 | .messages {
113 | background: #fff;
114 | font-size: 100%;
115 | }
116 |
117 | .toolbar {
118 | position: fixed;
119 | top: 4rem;
120 | left: 0;
121 | z-index: 4;
122 |
123 | display: none;
124 | padding: 0.5rem;
125 | }
126 |
127 | .toolbarButton {
128 | background-color: rgba(255, 255, 255, 0.2);
129 | border: 1px solid rgba(0, 0, 0, 0.2);
130 | border-radius: 3px;
131 | font-size: 1rem;
132 | padding: 0.5em 0.7rem;
133 | }
134 |
135 | .inputMessage {
136 | font-size: 100%;
137 | }
138 |
139 | .log {
140 | color: gray;
141 | font-size: 70%;
142 | margin: 5px;
143 | text-align: center;
144 | }
145 |
146 | /* Messages */
147 |
148 | .chatArea {
149 | height: 100%;
150 | padding-bottom: 60px;
151 | }
152 |
153 | .messages {
154 | height: 100%;
155 | margin: 0;
156 | overflow-y: scroll;
157 | padding: 10px 20px 10px 20px;
158 | }
159 |
160 | .message.typing .messageBody {
161 | color: gray;
162 | }
163 |
164 | .username {
165 | font-weight: 700;
166 | overflow: hidden;
167 | padding-right: 15px;
168 | text-align: right;
169 | }
170 |
171 | /* Input */
172 |
173 | .inputMessage {
174 | border: 10px solid #000;
175 | bottom: 0;
176 | height: 60px;
177 | left: 0;
178 | outline: none;
179 | padding-left: 10px;
180 | position: absolute;
181 | right: 0;
182 | width: 100%;
183 | }
184 |
185 | /* Video */
186 |
187 | .main {
188 | position: absolute;
189 | top: 0;
190 | left: 0;
191 | bottom: 0;
192 | background: #394039;
193 |
194 | display: flex;
195 | width: 100%;
196 | align-items: center;
197 |
198 | transition: transform 0.3s ease-in-out;
199 | }
200 |
201 | .show-chat .main {
202 | width: calc(100% - 320px);
203 | transition: transform 0.3s ease-in-out, width 0s 0.3s linear;
204 | transform: translateX(320px);
205 | }
206 |
207 | .adminView {
208 | display: none;
209 | background: whitesmoke;
210 | font-weight: normal;
211 | padding: 3rem;
212 | font-size: 3rem;
213 | }
214 |
215 | .adminView.visible {
216 | display: block;
217 | }
218 |
219 | .video-js {
220 | width: 100%;
221 | height: 100%;
222 | }
223 |
224 | /* Loader */
225 | .spinner {
226 | margin: 100px auto;
227 | width: 40px;
228 | height: 40px;
229 | position: relative;
230 | text-align: center;
231 |
232 | -webkit-animation: sk-rotate 2s infinite linear;
233 | animation: sk-rotate 2s infinite linear;
234 | }
235 |
236 | .dot1,
237 | .dot2 {
238 | width: 60%;
239 | height: 60%;
240 | display: inline-block;
241 | position: absolute;
242 | top: 0;
243 | background-color: #82ffd8;
244 | border-radius: 100%;
245 |
246 | -webkit-animation: sk-bounce 2s infinite ease-in-out;
247 | animation: sk-bounce 2s infinite ease-in-out;
248 | }
249 |
250 | .dot2 {
251 | top: auto;
252 | bottom: 0;
253 | -webkit-animation-delay: -1s;
254 | animation-delay: -1s;
255 | }
256 |
257 | .video-js .vjs-big-play-button {
258 | top: 50%;
259 | left: 50%;
260 | transform: translate(-50%, -50%);
261 | }
262 |
263 | @-webkit-keyframes sk-rotate {
264 | 100% {
265 | -webkit-transform: rotate(360deg);
266 | }
267 | }
268 | @keyframes sk-rotate {
269 | 100% {
270 | transform: rotate(360deg);
271 | -webkit-transform: rotate(360deg);
272 | }
273 | }
274 |
275 | @-webkit-keyframes sk-bounce {
276 | 0%,
277 | 100% {
278 | -webkit-transform: scale(0);
279 | }
280 | 50% {
281 | -webkit-transform: scale(1);
282 | }
283 | }
284 |
285 | @keyframes sk-bounce {
286 | 0%,
287 | 100% {
288 | transform: scale(0);
289 | -webkit-transform: scale(0);
290 | }
291 | 50% {
292 | transform: scale(1);
293 | -webkit-transform: scale(1);
294 | }
295 | }
296 |
--------------------------------------------------------------------------------
/public/videos/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/brunnolou/streaming-room/608e9651a7cffe569e56a39ddaffe5fa21e97316/public/videos/.gitkeep
--------------------------------------------------------------------------------
/src/chatServer.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk');
2 |
3 | const { deleteVideos, stringify, log } = require('./utils');
4 |
5 | let numUsers = 0;
6 | const usersCount = {};
7 | const sum = x => Object.values(x).reduce((acc, v) => acc + v, 0);
8 | const userAdd = (user, x = 1) => {
9 | if (user === 'Admin') return sum(usersCount);
10 |
11 | if (!usersCount[user]) {
12 | usersCount[user] = 1;
13 |
14 | return sum(usersCount);
15 | }
16 |
17 | usersCount[user] += x;
18 |
19 | return sum(usersCount);
20 | };
21 |
22 | // io -> Promise -> socket
23 | function chatServer(io) {
24 | return new Promise((resolve) => {
25 | io.on('connection', (socket) => {
26 | let addedUser = false;
27 | resolve(socket);
28 |
29 | socket.on('reset', () => {
30 | deleteVideos();
31 | socket.broadcast.emit('restart', { message: 'Server restarted please refresh' });
32 | });
33 |
34 | // When the client emits 'new message', this listens and executes
35 | socket.on('new message', (data) => {
36 | // we tell the client to execute 'new message'
37 | socket.broadcast.emit('new message', {
38 | username: socket.username,
39 | message: data,
40 | });
41 | });
42 |
43 | // When the client emits 'add user', this listens and executes
44 | socket.on('add user', (username) => {
45 | if (addedUser) return;
46 |
47 | // we store the username in the socket session for this client
48 | socket.username = username;
49 |
50 | numUsers = userAdd(username);
51 | addedUser = true;
52 |
53 | if (usersCount.Admin) delete usersCount.Admin;
54 |
55 | socket.emit('login', { numUsers, usersCount });
56 |
57 | // echo globally (all clients) that a person has connected
58 | socket.broadcast.emit('user joined', {
59 | username: socket.username,
60 | numUsers,
61 | usersCount,
62 | });
63 |
64 | log('-----------------------');
65 | console.log(chalk.bold('In ') + chalk.green(` > ${socket.username}`));
66 | console.log(stringify(usersCount));
67 | console.log(chalk.bold('Total: ') + chalk.yellow(numUsers));
68 | });
69 |
70 | // When the client emits 'typing', we broadcast it to others
71 | socket.on('typing', () => {
72 | socket.broadcast.emit('typing', { username: socket.username });
73 | });
74 |
75 | socket.on('stop typing', () => {
76 | socket.broadcast.emit('stop typing', { username: socket.username });
77 | });
78 |
79 | // When the user disconnects.
80 | socket.on('disconnect', () => {
81 | if (addedUser) {
82 | numUsers = userAdd(socket.username, -1);
83 |
84 | // echo globally that this client has left
85 | socket.broadcast.emit('user left', {
86 | username: socket.username,
87 | numUsers,
88 | usersCount,
89 | });
90 |
91 | log('-----------------------');
92 | console.log(chalk.bold('Out') + chalk.red(` < ${socket.username}`));
93 | console.log(stringify(usersCount));
94 | console.log(chalk.bold('Total: ') + chalk.yellow(numUsers));
95 | }
96 | });
97 | });
98 | });
99 | }
100 |
101 | module.exports = chatServer;
102 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | // Setup basic express server
2 | const express = require('express');
3 | const http = require('http');
4 | const socketIo = require('socket.io');
5 |
6 | const webServer = require('./webServer');
7 | const chatServer = require('./chatServer');
8 | const { deleteVideos } = require('./utils');
9 | const rtmpServer = require('./rtmpServer');
10 |
11 | async function App() {
12 | const app = express();
13 | const server = http.createServer(app);
14 | const io = socketIo(server);
15 |
16 | deleteVideos();
17 |
18 | // Start web server.
19 | webServer(app, server);
20 |
21 | // Start chat server.
22 | const socket = await chatServer(io);
23 |
24 | // Start stream server.
25 | rtmpServer(socket);
26 | }
27 |
28 | module.exports = App;
29 |
--------------------------------------------------------------------------------
/src/locales/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "MEETING_ROOM": "Konferenzraum",
3 | "WHAT_S_YOUR_NAME": "Dein Name:",
4 | "PASSWORD": "Passwort:",
5 | "ENTER": "BETRETEN",
6 | "CHAT": "💬"
7 | }
8 |
--------------------------------------------------------------------------------
/src/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "MEETING_ROOM": "Meeting room",
3 | "WHAT_S_YOUR_NAME": "Your name:",
4 | "PASSWORD": "Password:",
5 | "ENTER": "ENTER",
6 | "CHAT": "💬"
7 | }
8 |
--------------------------------------------------------------------------------
/src/locales/pt-BR.json:
--------------------------------------------------------------------------------
1 | {
2 | "MEETING_ROOM": "Sala de reuniões",
3 | "WHAT_S_YOUR_NAME": "Qual o seu nome:",
4 | "PASSWORD": "Senha:",
5 | "ENTER": "Entrar",
6 | "CHAT": "💬"
7 | }
8 |
--------------------------------------------------------------------------------
/src/pages/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= __('MEETING_ROOM') %>
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
32 |
33 |
67 |
68 |
69 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/src/rtmpServer.js:
--------------------------------------------------------------------------------
1 | const RtmpServer = require('rtmp-server');
2 | const rtmpToHLS = require('./rtmpToHLS');
3 | const { streamKey } = require('../config.json');
4 | const { deleteVideos, log } = require('./utils');
5 |
6 | function server(socket) {
7 | const rtmpServer = new RtmpServer();
8 |
9 | rtmpServer.listen(1935);
10 |
11 | rtmpServer.on('error', (err) => {
12 | log('RTMP server error:', 'yellow');
13 | log(err, 'yellow');
14 | });
15 |
16 | rtmpServer.on('client', (client) => {
17 | client.on('connect', () => log(`CONNECT ${client.app}`, 'blue'));
18 |
19 | client.on('play', () => log('PLAY ', 'blue'));
20 |
21 | client.on('publish', ({ streamName }) => {
22 | if (streamKey !== streamName) {
23 | log('Publishing error: Wrong stream key', 'red');
24 |
25 | return;
26 | }
27 |
28 | deleteVideos();
29 | rtmpToHLS();
30 | socket.broadcast.emit('restart', {});
31 | socket.broadcast.emit('published', {});
32 |
33 | log('PUBLISH', 'blue');
34 | });
35 |
36 | client.on('stop', () => {
37 | socket.broadcast.emit('restart', {});
38 | socket.broadcast.emit('disconeted', {});
39 | log('DISCONNECTED', 'red');
40 | });
41 | });
42 | }
43 |
44 | module.exports = server;
45 |
--------------------------------------------------------------------------------
/src/rtmpToHLS.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const ffmpeg = require('@ffmpeg-installer/ffmpeg');
3 | const { log, execute } = require('./utils');
4 | const {
5 | hls: {
6 | input, maxrate, bufsize, output, numberOfTries, delayOfTries,
7 | },
8 | videosPath,
9 | streamKey,
10 | } = require('../config.json');
11 |
12 | const rtmpToHLS = (tries = 0) => {
13 | const command = `${`${ffmpeg.path} -i ${input}${streamKey} ` +
14 | ` -maxrate ${maxrate}` +
15 | ` -bufsize ${bufsize}` +
16 | ' -v verbose ' +
17 | ' -c:v libx264 ' +
18 | ' -c:a aac ' +
19 | ' -ac 1 ' +
20 | ' -strict -2' +
21 | ' -crf 18' +
22 | ' -profile:v baseline' +
23 | ' -pix_fmt yuv420p' +
24 | ' -flags' +
25 | ' -global_header' +
26 | ' -hls_time 10' +
27 | ' -hls_list_size 6' +
28 | ' -hls_wrap 10' +
29 | ' -start_number 1 '}${path.join(videosPath, output)}`;
30 |
31 | execute(command, (err, stdout, stderr) => {
32 | if (err) {
33 | if (tries >= numberOfTries) {
34 | log('FFmpeg error:', 'red');
35 | log(err, 'red');
36 | } else {
37 | log(`FFmpeg error retrying in ${delayOfTries}ms — ${tries + 1} of ${numberOfTries}`, 'red');
38 |
39 | setTimeout(() => {
40 | rtmpToHLS(tries + 1);
41 | }, delayOfTries);
42 | }
43 |
44 | return;
45 | }
46 |
47 | // the *entire* stdout and stderr (buffered)
48 | console.log(`ffmpeg: ${stdout}`);
49 | console.log(`ffmpeg: ${stderr}`);
50 | });
51 | };
52 |
53 | module.exports = rtmpToHLS;
54 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | const del = require('del');
2 | const path = require('path');
3 | const { passwords, videosPath, maxBuffer } = require('../config.json');
4 | const chalk = require('chalk');
5 | const { exec } = require('child_process');
6 |
7 | const execute = (command, callback) => {
8 | exec(command, { maxBuffer: 1024 * maxBuffer }, callback);
9 | };
10 |
11 | const log = (data, color = 'blue') => {
12 | console.log(chalk[color](data));
13 | };
14 |
15 | const deleteVideos = () => {
16 | const videoGlob = path.join(__dirname, '..', videosPath, '*');
17 |
18 | del.sync([videoGlob]);
19 | };
20 |
21 | const isAuth = (username, pass) => {
22 | if (username === 'Admin') {
23 | const userPass = passwords.find(x => x.name === 'Admin');
24 |
25 | if (!userPass) return false;
26 |
27 | return userPass.password === pass;
28 | }
29 |
30 | return passwords.some(({ password }) => password === pass);
31 | };
32 |
33 | const stringify = (obj) => {
34 | const stg = JSON.stringify(obj, null, ' ');
35 |
36 | return stg.replace(/[{}]+/g, '');
37 | };
38 |
39 | module.exports = {
40 | deleteVideos,
41 | execute,
42 | isAuth,
43 | log,
44 | stringify,
45 | };
46 |
--------------------------------------------------------------------------------
/src/webServer.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const bodyParser = require('body-parser');
3 | const cookieParser = require('cookie-parser');
4 | const cors = require('cors');
5 | const i18n = require('i18n');
6 | const path = require('path');
7 | const ip = require('my-ip');
8 | const publicIp = require('public-ip');
9 |
10 | const { isAuth, log } = require('./utils');
11 |
12 | const port = process.env.PORT || 3000;
13 |
14 | const protect = (req, res, next) => {
15 | if (!req.signedCookies.username) {
16 | res.redirect('/');
17 | }
18 |
19 | next();
20 | };
21 |
22 | function webServer(app, server) {
23 | server.listen(port, '0.0.0.0', () => {
24 | log('');
25 | log('Web server is running on:', 'white');
26 | log(`http://localhost:${port}`);
27 | log(`http://${ip()}:${port}`);
28 |
29 | publicIp
30 | .v4()
31 | .then((v4) => {
32 | log('Your external IP is:', 'white');
33 | log(`http://${v4}`);
34 | log('');
35 | })
36 | .catch(() => log('Cannot get external IP'));
37 | });
38 |
39 | // Cookie secret.
40 | const secret = 'Mmm98N)8bewd88';
41 | app.use(cookieParser(secret));
42 | app.use(cors());
43 | app.use(bodyParser.json());
44 | app.use(bodyParser.urlencoded({ extended: true })); // for x-www-form-urlencoded
45 | app.use('/videos', protect);
46 | app.use(express.static(path.join(__dirname, '..', 'public')));
47 |
48 | // i18n
49 | i18n.configure({
50 | locales: ['pt', 'de', 'en'],
51 | queryParameter: 'lang',
52 | directory: path.join(__dirname, 'locales'),
53 | });
54 | app.use(i18n.init);
55 |
56 | // View engine.
57 | app.set('view engine', 'ejs');
58 | app.set('views', path.join(__dirname, 'pages'));
59 |
60 | // Routes.
61 | app.get('/', (req, res) => {
62 | res.render('index', { title: 'The index page!' });
63 | });
64 |
65 | app.post('/login', (req, res) => {
66 | if (req.signedCookies.username) return res.send({ data: 'OK' });
67 |
68 | const { password, username } = req.body;
69 |
70 | if (!isAuth(username, password)) return res.status(401).send({ error: 'Unauthorized' });
71 |
72 | // read cookies
73 | const options = {
74 | maxAge: 1000 * 60 * 60 * 24 * 30, // would expire after 1 month.
75 | httpOnly: false, // The cookie only accessible by the web server
76 | signed: true, // Indicates if the cookie should be signed,
77 | };
78 |
79 | // Set cookie
80 | res.cookie('username', username, options);
81 | return res.send({ username });
82 | });
83 |
84 | app.get('/logout', (req, res) => {
85 | res.cookie('username', '');
86 | res.redirect('/');
87 | });
88 | }
89 |
90 | module.exports = webServer;
91 |
--------------------------------------------------------------------------------
/windows/README.md:
--------------------------------------------------------------------------------
1 | # Windows installation
2 |
3 | 1. ## Install Nodejs
4 |
5 | Download here: https://nodejs.org/en/download/
6 |
7 | 2. ## Download Streaming Room
8 |
9 | Clone this repository or
10 | [download](https://github.com/brunnolou/streaming-room/archive/master.zip)
11 | the code.
12 |
13 | You can check all
14 | [releases here](https://github.com/brunnolou/streaming-room/releases/latest).
15 |
16 | Unzip and put the code in the final destination.
17 |
18 | 3. ## Install Streaming Room
19 |
20 | Open the project folder in **terminal** and run:
21 |
22 | ```sh
23 | npm install
24 | ```
25 |
26 | After the npm installation run the following to start the server:
27 |
28 | ```sh
29 | npm start
30 | ```
31 |
32 | 4. ## Open the web browser
33 |
34 | http://localhost:3000
35 |
36 | Login as administrator:
37 |
38 | ```
39 | Name: Admin
40 | Password: root
41 | ```
42 |
43 | Open a different browser or an incognito window and log in as:
44 |
45 | ```
46 | Name: [Anything]
47 | Password: room1
48 | ```
49 |
50 | 5. ## Start the stream
51 |
52 | Using the software of your choice, stream the `RTMP` video to the Streaming
53 | Room with the following settings:
54 |
55 | ```
56 | URL: rtmp://localhost/live
57 | KEY: live
58 | ```
59 |
60 | Depending on the computer you may need to wait ~1min until the client windows
61 | automatically start the video.
62 |
63 | > Recommended: **[OBS](https://obsproject.com/)**
64 | >
65 | > Multi platform, Free and open source software for video recording and live
66 | > streaming.
67 |
68 | ## Passwords
69 |
70 | After testing with the default settings you might update the default login
71 | passwords and stream key in the file: `config.json`. For security reasons, you
72 | must update the streaming KEY and keep it private.
73 |
74 | ## Run at Startup
75 |
76 | * Press `Windows Key` + `R` then type `shell:common startup`
77 |
78 | * Into the **Run dialog**, and press `Enter`.
79 |
80 | * The **Startup** folder will open. Create **shortcuts** from each `*.bat` files
81 | inside this folder.
82 |
83 | > **_Warning:_** Don't move or copy this files!
84 |
85 | To create **shortcuts** `Right-click` on each `*.bat` file and select `Create
86 | Shortcut`.
87 |
88 | Then move the shortcut, not the file.
89 |
--------------------------------------------------------------------------------
/windows/start-firefox.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | title Start Open Mozilla Firefox
3 | cd C:\Program Files (x86)\Mozilla Firefox\
4 |
5 | start "Open chat" firefox.exe http://localhost:3000
6 |
7 | && exit
8 |
--------------------------------------------------------------------------------
/windows/start-obs-streaming.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | title Start Open OBS Streaming
3 | cd C:\Program Files (x86)\obs-studio\bin\64bit
4 |
5 | :: Sleep for 5 seconds to wait server to start.
6 | ping 127.0.0.1 -n 5 > nul
7 |
8 | start "OBS straming" obs64.exe --startstreaming
9 |
10 | && exit
11 |
--------------------------------------------------------------------------------
/windows/start-rtpm-server.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | title Starting Stream
3 |
4 | cd ..
5 | npm start
6 |
7 | exit
8 |
--------------------------------------------------------------------------------