1.1 --- /dev/null Thu Jan 01 00:00:00 1970 +0000 1.2 +++ b/mobile/android/chrome/content/CastingApps.js Wed Dec 31 06:09:35 2014 +0100 1.3 @@ -0,0 +1,451 @@ 1.4 +/* This Source Code Form is subject to the terms of the Mozilla Public 1.5 + * License, v. 2.0. If a copy of the MPL was not distributed with this file, 1.6 + * You can obtain one at http://mozilla.org/MPL/2.0/. */ 1.7 +"use strict"; 1.8 + 1.9 +var CastingApps = { 1.10 + _castMenuId: -1, 1.11 + 1.12 + init: function ca_init() { 1.13 + if (!this.isEnabled()) { 1.14 + return; 1.15 + } 1.16 + 1.17 + // Register a service target 1.18 + SimpleServiceDiscovery.registerTarget("roku:ecp", function(aService, aApp) { 1.19 + Cu.import("resource://gre/modules/RokuApp.jsm"); 1.20 + return new RokuApp(aService, "FirefoxTest"); 1.21 + }); 1.22 + 1.23 + // Search for devices continuously every 120 seconds 1.24 + SimpleServiceDiscovery.search(120 * 1000); 1.25 + 1.26 + this._castMenuId = NativeWindow.contextmenus.add( 1.27 + Strings.browser.GetStringFromName("contextmenu.castToScreen"), 1.28 + this.filterCast, 1.29 + this.openExternal.bind(this) 1.30 + ); 1.31 + 1.32 + Services.obs.addObserver(this, "Casting:Play", false); 1.33 + Services.obs.addObserver(this, "Casting:Pause", false); 1.34 + Services.obs.addObserver(this, "Casting:Stop", false); 1.35 + 1.36 + BrowserApp.deck.addEventListener("TabSelect", this, true); 1.37 + BrowserApp.deck.addEventListener("pageshow", this, true); 1.38 + BrowserApp.deck.addEventListener("playing", this, true); 1.39 + BrowserApp.deck.addEventListener("ended", this, true); 1.40 + }, 1.41 + 1.42 + uninit: function ca_uninit() { 1.43 + BrowserApp.deck.removeEventListener("TabSelect", this, true); 1.44 + BrowserApp.deck.removeEventListener("pageshow", this, true); 1.45 + BrowserApp.deck.removeEventListener("playing", this, true); 1.46 + BrowserApp.deck.removeEventListener("ended", this, true); 1.47 + 1.48 + Services.obs.removeObserver(this, "Casting:Play"); 1.49 + Services.obs.removeObserver(this, "Casting:Pause"); 1.50 + Services.obs.removeObserver(this, "Casting:Stop"); 1.51 + 1.52 + NativeWindow.contextmenus.remove(this._castMenuId); 1.53 + }, 1.54 + 1.55 + isEnabled: function isEnabled() { 1.56 + return Services.prefs.getBoolPref("browser.casting.enabled"); 1.57 + }, 1.58 + 1.59 + observe: function (aSubject, aTopic, aData) { 1.60 + switch (aTopic) { 1.61 + case "Casting:Play": 1.62 + if (this.session && this.session.remoteMedia.status == "paused") { 1.63 + this.session.remoteMedia.play(); 1.64 + } 1.65 + break; 1.66 + case "Casting:Pause": 1.67 + if (this.session && this.session.remoteMedia.status == "started") { 1.68 + this.session.remoteMedia.pause(); 1.69 + } 1.70 + break; 1.71 + case "Casting:Stop": 1.72 + if (this.session) { 1.73 + this.closeExternal(); 1.74 + } 1.75 + break; 1.76 + } 1.77 + }, 1.78 + 1.79 + handleEvent: function(aEvent) { 1.80 + switch (aEvent.type) { 1.81 + case "TabSelect": { 1.82 + let tab = BrowserApp.getTabForBrowser(aEvent.target); 1.83 + this._updatePageActionForTab(tab, aEvent); 1.84 + break; 1.85 + } 1.86 + case "pageshow": { 1.87 + let tab = BrowserApp.getTabForWindow(aEvent.originalTarget.defaultView); 1.88 + this._updatePageActionForTab(tab, aEvent); 1.89 + break; 1.90 + } 1.91 + case "playing": 1.92 + case "ended": { 1.93 + let video = aEvent.target; 1.94 + if (video instanceof HTMLVideoElement) { 1.95 + // If playing, send the <video>, but if ended we send nothing to shutdown the pageaction 1.96 + this._updatePageActionForVideo(aEvent.type === "playing" ? video : null); 1.97 + } 1.98 + break; 1.99 + } 1.100 + } 1.101 + }, 1.102 + 1.103 + _sendEventToVideo: function _sendEventToVideo(aElement, aData) { 1.104 + let event = aElement.ownerDocument.createEvent("CustomEvent"); 1.105 + event.initCustomEvent("media-videoCasting", false, true, JSON.stringify(aData)); 1.106 + aElement.dispatchEvent(event); 1.107 + }, 1.108 + 1.109 + handleVideoBindingAttached: function handleVideoBindingAttached(aTab, aEvent) { 1.110 + // Let's figure out if we have everything needed to cast a video. The binding 1.111 + // defaults to |false| so we only need to send an event if |true|. 1.112 + let video = aEvent.target; 1.113 + if (!video instanceof HTMLVideoElement) { 1.114 + return; 1.115 + } 1.116 + 1.117 + if (SimpleServiceDiscovery.services.length == 0) { 1.118 + return; 1.119 + } 1.120 + 1.121 + if (!this.getVideo(video, 0, 0)) { 1.122 + return; 1.123 + } 1.124 + 1.125 + // Let the binding know casting is allowed 1.126 + this._sendEventToVideo(video, { allow: true }); 1.127 + }, 1.128 + 1.129 + handleVideoBindingCast: function handleVideoBindingCast(aTab, aEvent) { 1.130 + // The binding wants to start a casting session 1.131 + let video = aEvent.target; 1.132 + if (!video instanceof HTMLVideoElement) { 1.133 + return; 1.134 + } 1.135 + 1.136 + // Close an existing session first. closeExternal has checks for an exsting 1.137 + // session and handles remote and video binding shutdown. 1.138 + this.closeExternal(); 1.139 + 1.140 + // Start the new session 1.141 + this.openExternal(video, 0, 0); 1.142 + }, 1.143 + 1.144 + makeURI: function makeURI(aURL, aOriginCharset, aBaseURI) { 1.145 + return Services.io.newURI(aURL, aOriginCharset, aBaseURI); 1.146 + }, 1.147 + 1.148 + getVideo: function(aElement, aX, aY) { 1.149 + // Fast path: Is the given element a video element 1.150 + let video = this._getVideo(aElement); 1.151 + if (video) { 1.152 + return video; 1.153 + } 1.154 + 1.155 + // The context menu system will keep walking up the DOM giving us a chance 1.156 + // to find an element we match. When it hits <html> things can go BOOM. 1.157 + try { 1.158 + // Maybe this is an overlay, with the video element under it 1.159 + // Use the (x, y) location to guess at a <video> element 1.160 + let elements = aElement.ownerDocument.querySelectorAll("video"); 1.161 + for (let element of elements) { 1.162 + // Look for a video element contained in the overlay bounds 1.163 + let rect = element.getBoundingClientRect(); 1.164 + if (aY >= rect.top && aX >= rect.left && aY <= rect.bottom && aX <= rect.right) { 1.165 + video = this._getVideo(element); 1.166 + if (video) { 1.167 + break; 1.168 + } 1.169 + } 1.170 + } 1.171 + } catch(e) {} 1.172 + 1.173 + // Could be null 1.174 + return video; 1.175 + }, 1.176 + 1.177 + _getVideo: function(aElement) { 1.178 + // Given the hardware support for H264, let's only look for 'mp4' sources 1.179 + if (!aElement instanceof HTMLVideoElement) { 1.180 + return null; 1.181 + } 1.182 + 1.183 + function allowableExtension(aURI) { 1.184 + if (aURI && aURI instanceof Ci.nsIURL) { 1.185 + return (aURI.fileExtension == "mp4"); 1.186 + } 1.187 + return false; 1.188 + } 1.189 + 1.190 + // Grab the poster attribute from the <video> 1.191 + let posterURL = aElement.poster; 1.192 + 1.193 + // First, look to see if the <video> has a src attribute 1.194 + let sourceURL = aElement.src; 1.195 + 1.196 + // If empty, try the currentSrc 1.197 + if (!sourceURL) { 1.198 + sourceURL = aElement.currentSrc; 1.199 + } 1.200 + 1.201 + if (sourceURL) { 1.202 + // Use the file extension to guess the mime type 1.203 + let sourceURI = this.makeURI(sourceURL, null, this.makeURI(aElement.baseURI)); 1.204 + if (allowableExtension(sourceURI)) { 1.205 + return { element: aElement, source: sourceURI.spec, poster: posterURL }; 1.206 + } 1.207 + } 1.208 + 1.209 + // Next, look to see if there is a <source> child element that meets 1.210 + // our needs 1.211 + let sourceNodes = aElement.getElementsByTagName("source"); 1.212 + for (let sourceNode of sourceNodes) { 1.213 + let sourceURI = this.makeURI(sourceNode.src, null, this.makeURI(sourceNode.baseURI)); 1.214 + 1.215 + // Using the type attribute is our ideal way to guess the mime type. Otherwise, 1.216 + // fallback to using the file extension to guess the mime type 1.217 + if (sourceNode.type == "video/mp4" || allowableExtension(sourceURI)) { 1.218 + return { element: aElement, source: sourceURI.spec, poster: posterURL }; 1.219 + } 1.220 + } 1.221 + 1.222 + return null; 1.223 + }, 1.224 + 1.225 + filterCast: { 1.226 + matches: function(aElement, aX, aY) { 1.227 + if (SimpleServiceDiscovery.services.length == 0) 1.228 + return false; 1.229 + let video = CastingApps.getVideo(aElement, aX, aY); 1.230 + if (CastingApps.session) { 1.231 + return (video && CastingApps.session.data.source != video.source); 1.232 + } 1.233 + return (video != null); 1.234 + } 1.235 + }, 1.236 + 1.237 + pageAction: { 1.238 + click: function() { 1.239 + // Since this is a pageaction, we use the selected browser 1.240 + let browser = BrowserApp.selectedBrowser; 1.241 + if (!browser) { 1.242 + return; 1.243 + } 1.244 + 1.245 + // Look for a castable <video> that is playing, and start casting it 1.246 + let videos = browser.contentDocument.querySelectorAll("video"); 1.247 + for (let video of videos) { 1.248 + if (!video.paused && video.mozAllowCasting) { 1.249 + CastingApps.openExternal(video, 0, 0); 1.250 + return; 1.251 + } 1.252 + } 1.253 + } 1.254 + }, 1.255 + 1.256 + _findCastableVideo: function _findCastableVideo(aBrowser) { 1.257 + // Scan for a <video> being actively cast. Also look for a castable <video> 1.258 + // on the page. 1.259 + let castableVideo = null; 1.260 + let videos = aBrowser.contentDocument.querySelectorAll("video"); 1.261 + for (let video of videos) { 1.262 + if (video.mozIsCasting) { 1.263 + // This <video> is cast-active. Break out of loop. 1.264 + return video; 1.265 + } 1.266 + 1.267 + if (!video.paused && video.mozAllowCasting) { 1.268 + // This <video> is cast-ready. Keep looking so cast-active could be found. 1.269 + castableVideo = video; 1.270 + } 1.271 + } 1.272 + 1.273 + // Could be null 1.274 + return castableVideo; 1.275 + }, 1.276 + 1.277 + _updatePageActionForTab: function _updatePageActionForTab(aTab, aEvent) { 1.278 + // We only care about events on the selected tab 1.279 + if (aTab != BrowserApp.selectedTab) { 1.280 + return; 1.281 + } 1.282 + 1.283 + // Update the page action, scanning for a castable <video> 1.284 + this._updatePageAction(); 1.285 + }, 1.286 + 1.287 + _updatePageActionForVideo: function _updatePageActionForVideo(aVideo) { 1.288 + this._updatePageAction(aVideo); 1.289 + }, 1.290 + 1.291 + _updatePageAction: function _updatePageAction(aVideo) { 1.292 + // Remove any exising pageaction first, in case state changes or we don't have 1.293 + // a castable video 1.294 + if (this.pageAction.id) { 1.295 + NativeWindow.pageactions.remove(this.pageAction.id); 1.296 + delete this.pageAction.id; 1.297 + } 1.298 + 1.299 + if (!aVideo) { 1.300 + aVideo = this._findCastableVideo(BrowserApp.selectedBrowser); 1.301 + if (!aVideo) { 1.302 + return; 1.303 + } 1.304 + } 1.305 + 1.306 + // We only show pageactions if the <video> is from the selected tab 1.307 + if (BrowserApp.selectedTab != BrowserApp.getTabForWindow(aVideo.ownerDocument.defaultView.top)) { 1.308 + return; 1.309 + } 1.310 + 1.311 + // We check for two state here: 1.312 + // 1. The video is actively being cast 1.313 + // 2. The video is allowed to be cast and is currently playing 1.314 + // Both states have the same action: Show the cast page action 1.315 + if (aVideo.mozIsCasting) { 1.316 + this.pageAction.id = NativeWindow.pageactions.add({ 1.317 + title: Strings.browser.GetStringFromName("contextmenu.castToScreen"), 1.318 + icon: "drawable://casting_active", 1.319 + clickCallback: this.pageAction.click, 1.320 + important: true 1.321 + }); 1.322 + } else if (aVideo.mozAllowCasting) { 1.323 + this.pageAction.id = NativeWindow.pageactions.add({ 1.324 + title: Strings.browser.GetStringFromName("contextmenu.castToScreen"), 1.325 + icon: "drawable://casting", 1.326 + clickCallback: this.pageAction.click, 1.327 + important: true 1.328 + }); 1.329 + } 1.330 + }, 1.331 + 1.332 + prompt: function(aCallback) { 1.333 + let items = []; 1.334 + SimpleServiceDiscovery.services.forEach(function(aService) { 1.335 + let item = { 1.336 + label: aService.friendlyName, 1.337 + selected: false 1.338 + }; 1.339 + items.push(item); 1.340 + }); 1.341 + 1.342 + let prompt = new Prompt({ 1.343 + title: Strings.browser.GetStringFromName("casting.prompt") 1.344 + }).setSingleChoiceItems(items).show(function(data) { 1.345 + let selected = data.button; 1.346 + let service = selected == -1 ? null : SimpleServiceDiscovery.services[selected]; 1.347 + if (aCallback) 1.348 + aCallback(service); 1.349 + }); 1.350 + }, 1.351 + 1.352 + openExternal: function(aElement, aX, aY) { 1.353 + // Start a second screen media service 1.354 + let video = this.getVideo(aElement, aX, aY); 1.355 + if (!video) { 1.356 + return; 1.357 + } 1.358 + 1.359 + this.prompt(function(aService) { 1.360 + if (!aService) 1.361 + return; 1.362 + 1.363 + // Make sure we have a player app for the given service 1.364 + let app = SimpleServiceDiscovery.findAppForService(aService, "video-sharing"); 1.365 + if (!app) 1.366 + return; 1.367 + 1.368 + video.title = aElement.ownerDocument.defaultView.top.document.title; 1.369 + if (video.element) { 1.370 + // If the video is currently playing on the device, pause it 1.371 + if (!video.element.paused) { 1.372 + video.element.pause(); 1.373 + } 1.374 + } 1.375 + 1.376 + app.stop(function() { 1.377 + app.start(function(aStarted) { 1.378 + if (!aStarted) { 1.379 + dump("CastingApps: Unable to start app"); 1.380 + return; 1.381 + } 1.382 + 1.383 + app.remoteMedia(function(aRemoteMedia) { 1.384 + if (!aRemoteMedia) { 1.385 + dump("CastingApps: Failed to create remotemedia"); 1.386 + return; 1.387 + } 1.388 + 1.389 + this.session = { 1.390 + service: aService, 1.391 + app: app, 1.392 + remoteMedia: aRemoteMedia, 1.393 + data: { 1.394 + title: video.title, 1.395 + source: video.source, 1.396 + poster: video.poster 1.397 + }, 1.398 + videoRef: Cu.getWeakReference(video.element) 1.399 + }; 1.400 + }.bind(this), this); 1.401 + }.bind(this)); 1.402 + }.bind(this)); 1.403 + }.bind(this)); 1.404 + }, 1.405 + 1.406 + closeExternal: function() { 1.407 + if (!this.session) { 1.408 + return; 1.409 + } 1.410 + 1.411 + this.session.remoteMedia.shutdown(); 1.412 + this.session.app.stop(); 1.413 + 1.414 + let video = this.session.videoRef.get(); 1.415 + if (video) { 1.416 + this._sendEventToVideo(video, { active: false }); 1.417 + this._updatePageAction(); 1.418 + } 1.419 + 1.420 + delete this.session; 1.421 + }, 1.422 + 1.423 + // RemoteMedia callback API methods 1.424 + onRemoteMediaStart: function(aRemoteMedia) { 1.425 + if (!this.session) { 1.426 + return; 1.427 + } 1.428 + 1.429 + aRemoteMedia.load(this.session.data); 1.430 + sendMessageToJava({ type: "Casting:Started", device: this.session.service.friendlyName }); 1.431 + 1.432 + let video = this.session.videoRef.get(); 1.433 + if (video) { 1.434 + this._sendEventToVideo(video, { active: true }); 1.435 + this._updatePageAction(video); 1.436 + } 1.437 + }, 1.438 + 1.439 + onRemoteMediaStop: function(aRemoteMedia) { 1.440 + sendMessageToJava({ type: "Casting:Stopped" }); 1.441 + }, 1.442 + 1.443 + onRemoteMediaStatus: function(aRemoteMedia) { 1.444 + if (!this.session) { 1.445 + return; 1.446 + } 1.447 + 1.448 + let status = aRemoteMedia.status; 1.449 + if (status == "completed") { 1.450 + this.closeExternal(); 1.451 + } 1.452 + } 1.453 +}; 1.454 +