mobile/android/chrome/content/CastingApps.js

branch
TOR_BUG_3246
changeset 4
fc2d59ddac77
equal deleted inserted replaced
-1:000000000000 0:debbcbb2e9a1
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

mercurial