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 821fbd25435f ☠ ☠
authorBrad Lassey <blassey@mozilla.com>
Wed, 15 Oct 2014 18:24:34 -0400
changeset 235096 1828c0ae54b92bc309d19d4c3b8dd2c36b67afb5
parent 235095 2a0d75a59098a3a1699c7700a7eaec8f56bf462a
child 235097 821fbd25435f84ea8dcc753a81be551d598e7a3b
push id4311
push userraliiev@mozilla.com
push dateMon, 12 Jan 2015 19:37:41 +0000
treeherdermozilla-beta@150c9fed433b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersgavin, madhava
bugs1054959
milestone36.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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 * * * bug 1054959 - follow up to fix context menut test r=orange CLOSED TREE
browser/base/content/browser-context.inc
browser/base/content/browser.js
browser/base/content/nsContextMenu.js
browser/base/content/test/general/test_contextmenu.html
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/base/content/test/general/test_contextmenu.html
+++ b/browser/base/content/test/general/test_contextmenu.html
@@ -169,17 +169,18 @@ function runTest(testNum) {
                           "context-video-showstats",    true,
                           "context-video-fullscreen",   true,
                           "---",                        null,
                           "context-viewvideo",          true,
                           "context-copyvideourl",       true,
                           "---",                        null,
                           "context-savevideo",          true,
                           "context-video-saveimage",    true,
-                          "context-sendvideo",          true
+                          "context-sendvideo",          true,
+                          "context-castvideo",          false
                          ].concat(inspectItems));
         closeContextMenu();
         openContextMenuFor(audio_in_video); // Invoke context menu for next test.
     },
 
     function () {
         // Context menu for a video (with an audio-only file)
           checkContextMenu(["context-media-play",         true,
--- 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()) {