bug 1054959 - Add 'Send Video To Device' to the context menu for sending videos from desktop to a second screen r=gavin, ui-r=madhava
☠☠ backed out by 095f351a7386 ☠ ☠
authorBrad Lassey <blassey@mozilla.com>
Wed, 15 Oct 2014 18:24:34 -0400
changeset 210584 402cbbf91165870533e5b6b1bdba08a66ec3249f
parent 210583 b008a281364db05e62fd8b4974cd71cffa748b11
child 210585 daf3d8773b12886156ebdef67fdcb515bf4d6829
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersgavin, madhava
bugs1054959
milestone36.0a1
bug 1054959 - Add 'Send Video To Device' to the context menu for sending videos from desktop to a second screen r=gavin, ui-r=madhava
browser/base/content/browser-context.inc
browser/base/content/browser.js
browser/base/content/nsContextMenu.js
browser/components/nsBrowserGlue.js
browser/locales/en-US/chrome/browser/browser.dtd
browser/modules/CastingApps.jsm
browser/modules/moz.build
toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm
--- a/browser/base/content/browser-context.inc
+++ b/browser/base/content/browser-context.inc
@@ -235,16 +235,21 @@
       <menuitem id="context-video-saveimage"
                 accesskey="&videoSaveImage.accesskey;"
                 label="&videoSaveImage.label;"
                 oncommand="gContextMenu.saveVideoFrameAsImage();"/>
       <menuitem id="context-sendvideo"
                 label="&emailVideoCmd.label;"
                 accesskey="&emailVideoCmd.accesskey;"
                 oncommand="gContextMenu.sendMedia();"/>
+      <menu id="context-castvideo"
+                label="&castVideoCmd.label;"
+                accesskey="&castVideoCmd.accesskey;">
+        <menupopup id="context-castvideo-popup" onpopupshowing="gContextMenu.populateCastVideoMenu(this)"/>
+      </menu>
       <menuitem id="context-sendaudio"
                 label="&emailAudioCmd.label;"
                 accesskey="&emailAudioCmd.accesskey;"
                 oncommand="gContextMenu.sendMedia();"/>
       <menuitem id="context-ctp-play"
                 label="&playPluginCmd.label;"
                 accesskey="&playPluginCmd.accesskey;"
                 oncommand="gContextMenu.playPlugin();"/>
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -183,16 +183,22 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 #endif
 
 XPCOMUtils.defineLazyModuleGetter(this, "FormValidationHandler",
   "resource:///modules/FormValidationHandler.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "UITour",
   "resource:///modules/UITour.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "CastingApps",
+  "resource:///modules/CastingApps.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "SimpleServiceDiscovery",
+  "resource://gre/modules/SimpleServiceDiscovery.jsm");
+
 let gInitialPages = [
   "about:blank",
   "about:newtab",
   "about:home",
   "about:privatebrowsing",
   "about:welcomeback",
   "about:sessionrestore"
 ];
