|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, |
|
3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 "use strict"; |
|
5 |
|
6 var CastingApps = { |
|
7 _castMenuId: -1, |
|
8 |
|
9 init: function ca_init() { |
|
10 if (!this.isEnabled()) { |
|
11 return; |
|
12 } |
|
13 |
|
14 // Register a service target |
|
15 SimpleServiceDiscovery.registerTarget("roku:ecp", function(aService, aApp) { |
|
16 Cu.import("resource://gre/modules/RokuApp.jsm"); |
|
17 return new RokuApp(aService, "FirefoxTest"); |
|
18 }); |
|
19 |
|
20 // Search for devices continuously every 120 seconds |
|
21 SimpleServiceDiscovery.search(120 * 1000); |
|
22 |
|
23 this._castMenuId = NativeWindow.contextmenus.add( |
|
24 Strings.browser.GetStringFromName("contextmenu.castToScreen"), |
|
25 this.filterCast, |
|
26 this.openExternal.bind(this) |
|
27 ); |
|
28 |
|
29 Services.obs.addObserver(this, "Casting:Play", false); |
|
30 Services.obs.addObserver(this, "Casting:Pause", false); |
|
31 Services.obs.addObserver(this, "Casting:Stop", false); |
|
32 |
|
33 BrowserApp.deck.addEventListener("TabSelect", this, true); |
|
34 BrowserApp.deck.addEventListener("pageshow", this, true); |
|
35 BrowserApp.deck.addEventListener("playing", this, true); |
|
36 BrowserApp.deck.addEventListener("ended", this, true); |
|
37 }, |
|
38 |
|
39 uninit: function ca_uninit() { |
|
40 BrowserApp.deck.removeEventListener("TabSelect", this, true); |
|
41 BrowserApp.deck.removeEventListener("pageshow", this, true); |
|
42 BrowserApp.deck.removeEventListener("playing", this, true); |
|
43 BrowserApp.deck.removeEventListener("ended", this, true); |
|
44 |
|
45 Services.obs.removeObserver(this, "Casting:Play"); |
|
46 Services.obs.removeObserver(this, "Casting:Pause"); |
|
47 Services.obs.removeObserver(this, "Casting:Stop"); |
|
48 |
|
49 NativeWindow.contextmenus.remove(this._castMenuId); |
|
50 }, |
|
51 |
|
52 isEnabled: function isEnabled() { |
|
53 return Services.prefs.getBoolPref("browser.casting.enabled"); |
|
54 }, |
|
55 |
|
56 observe: function (aSubject, aTopic, aData) { |
|
57 switch (aTopic) { |
|
58 case "Casting:Play": |
|
59 if (this.session && this.session.remoteMedia.status == "paused") { |
|
60 this.session.remoteMedia.play(); |
|
61 } |
|
62 break; |
|
63 case "Casting:Pause": |
|
64 if (this.session && this.session.remoteMedia.status == "started") { |
|
65 this.session.remoteMedia.pause(); |
|
66 } |
|
67 break; |
|
68 case "Casting:Stop": |
|
69 if (this.session) { |
|
70 this.closeExternal(); |
|
71 } |
|
72 break; |
|
73 } |
|
74 }, |
|
75 |
|
76 handleEvent: function(aEvent) { |
|
77 switch (aEvent.type) { |
|
78 case "TabSelect": { |
|
79 let tab = BrowserApp.getTabForBrowser(aEvent.target); |
|
80 this._updatePageActionForTab(tab, aEvent); |
|
81 break; |
|
82 } |
|
83 case "pageshow": { |
|
84 let tab = BrowserApp.getTabForWindow(aEvent.originalTarget.defaultView); |
|
85 this._updatePageActionForTab(tab, aEvent); |
|
86 break; |
|
87 } |
|
88 case "playing": |
|
89 case "ended": { |
|
90 let video = aEvent.target; |
|
91 if (video instanceof HTMLVideoElement) { |
|
92 // If playing, send the <video>, but if ended we send nothing to shutdown the pageaction |
|
93 this._updatePageActionForVideo(aEvent.type === "playing" ? video : null); |
|
94 } |
|
95 break; |
|
96 } |
|
97 } |
|
98 }, |
|
99 |
|
100 _sendEventToVideo: function _sendEventToVideo(aElement, aData) { |
|
101 let event = aElement.ownerDocument.createEvent("CustomEvent"); |
|
102 event.initCustomEvent("media-videoCasting", false, true, JSON.stringify(aData)); |
|
103 aElement.dispatchEvent(event); |
|
104 }, |
|
105 |
|
106 handleVideoBindingAttached: function handleVideoBindingAttached(aTab, aEvent) { |
|
107 // Let's figure out if we have everything needed to cast a video. The binding |
|
108 // defaults to |false| so we only need to send an event if |true|. |
|
109 let video = aEvent.target; |
|
110 if (!video instanceof HTMLVideoElement) { |
|
111 return; |
|
112 } |
|
113 |
|
114 if (SimpleServiceDiscovery.services.length == 0) { |
|
115 return; |
|
116 } |
|
117 |
|
118 if (!this.getVideo(video, 0, 0)) { |
|
119 return; |
|
120 } |
|
121 |
|
122 // Let the binding know casting is allowed |
|
123 this._sendEventToVideo(video, { allow: true }); |
|
124 }, |
|
125 |
|
126 handleVideoBindingCast: function handleVideoBindingCast(aTab, aEvent) { |
|
127 // The binding wants to start a casting session |
|
128 let video = aEvent.target; |
|
129 if (!video instanceof HTMLVideoElement) { |
|
130 return; |
|
131 } |
|
132 |
|
133 // Close an existing session first. closeExternal has checks for an exsting |
|
134 // session and handles remote and video binding shutdown. |
|
135 this.closeExternal(); |
|
136 |
|
137 // Start the new session |
|
138 this.openExternal(video, 0, 0); |
|
139 }, |
|
140 |
|
141 makeURI: function makeURI(aURL, aOriginCharset, aBaseURI) { |
|
142 return Services.io.newURI(aURL, aOriginCharset, aBaseURI); |
|
143 }, |
|
144 |
|
145 getVideo: function(aElement, aX, aY) { |
|
146 // Fast path: Is the given element a video element |
|
147 let video = this._getVideo(aElement); |
|
148 if (video) { |
|
149 return video; |
|
150 } |
|
151 |
|
152 // The context menu system will keep walking up the DOM giving us a chance |
|
153 // to find an element we match. When it hits <html> things can go BOOM. |
|
154 try { |
|
155 // Maybe this is an overlay, with the video element under it |
|
156 // Use the (x, y) location to guess at a <video> element |
|
157 let elements = aElement.ownerDocument.querySelectorAll("video"); |
|
158 for (let element of elements) { |
|
159 // Look for a video element contained in the overlay bounds |
|
160 let rect = element.getBoundingClientRect(); |
|
161 if (aY >= rect.top && aX >= rect.left && aY <= rect.bottom && aX <= rect.right) { |
|
162 video = this._getVideo(element); |
|
163 if (video) { |
|
164 break; |
|
165 } |
|
166 } |
|
167 } |
|
168 } catch(e) {} |
|
169 |
|
170 // Could be null |
|
171 return video; |
|
172 }, |
|
173 |
|
174 _getVideo: function(aElement) { |
|
175 // Given the hardware support for H264, let's only look for 'mp4' sources |
|
176 if (!aElement instanceof HTMLVideoElement) { |
|
177 return null; |
|
178 } |
|
179 |
|
180 function allowableExtension(aURI) { |
|
181 if (aURI && aURI instanceof Ci.nsIURL) { |
|
182 return (aURI.fileExtension == "mp4"); |
|
183 } |
|
184 return false; |
|
185 } |
|
186 |
|
187 // Grab the poster attribute from the <video> |
|
188 let posterURL = aElement.poster; |
|
189 |
|
190 // First, look to see if the <video> has a src attribute |
|
191 let sourceURL = aElement.src; |
|
192 |
|
193 // If empty, try the currentSrc |
|
194 if (!sourceURL) { |
|
195 sourceURL = aElement.currentSrc; |
|
196 } |
|
197 |
|
198 if (sourceURL) { |
|
199 // Use the file extension to guess the mime type |
|
200 let sourceURI = this.makeURI(sourceURL, null, this.makeURI(aElement.baseURI)); |
|
201 if (allowableExtension(sourceURI)) { |
|
202 return { element: aElement, source: sourceURI.spec, poster: posterURL }; |
|
203 } |
|
204 } |
|
205 |
|
206 // Next, look to see if there is a <source> child element that meets |
|
207 // our needs |
|
208 let sourceNodes = aElement.getElementsByTagName("source"); |
|
209 for (let sourceNode of sourceNodes) { |
|
210 let sourceURI = this.makeURI(sourceNode.src, null, this.makeURI(sourceNode.baseURI)); |
|
211 |
|
212 // Using the type attribute is our ideal way to guess the mime type. Otherwise, |
|
213 // fallback to using the file extension to guess the mime type |
|
214 if (sourceNode.type == "video/mp4" || allowableExtension(sourceURI)) { |
|
215 return { element: aElement, source: sourceURI.spec, poster: posterURL }; |
|
216 } |
|
217 } |
|
218 |
|
219 return null; |
|
220 }, |
|
221 |
|
222 filterCast: { |
|
223 matches: function(aElement, aX, aY) { |
|
224 if (SimpleServiceDiscovery.services.length == 0) |
|
225 return false; |
|
226 let video = CastingApps.getVideo(aElement, aX, aY); |
|
227 if (CastingApps.session) { |
|
228 return (video && CastingApps.session.data.source != video.source); |
|
229 } |
|
230 return (video != null); |
|
231 } |
|
232 }, |
|
233 |
|
234 pageAction: { |
|
235 click: function() { |
|
236 // Since this is a pageaction, we use the selected browser |
|
237 let browser = BrowserApp.selectedBrowser; |
|
238 if (!browser) { |
|
239 return; |
|
240 } |
|
241 |
|
242 // Look for a castable <video> that is playing, and start casting it |
|
243 let videos = browser.contentDocument.querySelectorAll("video"); |
|
244 for (let video of videos) { |
|
245 if (!video.paused && video.mozAllowCasting) { |
|
246 CastingApps.openExternal(video, 0, 0); |
|
247 return; |
|
248 } |
|
249 } |
|
250 } |
|
251 }, |
|
252 |
|
253 _findCastableVideo: function _findCastableVideo(aBrowser) { |
|
254 // Scan for a <video> being actively cast. Also look for a castable <video> |
|
255 // on the page. |
|
256 let castableVideo = null; |
|
257 let videos = aBrowser.contentDocument.querySelectorAll("video"); |
|
258 for (let video of videos) { |
|
259 if (video.mozIsCasting) { |
|
260 // This <video> is cast-active. Break out of loop. |
|
261 return video; |
|
262 } |
|
263 |
|
264 if (!video.paused && video.mozAllowCasting) { |
|
265 // This <video> is cast-ready. Keep looking so cast-active could be found. |
|
266 castableVideo = video; |
|
267 } |
|
268 } |
|
269 |
|
270 // Could be null |
|
271 return castableVideo; |
|
272 }, |
|
273 |
|
274 _updatePageActionForTab: function _updatePageActionForTab(aTab, aEvent) { |
|
275 // We only care about events on the selected tab |
|
276 if (aTab != BrowserApp.selectedTab) { |
|
277 return; |
|
278 } |
|
279 |
|
280 // Update the page action, scanning for a castable <video> |
|
281 this._updatePageAction(); |
|
282 }, |
|
283 |
|
284 _updatePageActionForVideo: function _updatePageActionForVideo(aVideo) { |
|
285 this._updatePageAction(aVideo); |
|
286 }, |
|
287 |
|
288 _updatePageAction: function _updatePageAction(aVideo) { |
|
289 // Remove any exising pageaction first, in case state changes or we don't have |
|
290 // a castable video |
|
291 if (this.pageAction.id) { |
|
292 NativeWindow.pageactions.remove(this.pageAction.id); |
|
293 delete this.pageAction.id; |
|
294 } |
|
295 |
|
296 if (!aVideo) { |
|
297 aVideo = this._findCastableVideo(BrowserApp.selectedBrowser); |
|
298 if (!aVideo) { |
|
299 return; |
|
300 } |
|
301 } |
|
302 |
|
303 // We only show pageactions if the <video> is from the selected tab |
|
304 if (BrowserApp.selectedTab != BrowserApp.getTabForWindow(aVideo.ownerDocument.defaultView.top)) { |
|
305 return; |
|
306 } |
|
307 |
|
308 // We check for two state here: |
|
309 // 1. The video is actively being cast |
|
310 // 2. The video is allowed to be cast and is currently playing |
|
311 // Both states have the same action: Show the cast page action |
|
312 if (aVideo.mozIsCasting) { |
|
313 this.pageAction.id = NativeWindow.pageactions.add({ |
|
314 title: Strings.browser.GetStringFromName("contextmenu.castToScreen"), |
|
315 icon: "drawable://casting_active", |
|
316 clickCallback: this.pageAction.click, |
|
317 important: true |
|
318 }); |
|
319 } else if (aVideo.mozAllowCasting) { |
|
320 this.pageAction.id = NativeWindow.pageactions.add({ |
|
321 title: Strings.browser.GetStringFromName("contextmenu.castToScreen"), |
|
322 icon: "drawable://casting", |
|
323 clickCallback: this.pageAction.click, |
|
324 important: true |
|
325 }); |
|
326 } |
|
327 }, |
|
328 |
|
329 prompt: function(aCallback) { |
|
330 let items = []; |
|
331 SimpleServiceDiscovery.services.forEach(function(aService) { |
|
332 let item = { |
|
333 label: aService.friendlyName, |
|
334 selected: false |
|
335 }; |
|
336 items.push(item); |
|
337 }); |
|
338 |
|
339 let prompt = new Prompt({ |
|
340 title: Strings.browser.GetStringFromName("casting.prompt") |
|
341 }).setSingleChoiceItems(items).show(function(data) { |
|
342 let selected = data.button; |
|
343 let service = selected == -1 ? null : SimpleServiceDiscovery.services[selected]; |
|
344 if (aCallback) |
|
345 aCallback(service); |
|
346 }); |
|
347 }, |
|
348 |
|
349 openExternal: function(aElement, aX, aY) { |
|
350 // Start a second screen media service |
|
351 let video = this.getVideo(aElement, aX, aY); |
|
352 if (!video) { |
|
353 return; |
|
354 } |
|
355 |
|
356 this.prompt(function(aService) { |
|
357 if (!aService) |
|
358 return; |
|
359 |
|
360 // Make sure we have a player app for the given service |
|
361 let app = SimpleServiceDiscovery.findAppForService(aService, "video-sharing"); |
|
362 if (!app) |
|
363 return; |
|
364 |
|
365 video.title = aElement.ownerDocument.defaultView.top.document.title; |
|
366 if (video.element) { |
|
367 // If the video is currently playing on the device, pause it |
|
368 if (!video.element.paused) { |
|
369 video.element.pause(); |
|
370 } |
|
371 } |
|
372 |
|
373 app.stop(function() { |
|
374 app.start(function(aStarted) { |
|
375 if (!aStarted) { |
|
376 dump("CastingApps: Unable to start app"); |
|
377 return; |
|
378 } |
|
379 |
|
380 app.remoteMedia(function(aRemoteMedia) { |
|
381 if (!aRemoteMedia) { |
|
382 dump("CastingApps: Failed to create remotemedia"); |
|
383 return; |
|
384 } |
|
385 |
|
386 this.session = { |
|
387 service: aService, |
|
388 app: app, |
|
389 remoteMedia: aRemoteMedia, |
|
390 data: { |
|
391 title: video.title, |
|
392 source: video.source, |
|
393 poster: video.poster |
|
394 }, |
|
395 videoRef: Cu.getWeakReference(video.element) |
|
396 }; |
|
397 }.bind(this), this); |
|
398 }.bind(this)); |
|
399 }.bind(this)); |
|
400 }.bind(this)); |
|
401 }, |
|
402 |
|
403 closeExternal: function() { |
|
404 if (!this.session) { |
|
405 return; |
|
406 } |
|
407 |
|
408 this.session.remoteMedia.shutdown(); |
|
409 this.session.app.stop(); |
|
410 |
|
411 let video = this.session.videoRef.get(); |
|
412 if (video) { |
|
413 this._sendEventToVideo(video, { active: false }); |
|
414 this._updatePageAction(); |
|
415 } |
|
416 |
|
417 delete this.session; |
|
418 }, |
|
419 |
|
420 // RemoteMedia callback API methods |
|
421 onRemoteMediaStart: function(aRemoteMedia) { |
|
422 if (!this.session) { |
|
423 return; |
|
424 } |
|
425 |
|
426 aRemoteMedia.load(this.session.data); |
|
427 sendMessageToJava({ type: "Casting:Started", device: this.session.service.friendlyName }); |
|
428 |
|
429 let video = this.session.videoRef.get(); |
|
430 if (video) { |
|
431 this._sendEventToVideo(video, { active: true }); |
|
432 this._updatePageAction(video); |
|
433 } |
|
434 }, |
|
435 |
|
436 onRemoteMediaStop: function(aRemoteMedia) { |
|
437 sendMessageToJava({ type: "Casting:Stopped" }); |
|
438 }, |
|
439 |
|
440 onRemoteMediaStatus: function(aRemoteMedia) { |
|
441 if (!this.session) { |
|
442 return; |
|
443 } |
|
444 |
|
445 let status = aRemoteMedia.status; |
|
446 if (status == "completed") { |
|
447 this.closeExternal(); |
|
448 } |
|
449 } |
|
450 }; |
|
451 |