| 1 | /** |
| 2 | * @licstart The following is the entire license notice for the JavaScript code in this page. |
| 3 | * |
| 4 | * IceCast Stream Monitor |
| 5 | * Copyright © 2015 David Thompson <davet@gnu.org> |
| 6 | * |
| 7 | * This program is free software: you can redistribute it and/or |
| 8 | * modify it under the terms of the GNU Affero General Public License |
| 9 | * as published by the Free Software Foundation, either version 3 of |
| 10 | * the License, or (at your option) any later version. |
| 11 | * |
| 12 | * This program is distributed in the hope that it will be useful, but |
| 13 | * WITHOUT ANY WARRANTY; without even the implied warranty of |
| 14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 15 | * Affero General Public License for more details. |
| 16 | * |
| 17 | * You should have received a copy of the GNU General Public License |
| 18 | * along with this program. If not, see |
| 19 | * <http://www.gnu.org/licenses/>. |
| 20 | * |
| 21 | * @licend The above is the entire license notice for the JavaScript code in this page |
| 22 | */ |
| 23 | |
| 24 | if (!Array.prototype.find) { |
| 25 | Array.prototype.find = function(predicate) { |
| 26 | if (this == null) { |
| 27 | throw new TypeError('Array.prototype.find called on null or undefined'); |
| 28 | } |
| 29 | if (typeof predicate !== 'function') { |
| 30 | throw new TypeError('predicate must be a function'); |
| 31 | } |
| 32 | var list = Object(this); |
| 33 | var length = list.length >>> 0; |
| 34 | var thisArg = arguments[1]; |
| 35 | var value; |
| 36 | |
| 37 | for (var i = 0; i < length; i++) { |
| 38 | value = list[i]; |
| 39 | if (predicate.call(thisArg, value, i, list)) { |
| 40 | return value; |
| 41 | } |
| 42 | } |
| 43 | return undefined; |
| 44 | }; |
| 45 | } |
| 46 | |
| 47 | var app = {}; |
| 48 | |
| 49 | app.icecastUrl = "https://live.fsf.org"; |
| 50 | |
| 51 | app.icecastApiUrl = "//live.fsf.org"; |
| 52 | |
| 53 | app.scheduleEvery = function(duration, thunk) { |
| 54 | thunk(); |
| 55 | setTimeout(function() { |
| 56 | app.scheduleEvery(duration, thunk); |
| 57 | }, duration); |
| 58 | }; |
| 59 | |
| 60 | app.nullStats = { |
| 61 | listeners: 0, |
| 62 | server_name: null, |
| 63 | server_description: null |
| 64 | }; |
| 65 | |
| 66 | app.publicApi = function(xhr) { |
| 67 | xhr.withCredentials = false; |
| 68 | }; |
| 69 | |
| 70 | app.streamStats = function(mount) { |
| 71 | var statsUrl = app.icecastApiUrl.concat('/status-json.xsl'); |
| 72 | |
| 73 | return m.request({ |
| 74 | method: "GET", |
| 75 | url: statsUrl, |
| 76 | config: app.publicApi |
| 77 | }).then(function(data) { |
| 78 | // Match the end of the listen URL for the mount point. |
| 79 | var regexp = new RegExp(mount.concat('$')); |
| 80 | |
| 81 | if(!data.icestats.source) { |
| 82 | return app.nullStats; |
| 83 | } |
| 84 | |
| 85 | // Due to <https://trac.xiph.org/ticket/2174>, we must |
| 86 | // explicitly test if icestats.source is an array. |
| 87 | if(!(data.icestats.source instanceof Array)) { |
| 88 | data.icestats.source = [data.icestats.source]; |
| 89 | } |
| 90 | |
| 91 | var stats = data.icestats.source.find(function(source) { |
| 92 | return regexp.test(source.listenurl); |
| 93 | }); |
| 94 | |
| 95 | return stats || app.nullStats; |
| 96 | }); |
| 97 | }; |
| 98 | |
| 99 | app.validStreamInfo = function(stats) { |
| 100 | var name = stats.server_name; |
| 101 | var desc = stats.server_description; |
| 102 | |
| 103 | return name && desc && name !== "Unspecified name" && |
| 104 | desc !== "Unspecified description"; |
| 105 | }; |
| 106 | |
| 107 | app.mountToStreamUrl = function(mount) { |
| 108 | return app.icecastUrl.concat(mount); |
| 109 | }; |
| 110 | |
| 111 | app.changeVideoMount = function(video, mount) { |
| 112 | // This is quite hacky and doesn't feel like the Mithril way to do |
| 113 | // things, but we need to explicitly reload the video when the |
| 114 | // source URL changes. |
| 115 | video.src = app.mountToStreamUrl(mount) + "?t=" + (new Date() * 1); |
| 116 | video.load(); |
| 117 | video.play(); |
| 118 | }; |
| 119 | |
| 120 | app.withVideo = function(id, callback) { |
| 121 | return function() { |
| 122 | var video = document.getElementById(id); |
| 123 | |
| 124 | if(video) { |
| 125 | callback(video); |
| 126 | } |
| 127 | }; |
| 128 | }; |
| 129 | |
| 130 | app.streams = [ |
| 131 | { |
| 132 | name: "Back Bay Grand Room", |
| 133 | speakerMount: "/stream-room-grand.webm", |
| 134 | desktopMount: "/slides-room-grand.webm", |
| 135 | speakerSmallMount: "/stream-room-grand-480p.webm", |
| 136 | ircChannel: "#libreplanet_room_grand" |
| 137 | }, { |
| 138 | name: "Freedom Room", |
| 139 | speakerMount: "/stream-room-freedom.webm", |
| 140 | desktopMount: "/slides-room-freedom.webm", |
| 141 | speakerSmallMount: "/stream-room-freedom-480p.webm", |
| 142 | ircChannel: "#libreplanet_room_freedom" |
| 143 | }, { |
| 144 | name: "Patriot Room", |
| 145 | speakerMount: "/stream-room-patriot.webm", |
| 146 | desktopMount: "/slides-room-patriot.webm", |
| 147 | speakerSmallMount: "/stream-room-patriot-480p.webm", |
| 148 | ircChannel: "#libreplanet_room_patriot" |
| 149 | } |
| 150 | ]; |
| 151 | |
| 152 | app.controller = function() { |
| 153 | this.stream = m.prop(app.streams[0]); |
| 154 | this.stats = m.prop(app.nullStats); |
| 155 | this.showDesktop = m.prop(false); |
| 156 | |
| 157 | // Check stats every 10 seconds. |
| 158 | app.scheduleEvery(10000, this.updateStats.bind(this)); |
| 159 | }; |
| 160 | |
| 161 | app.controller.prototype.updateStats = function() { |
| 162 | //this.stats = app.streamStats(this.stream().speakerMount); |
| 163 | }; |
| 164 | |
| 165 | app.view = function(ctrl) { |
| 166 | var stream = ctrl.stream(); |
| 167 | var stats = ctrl.stats() || app.nullStats; |
| 168 | var showDesktop = ctrl.showDesktop(); |
| 169 | |
| 170 | function renderSpeakerStream() { |
| 171 | return m("video.lp-video", { |
| 172 | id: "speaker-video", |
| 173 | controls: true, |
| 174 | autoplay: true, |
| 175 | // Sync desktop stream state as best we can. |
| 176 | onpause: app.withVideo("desktop-video", function(video) { |
| 177 | video.pause(); |
| 178 | }), |
| 179 | onplay: app.withVideo("desktop-video", function(video) { |
| 180 | video.play(); |
| 181 | }) |
| 182 | // onended: app.withVideo("desktop-video", function(video) { |
| 183 | // setTimeout(function() { |
| 184 | // |
| 185 | // // we probably need to update the video stream GET request with the |
| 186 | // // date hack so we get a non-cached version of the video when we |
| 187 | // // try to resume. |
| 188 | // |
| 189 | // // we also probably need to re-try loading the video if it fails to |
| 190 | // // load |
| 191 | // |
| 192 | // video.load(); |
| 193 | // video.play(); |
| 194 | // }, 3000); |
| 195 | // }) |
| 196 | }, [ |
| 197 | m("source", { |
| 198 | src: app.mountToStreamUrl(stream.speakerMount) + "?t=" + (new Date() * 1) |
| 199 | }), |
| 200 | m("p", |
| 201 | m("em", [ |
| 202 | "Your browser does not support the HTML5 video tag, ", |
| 203 | m("a", { href: "TODO" }, "[ please download ]"), |
| 204 | "the video instead" |
| 205 | ])) |
| 206 | ]); |
| 207 | } |
| 208 | |
| 209 | function renderDesktopStream() { |
| 210 | return m("video.lp-video", { |
| 211 | id: "desktop-video", |
| 212 | controls: true, |
| 213 | autoplay: true |
| 214 | // onended: app.withVideo("desktop-video", function(video) { |
| 215 | // setTimeout(function() { |
| 216 | // // we probably need to update the video stream GET request with the |
| 217 | // // date hack so we get a non-cached version of the video when we |
| 218 | // // try to resume. |
| 219 | // |
| 220 | // // we also probably need to re-try loading the video if it fails to |
| 221 | // // load |
| 222 | // video.load(); |
| 223 | // video.play(); |
| 224 | // }, 3000); |
| 225 | // }) |
| 226 | }, [ |
| 227 | m("source", { src: app.mountToStreamUrl(stream.desktopMount) + "?t=" + (new Date() * 1) }), |
| 228 | m("p", |
| 229 | m("em", [ |
| 230 | "Your browser does not support the HTML5 video tag, ", |
| 231 | m("a", { href: "TODO" }, "[ please download ]"), |
| 232 | "the video instead" |
| 233 | ])) |
| 234 | ]); |
| 235 | } |
| 236 | |
| 237 | /* |
| 238 | function renderToggleDesktopStream() { |
| 239 | var action = showDesktop ? "Hide slides stream" : "Show slides stream"; |
| 240 | |
| 241 | return m(".row", [ |
| 242 | m(".col-sm-offset-4.col-sm-4", |
| 243 | m("button.btn.btn-block.btn-default", { |
| 244 | onclick: function() { |
| 245 | ctrl.showDesktop(!showDesktop); |
| 246 | } |
| 247 | }, action) |
| 248 | ) |
| 249 | ]); |
| 250 | } |
| 251 | */ |
| 252 | |
| 253 | function renderRoomSelector() { |
| 254 | return m(".row", |
| 255 | m(".col-sm-offset-1.col-sm-10", |
| 256 | m("ol.breadcrumb.text-center", app.streams.map(function(s) { |
| 257 | return m("li", { |
| 258 | class: s === stream ? "active" : null, |
| 259 | onclick: function() { |
| 260 | var speakerVideo = document.getElementById("speaker-video"); |
| 261 | var desktopVideo = document.getElementById("desktop-video"); |
| 262 | |
| 263 | app.changeVideoMount(speakerVideo, s.speakerMount); |
| 264 | |
| 265 | // Video element doesn't exist when the user |
| 266 | // hasn't elected to show it. |
| 267 | if(desktopVideo) { |
| 268 | app.changeVideoMount(desktopVideo, s.desktopMount); |
| 269 | } |
| 270 | |
| 271 | ctrl.stream(s); |
| 272 | ctrl.updateStats(); |
| 273 | |
| 274 | return false; |
| 275 | } |
| 276 | }, m("a.alt-a", { href: "#" }, s.name)); |
| 277 | })))); |
| 278 | } |
| 279 | |
| 280 | function renderStats() { |
| 281 | var info; |
| 282 | |
| 283 | if(stats === app.nullStats) { |
| 284 | info = m("i", "not broadcasting"); |
| 285 | } else { |
| 286 | info = m("i", "live"); |
| 287 | } |
| 288 | // else if(app.validStreamInfo(stats)) { |
| 289 | // info = [ |
| 290 | // m("strong", stats.server_name), |
| 291 | // " — ", |
| 292 | // m("i", stats.server_description) |
| 293 | // ]; |
| 294 | // } else { |
| 295 | // info = null; |
| 296 | // } |
| 297 | |
| 298 | //return m(".row", [ |
| 299 | // m(".col-sm-8", info), |
| 300 | // m(".col-sm-4.text-right", [ |
| 301 | // m("strong", stats.listeners), |
| 302 | // " watching" |
| 303 | // ]) |
| 304 | //]); |
| 305 | return m("span"); |
| 306 | } |
| 307 | |
| 308 | return [ |
| 309 | renderRoomSelector(), |
| 310 | m("h2", stream.name), |
| 311 | renderStats(), |
| 312 | renderSpeakerStream(), |
| 313 | showDesktop ? renderDesktopStream() : null, |
| 314 | // renderToggleDesktopStream(), |
| 315 | m("p", "Join the discussion online!"), |
| 316 | m("ul", [ |
| 317 | m("li", [ |
| 318 | "Conference-wide Freenode IRC channel: ", |
| 319 | m("strong", "/join #libreplanet") |
| 320 | ]), |
| 321 | m("li", [ |
| 322 | "Freenode IRC channel for ", |
| 323 | stream.name, |
| 324 | ": ", |
| 325 | m("strong", ["/join ", stream.ircChannel]) |
| 326 | ]), |
| 327 | // m("li", [ |
| 328 | // "Conference-wide Mumble (voice chat) server: ", |
| 329 | // m("strong", "mumble.fsf.org") |
| 330 | // ]), |
| 331 | m("li", [ |
| 332 | "Conference hashtag for ", |
| 333 | m("a", { href: "https://fsf.org/twitter" }, "microblogging"), |
| 334 | ": ", |
| 335 | m("strong", "#libreplanet") |
| 336 | ]) |
| 337 | ]), |
| 338 | m("h2", "IRC") |
| 339 | ]; |
| 340 | }; |
| 341 | |
| 342 | m.module(document.getElementById("stream"), app); |