--- a/browser/base/content/nsContextMenu.js
+++ b/browser/base/content/nsContextMenu.js
@@ -203,19 +203,29 @@ nsContextMenu.prototype = {
     this.showItem("context-savevideo", this.onVideo);
     this.showItem("context-saveaudio", this.onAudio);
     this.showItem("context-video-saveimage", this.onVideo);
     this.setItemAttr("context-savevideo", "disabled", !this.mediaURL);
     this.setItemAttr("context-saveaudio", "disabled", !this.mediaURL);
     // Send media URL (but not for canvas, since it's a big data: URL)
     this.showItem("context-sendimage", this.onImage);
     this.showItem("context-sendvideo", this.onVideo);
+    this.showItem("context-castvideo", this.onVideo);
     this.showItem("context-sendaudio", this.onAudio);
     this.setItemAttr("context-sendvideo", "disabled", !this.mediaURL);
     this.setItemAttr("context-sendaudio", "disabled", !this.mediaURL);
+    // getServicesForVideo alone would be sufficient here (it depends on
+    // SimpleServiceDiscovery.services), but SimpleServiceDiscovery is garanteed
+    // to be already loaded, since we load it on startup, and CastingApps isn't,
+    // so check SimpleServiceDiscovery.services first to avoid needing to load
+    // CastingApps.jsm if we don't need to.
+    let shouldShowCast = this.mediaURL &&
+                         SimpleServiceDiscovery.services.length > 0 &&
+                         CastingApps.getServicesForVideo(this.target).length > 0;
+    this.setItemAttr("context-castvideo", "disabled", !shouldShowCast);
   },
 
   initViewItems: function CM_initViewItems() {
     // View source is always OK, unless in directory listing.
     this.showItem("context-viewpartialsource-selection",
                   this.isContentSelected);
     this.showItem("context-viewpartialsource-mathml",
                   this.onMathML && !this.isContentSelected);
@@ -1311,16 +1321,35 @@ nsContextMenu.prototype = {
     if (this.onCanvas || this.onImage)
         this.sendMedia();
   },
 
   sendMedia: function() {
     MailIntegration.sendMessage(this.mediaURL, "");
   },
 
+  castVideo: function() {
+    CastingApps.openExternal(this.target, window);
+  },
+
+  populateCastVideoMenu: function(popup) {
+    let videoEl = this.target;
+    popup.innerHTML = null;
+    let doc = popup.ownerDocument;
+    let services = CastingApps.getServicesForVideo(videoEl);
+    services.forEach(service => {
+      let item = doc.createElement("menuitem");
+      item.setAttribute("label", service.friendlyName);
+      item.addEventListener("command", event => {
+        CastingApps.sendVideoToService(videoEl, service);
+      });
+      popup.appendChild(item);
+    });
+  },
+
   playPlugin: function() {
     gPluginHandler.contextMenuCommand(this.browser, this.target, "play");
   },
 
   hidePlugin: function() {
     gPluginHandler.contextMenuCommand(this.browser, this.target, "hide");
   },
 
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -94,16 +94,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource:///modules/BrowserUITelemetry.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown",
                                   "resource://gre/modules/AsyncShutdown.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "LoginManagerParent",
                                   "resource://gre/modules/LoginManagerParent.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "SimpleServiceDiscovery",
+                                  "resource://gre/modules/SimpleServiceDiscovery.jsm");
+
 #ifdef NIGHTLY_BUILD
 XPCOMUtils.defineLazyModuleGetter(this, "SignInToWebsiteUX",
                                   "resource:///modules/SignInToWebsite.jsm");
 #endif
 
 XPCOMUtils.defineLazyModuleGetter(this, "ContentSearch",
                                   "resource:///modules/ContentSearch.jsm");
 
@@ -742,18 +745,40 @@ BrowserGlue.prototype = {
     if (Services.prefs.getBoolPref("dom.identity.enabled")) {
       SignInToWebsiteUX.uninit();
     }
 #endif
     webrtcUI.uninit();
     FormValidationHandler.uninit();
   },
 
