d4eb563b |
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 = "http://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: "Room 123", |
133 | speakerMount: "/stream-123.webm", |
134 | desktopMount: "/slides-123.webm", |
135 | speakerSmallMount: "/stream-123-480p.webm", |
136 | ircChannel: "#libreplanet_room123" |
137 | }, { |
138 | name: "Room 144", |
139 | speakerMount: "/stream-144.webm", |
140 | desktopMount: "/slides-144.webm", |
141 | speakerSmallMount: "/stream-144-480p.webm", |
142 | ircChannel: "#libreplanet_room144" |
143 | }, { |
144 | name: "Room 155", |
145 | speakerMount: "/stream-155.webm", |
146 | desktopMount: "/slides-155.webm", |
147 | speakerSmallMount: "/stream-155-480p.webm", |
148 | ircChannel: "#libreplanet_room155" |
d4eb563b |
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 | }, [ |
183 | m("source", { |
184 | src: app.mountToStreamUrl(stream.speakerMount) + "?t=" + (new Date() * 1) |
185 | }), |
186 | m("p", |
187 | m("em", [ |
188 | "Your browser does not support the HTML5 video tag, ", |
189 | m("a", { href: "TODO" }, "[ please download ]"), |
190 | "the video instead" |
191 | ])) |
192 | ]); |
193 | } |
194 | |
195 | function renderDesktopStream() { |
196 | return m("video.lp-video", { |
197 | id: "desktop-video", |
198 | controls: true, |
199 | autoplay: true, |
200 | // Sync speaker stream state as best we can. |
201 | onpause: app.withVideo("speaker-video", function(video) { |
202 | video.pause(); |
203 | }), |
204 | onplay: app.withVideo("speaker-video", function(video) { |
205 | video.play(); |
206 | }) |
207 | }, [ |
208 | m("source", { src: app.mountToStreamUrl(stream.desktopMount) + "?t=" + (new Date() * 1) }), |
209 | m("p", |
210 | m("em", [ |
211 | "Your browser does not support the HTML5 video tag, ", |
212 | m("a", { href: "TODO" }, "[ please download ]"), |
213 | "the video instead" |
214 | ])) |
215 | ]); |
216 | } |
217 | |
218 | function renderToggleDesktopStream() { |
219 | var action = showDesktop ? "Hide desktop stream" : "Show desktop stream"; |
220 | |
221 | return m(".row", [ |
222 | m(".col-sm-offset-4.col-sm-4", |
223 | m("button.btn.btn-block.btn-default", { |
224 | onclick: function() { |
225 | ctrl.showDesktop(!showDesktop); |
226 | } |
227 | }, action) |
228 | ) |
229 | ]); |
230 | } |
231 | |
232 | function renderRoomSelector() { |
233 | return m(".row", |
234 | m(".col-sm-offset-1.col-sm-10", |
235 | m("ol.breadcrumb.text-center", app.streams.map(function(s) { |
236 | return m("li", { |
237 | class: s === stream ? "active" : null, |
238 | onclick: function() { |
239 | var speakerVideo = document.getElementById("speaker-video"); |
240 | var desktopVideo = document.getElementById("desktop-video"); |
241 | |
242 | app.changeVideoMount(speakerVideo, s.speakerMount); |
243 | |
244 | // Video element doesn't exist when the user |
245 | // hasn't elected to show it. |
246 | if(desktopVideo) { |
247 | app.changeVideoMount(desktopVideo, s.desktopMount); |
248 | } |
249 | |
250 | ctrl.stream(s); |
251 | ctrl.updateStats(); |
252 | |
253 | return false; |
254 | } |
255 | }, m("a.alt-a", { href: "#" }, s.name)); |
256 | })))); |
257 | } |
258 | |
259 | function renderStats() { |
260 | var info; |
261 | |
262 | if(stats === app.nullStats) { |
263 | info = m("i", "not broadcasting"); |
264 | } else { |
265 | info = m("i", "live"); |
266 | } |
267 | // else if(app.validStreamInfo(stats)) { |
268 | // info = [ |
269 | // m("strong", stats.server_name), |
270 | // " — ", |
271 | // m("i", stats.server_description) |
272 | // ]; |
273 | // } else { |
274 | // info = null; |
275 | // } |
276 | |
277 | //return m(".row", [ |
278 | // m(".col-sm-8", info), |
279 | // m(".col-sm-4.text-right", [ |
280 | // m("strong", stats.listeners), |
281 | // " watching" |
282 | // ]) |
283 | //]); |
284 | return m("span"); |
285 | } |
286 | |
287 | return [ |
288 | renderRoomSelector(), |
289 | m("h2", stream.name), |
290 | renderStats(), |
291 | renderSpeakerStream(), |
292 | showDesktop ? renderDesktopStream() : null, |
293 | renderToggleDesktopStream(), |
294 | m("h2", "IRC"), |
295 | m("p", "Join the discussion online!"), |
296 | m("ul", [ |
297 | m("li", [ |
298 | "Conference-wide Freenode IRC channel: ", |
299 | m("strong", "#libreplanet") |
300 | ]), |
301 | m("li", [ |
302 | "Freenode IRC channel for ", |
303 | stream.name, |
304 | ": ", |
305 | m("strong", stream.ircChannel) |
306 | ]), |
307 | m("li", [ |
308 | "Conference hashtag for ", |
309 | m("a", { href: "https://fsf.org/twitter" }, "microblogging"), |
310 | ": ", |
311 | m("strong", "#libreplanet") |
312 | ]) |
313 | ]) |
314 | ]; |
315 | }; |
316 | |
317 | m.module(document.getElementById("stream"), app); |