+  _initServiceDiscovery: function () {
+    var rokuDevice = {
+      id: "roku:ecp",
+      target: "roku:ecp",
+      factory: function(aService) {
+        Cu.import("resource://gre/modules/RokuApp.jsm");
+        return new RokuApp(aService);
+      },
+      mirror: false,
+      types: ["video/mp4"],
+      extensions: ["mp4"]
+    };
+
+    // Register targets
+    SimpleServiceDiscovery.registerDevice(rokuDevice);
+
+    // Search for devices continuously every 120 seconds
+    SimpleServiceDiscovery.search(120 * 1000);
+  },
+
   // All initial windows have opened.
   _onWindowsRestored: function BG__onWindowsRestored() {
+    this._initServiceDiscovery();
+
     // Show update notification, if needed.
     if (Services.prefs.prefHasUserValue("app.update.postupdate"))
       this._showUpdateNotification();
 
     // Load the "more info" page for a locked places.sqlite
     // This property is set earlier by places-database-locked topic.
     if (this._isPlacesDatabaseLocked) {
       this._showPlacesLockedNotificationBox();
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -479,16 +479,18 @@ These should match what Safari and other
 <!ENTITY saveVideoCmd.label           "Save Video As…">
 <!ENTITY saveVideoCmd.accesskey       "v">
 <!ENTITY saveAudioCmd.label           "Save Audio As…">
 <!ENTITY saveAudioCmd.accesskey       "v">
 <!ENTITY emailImageCmd.label          "Email Image…">
 <!ENTITY emailImageCmd.accesskey      "g">
 <!ENTITY emailVideoCmd.label          "Email Video…">
 <!ENTITY emailVideoCmd.accesskey      "a">
+<!ENTITY castVideoCmd.label           "Send Video To Device">
+<!ENTITY castVideoCmd.accesskey       "c">
 <!ENTITY emailAudioCmd.label          "Email Audio…">
 <!ENTITY emailAudioCmd.accesskey      "a">
 <!ENTITY playPluginCmd.label          "Activate this plugin">
 <!ENTITY playPluginCmd.accesskey      "c">
 <!ENTITY hidePluginCmd.label          "Hide this plugin">
 <!ENTITY hidePluginCmd.accesskey      "H">
 <!ENTITY copyLinkCmd.label            "Copy Link Location">
 <!ENTITY copyLinkCmd.accesskey        "a">
new file mode 100644
--- /dev/null
+++ b/browser/modules/CastingApps.jsm
@@ -0,0 +1,160 @@
+// -*- Mode: js; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+this.EXPORTED_SYMBOLS = ["CastingApps"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/SimpleServiceDiscovery.jsm");
+
+
+var CastingApps = {
+  _sendEventToVideo: function (element, data) {
+    let event = element.ownerDocument.createEvent("CustomEvent");
+    event.initCustomEvent("media-videoCasting", false, true, JSON.stringify(data));
+    element.dispatchEvent(event);
+  },
+
+  makeURI: function (url, charset, baseURI) {
+    return Services.io.newURI(url, charset, baseURI);
+  },
+
+  getVideo: function (element) {
+    if (!element) {
+      return null;
+    }
+
+    let extensions = SimpleServiceDiscovery.getSupportedExtensions();
+    let types = SimpleServiceDiscovery.getSupportedMimeTypes();
+
+    // Grab the poster attribute from the <video>
+    let posterURL = element.poster;
+
+    // First, look to see if the <video> has a src attribute
+    let sourceURL = element.src;
+
+    // If empty, try the currentSrc
+    if (!sourceURL) {
+      sourceURL = element.currentSrc;
+    }
+
+    if (sourceURL) {
+      // Use the file extension to guess the mime type
+      let sourceURI = this.makeURI(sourceURL, null, this.makeURI(element.baseURI));
+      if (this.allowableExtension(sourceURI, extensions)) {
+        return { element: element, source: sourceURI.spec, poster: posterURL, sourceURI: sourceURI};
+      }
+    }
+
+    // Next, look to see if there is a <source> child element that meets
+    // our needs
+    let sourceNodes = element.getElementsByTagName("source");
+    for (let sourceNode of sourceNodes) {
+      let sourceURI = this.makeURI(sourceNode.src, null, this.makeURI(sourceNode.baseURI));
+
+      // Using the type attribute is our ideal way to guess the mime type. Otherwise,
+      // fallback to using the file extension to guess the mime type
+      if (this.allowableMimeType(sourceNode.type, types) || this.allowableExtension(sourceURI, extensions)) {
+        return { element: element, source: sourceURI.spec, poster: posterURL, sourceURI: sourceURI, type: sourceNode.type };
+      }
+    }
+
+    return null;
+  },
+
+  sendVideoToService: function (videoElement, service) {
+    if (!service)
+      return;
+
+    let video = this.getVideo(videoElement);
+    if (!video) {
+      return;
+    }
+
+    // Make sure we have a player app for the given service
+    let app = SimpleServiceDiscovery.findAppForService(service);
+    if (!app)
+      return;
+
+    video.title = videoElement.ownerDocument.defaultView.top.document.title;
+    if (video.element) {
+      // If the video is currently playing on the device, pause it
+      if (!video.element.paused) {
+        video.element.pause();
+      }
+    }
+
+    app.stop(() => {
+      app.start(started => {
+        if (!started) {
+          Cu.reportError("CastingApps: Unable to start app");
+          return;
+        }
+
+        app.remoteMedia(remoteMedia => {
+          if (!remoteMedia) {
+            Cu.reportError("CastingApps: Failed to create remotemedia");
+            return;
+          }
+
+          this.session = {
+            service: service,
+            app: app,
+            remoteMedia: remoteMedia,
+            data: {
+              title: video.title,
+              source: video.source,
+              poster: video.poster
+            },
+            videoRef: Cu.getWeakReference(video.element)
+          };
+        }, this);
+      });
+    });
+  },
+
+  getServicesForVideo: function (videoElement) {
+    let video = this.getVideo(videoElement);
+    if (!video) {
+      return {};
+    }
+
+    let filteredServices = SimpleServiceDiscovery.services.filter(service => {
+      return this.allowableExtension(video.sourceURI, service.extensions) ||
+             this.allowableMimeType(video.type, service.types);
+    });
+
+    return filteredServices;
+  },
+
+  // RemoteMedia callback API methods
+  onRemoteMediaStart: function (remoteMedia) {
+    if (!this.session) {
+      return;
+    }
+
+    remoteMedia.load(this.session.data);
+
+    let video = this.session.videoRef.get();
+    if (video) {
+      this._sendEventToVideo(video, { active: true });
+    }
+  },
+
+  onRemoteMediaStop: function (remoteMedia) {
+  },
+
+  onRemoteMediaStatus: function (remoteMedia) {
+  },
+
+  allowableExtension: function (uri, extensions) {
+    return (uri instanceof Ci.nsIURL) && extensions.indexOf(uri.fileExtension) != -1;
+  },
+
+  allowableMimeType: function (type, types) {
+    return types.indexOf(type) != -1;
+  }
+};
--- a/browser/modules/moz.build
+++ b/browser/modules/moz.build
@@ -9,16 +9,17 @@ MOCHITEST_CHROME_MANIFESTS += ['test/chr
 XPCSHELL_TESTS_MANIFESTS += [
     'test/unit/social/xpcshell.ini',
     'test/xpcshell/xpcshell.ini',
 ]
 
 EXTRA_JS_MODULES += [
     'BrowserNewTabPreloader.jsm',
     'BrowserUITelemetry.jsm',
+    'CastingApps.jsm',
     'Chat.jsm',
     'ContentClick.jsm',
     'ContentLinkHandler.jsm',
     'ContentSearch.jsm',
     'ContentWebRTC.jsm',
     'CustomizationTabPreloader.jsm',
     'DirectoryLinksProvider.jsm',
     'E10SUtils.jsm',
--- a/toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm
+++ b/toolkit/modules/secondscreen/SimpleServiceDiscovery.jsm
@@ -130,17 +130,19 @@ var SimpleServiceDiscovery = {
 
   // Stop the current continuous search
   stopSearch: function stopSearch() {
     this._searchRepeat.cancel();
   },
 
   _usingLAN: function() {
     let network = Cc["@mozilla.org/network/network-link-service;1"].getService(Ci.nsINetworkLinkService);
-    return (network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_WIFI || network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET);
+    return (network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_WIFI ||
+            network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_ETHERNET ||
+            network.linkType == Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN);
   },
 
   _search: function _search() {
     // If a search is already active, shut it down.
     this._searchShutdown();
 
     // We only search if on local network
     if (!this._usingLAN()) {