Bug 946454 - [Roku] Show 'casting' button in the video controls r=wesj, dolske
authorMark Finkle <mfinkle@mozilla.com>
Fri, 18 Apr 2014 16:48:06 -0400
changeset 179314 45a45f4a228fa68e3748735b8c7c02c571ada78d
parent 179313 d8300867f3f219a3d183ee0577d548d04f802d8c
child 179315 582b2d81ebe148856358b97f5395dd714c2efa5c
child 179330 e5b5eed30c6ec8887c464db72ff1f01ca69d9efd
push id26613
push userryanvm@gmail.com
push dateSat, 19 Apr 2014 02:00:22 +0000
treeherdermozilla-central@582b2d81ebe1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerswesj, dolske
bugs946454
milestone31.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 946454 - [Roku] Show 'casting' button in the video controls r=wesj, dolske
b2g/chrome/content/touchcontrols.css
browser/metro/theme/touchcontrols.css
mobile/android/chrome/content/CastingApps.js
mobile/android/chrome/content/browser.js
mobile/android/themes/core/images/cast-active-hdpi.png
mobile/android/themes/core/images/cast-ready-hdpi.png
mobile/android/themes/core/jar.mn
mobile/android/themes/core/touchcontrols.css
toolkit/content/widgets/videocontrols.xml
toolkit/locales/en-US/chrome/global/videocontrols.dtd
--- a/b2g/chrome/content/touchcontrols.css
+++ b/b2g/chrome/content/touchcontrols.css
@@ -24,16 +24,17 @@
 
 .controlsSpacer {
   display: none;
   -moz-box-flex: 0;
 }
 
 .fullscreenButton,
 .playButton,
+.castingButton,
 .muteButton {
   -moz-appearance: none;
   min-height: 42px;
   min-width: 42px;
   border: none !important;
 }
 
 .fullscreenButton {
@@ -57,16 +58,20 @@
 .controlBar.audio-only .playButton {
   transform: translateX(28px);
 }
 
 .playButton[paused="true"] {
   background: url("chrome://b2g/content/images/play-hdpi.png") no-repeat center;
 }
 
+.castingButton {
+  display: none;
+}
+
 .muteButton {
   background: url("chrome://b2g/content/images/mute-hdpi.png") no-repeat center;
 }
 
 .muteButton[muted="true"] {
   background: url("chrome://b2g/content/images/unmute-hdpi.png") no-repeat center;
 }
 
--- a/browser/metro/theme/touchcontrols.css
+++ b/browser/metro/theme/touchcontrols.css
@@ -24,16 +24,17 @@
 
 .controlsSpacer {
   display: none;
   -moz-box-flex: 0;
 }
 
 .fullscreenButton,
 .playButton,
+.castingButton,
 .muteButton {
   -moz-appearance: none;
   min-height: 42px;
   min-width: 42px;
   border: none !important;
 }
 
 .fullscreenButton {
@@ -57,16 +58,20 @@
 .controlBar.audio-only .playButton {
   transform: translateX(28px);
 }
 
 .playButton[paused="true"] {
   background: url("chrome://browser/skin/images/play-hdpi.png") no-repeat center;
 }
 
+.castingButton {
+  display: none;
+}
+
 .muteButton {
   background: url("chrome://browser/skin/images/mute-hdpi.png") no-repeat center;
 }
 
 .muteButton[muted="true"] {
   background: url("chrome://browser/skin/images/unmute-hdpi.png") no-repeat center;
 }
 
--- a/mobile/android/chrome/content/CastingApps.js
+++ b/mobile/android/chrome/content/CastingApps.js
@@ -58,16 +58,57 @@ var CastingApps = {
       case "Casting:Stop":
         if (this.session) {
           this.closeExternal();
         }
         break;
     }
   },
 
+  _sendEventToVideo: function _sendEventToVideo(aElement, aData) {
+    let event = aElement.ownerDocument.createEvent("CustomEvent");
+    event.initCustomEvent("media-videoCasting", false, true, JSON.stringify(aData));
+    aElement.dispatchEvent(event);
+  },
+
+  handleVideoBindingAttached: function handleVideoBindingAttached(aTab, aEvent) {
+    // Let's figure out if we have everything needed to cast a video. The binding
+    // defaults to |false| so we only need to send an event if |true|.
+    let video = aEvent.target;
+    if (!video instanceof HTMLVideoElement) {
+      return;
+    }
+
+    if (SimpleServiceDiscovery.services.length == 0) {
+      return;
+    }
+
+    if (!this.getVideo(video, 0, 0)) {
+      return;
+    }
+
+    // Let the binding know casting is allowed
+    this._sendEventToVideo(video, { allow: true });
+  },
+
+  handleVideoBindingCast: function handleVideoBindingCast(aTab, aEvent) {
+    // The binding wants to start a casting session
+    let video = aEvent.target;
+    if (!video instanceof HTMLVideoElement) {
+      return;
+    }
+
+    // Close an existing session first. closeExternal has checks for an exsting
+    // session and handles remote and video binding shutdown.
+    this.closeExternal();
+
+    // Start the new session
+    this.openExternal(video, 0, 0);
+  },
+
   makeURI: function makeURI(aURL, aOriginCharset, aBaseURI) {
     return Services.io.newURI(aURL, aOriginCharset, aBaseURI);
   },
 
   getVideo: function(aElement, aX, aY) {
     // Fast path: Is the given element a video element
     let video = this._getVideo(aElement);
     if (video) {
@@ -216,42 +257,54 @@ var CastingApps = {
             this.session = {
               service: aService,
               app: app,
               remoteMedia: aRemoteMedia,
               data: {
                 title: video.title,
                 source: video.source,
                 poster: video.poster
-              }
+              },
+              videoRef: Cu.getWeakReference(video.element)
             };
           }.bind(this), this);
         }.bind(this));
       }.bind(this));
     }.bind(this));
   },
 
   closeExternal: function() {
     if (!this.session) {
       return;
     }
 
     this.session.remoteMedia.shutdown();
     this.session.app.stop();
+
+    let video = this.session.videoRef.get();
+    if (video) {
+      this._sendEventToVideo(video, { active: false });
+    }
+
     delete this.session;
   },
 
   // RemoteMedia callback API methods
   onRemoteMediaStart: function(aRemoteMedia) {
     if (!this.session) {
       return;
     }
 
     aRemoteMedia.load(this.session.data);
     sendMessageToJava({ type: "Casting:Started", device: this.session.service.friendlyName });
+
+    let video = this.session.videoRef.get();
+    if (video) {
+      this._sendEventToVideo(video, { active: true });
+    }
   },
 
   onRemoteMediaStop: function(aRemoteMedia) {
     sendMessageToJava({ type: "Casting:Stopped" });
   },
 
   onRemoteMediaStatus: function(aRemoteMedia) {
     if (!this.session) {
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -3005,20 +3005,23 @@ Tab.prototype = {
     this.browser.addEventListener("DOMLinkAdded", this, true);
     this.browser.addEventListener("DOMTitleChanged", this, true);
     this.browser.addEventListener("DOMWindowClose", this, true);
     this.browser.addEventListener("DOMWillOpenModalDialog", this, true);
     this.browser.addEventListener("DOMAutoComplete", this, true);
     this.browser.addEventListener("blur", this, true);
     this.browser.addEventListener("scroll", this, true);
     this.browser.addEventListener("MozScrolledAreaChanged", this, true);
+    this.browser.addEventListener("pageshow", this, true);
+    this.browser.addEventListener("MozApplicationManifest", this, true);
+
     // Note that the XBL binding is untrusted
     this.browser.addEventListener("PluginBindingAttached", this, true, true);
-    this.browser.addEventListener("pageshow", this, true);
-    this.browser.addEventListener("MozApplicationManifest", this, true);
+    this.browser.addEventListener("VideoBindingAttached", this, true, true);
+    this.browser.addEventListener("VideoBindingCast", this, true, true);
 
     Services.obs.addObserver(this, "before-first-paint", false);
     Services.obs.addObserver(this, "after-viewport-change", false);
     Services.prefs.addObserver("browser.ui.zoom.force-user-scalable", this, false);
 
     if (aParams.delayLoad) {
       // If this is a zombie tab, attach restore data so the tab will be
       // restored when selected
@@ -3174,20 +3177,23 @@ Tab.prototype = {
     this.browser.removeEventListener("DOMLinkAdded", this, true);
     this.browser.removeEventListener("DOMTitleChanged", this, true);
     this.browser.removeEventListener("DOMWindowClose", this, true);
     this.browser.removeEventListener("DOMWillOpenModalDialog", this, true);
     this.browser.removeEventListener("DOMAutoComplete", this, true);
     this.browser.removeEventListener("blur", this, true);
     this.browser.removeEventListener("scroll", this, true);
     this.browser.removeEventListener("MozScrolledAreaChanged", this, true);
-    this.browser.removeEventListener("PluginBindingAttached", this, true);
     this.browser.removeEventListener("pageshow", this, true);
     this.browser.removeEventListener("MozApplicationManifest", this, true);
 
+    this.browser.removeEventListener("PluginBindingAttached", this, true, true);
+    this.browser.removeEventListener("VideoBindingAttached", this, true, true);
+    this.browser.removeEventListener("VideoBindingCast", this, true, true);
+
     Services.obs.removeObserver(this, "before-first-paint");
     Services.obs.removeObserver(this, "after-viewport-change");
     Services.prefs.removeObserver("browser.ui.zoom.force-user-scalable", this);
 
     // Make sure the previously selected panel remains selected. The selected panel of a deck is
     // not stable when panels are removed.
     let selectedPanel = BrowserApp.deck.selectedPanel;
     BrowserApp.deck.removeChild(this.browser);
@@ -3929,16 +3935,26 @@ Tab.prototype = {
         break;
       }
 
       case "PluginBindingAttached": {
         PluginHelper.handlePluginBindingAttached(this, aEvent);
         break;
       }
 
+      case "VideoBindingAttached": {
+        CastingApps.handleVideoBindingAttached(this, aEvent);
+        break;
+      }
+
+      case "VideoBindingCast": {
+        CastingApps.handleVideoBindingCast(this, aEvent);
+        break;
+      }
+
       case "MozApplicationManifest": {
         OfflineApps.offlineAppRequested(aEvent.originalTarget.defaultView);
         break;
       }
 
       case "pageshow": {
         // only send pageshow for the top-level document
         if (aEvent.originalTarget.defaultView != this.browser.contentWindow)
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..1be34d5bb94ff9d942472b99a8eaacf271c1ba57
GIT binary patch
literal 684
zc$@*O0#p5oP)<h;3K|Lk000e1NJLTq001fg001fo1^@s6#ly*400001b5ch_0Itp)
z=>Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2i^n-
z0}DB7W41~F00JyYL_t(o!|m8hsEtt+2k_syo^geg!BK=#9x-r)k+;DyV4%EH_YQ6t
z7%0WWtEkJ60ZEd3BTAHG;87TuoXmv?9Wj!w2-h)Lr*=XL-?^7>)$Uts?fupNTaW+#
z_Ab=xb^SqF^b^T}=0J0xe@F(VMyXQkQ(RRo7Yljnn*p?t^+;3wZ#2^nB&!B1<UE=K
z&4D%n=ykm75A?JggRr1aMzmC^jZ1u=a3iq~;eB68{fkni)`MHvg^9_U-I$4+eWgZ+
zV-XJGF|J~6%sq;-*puN-^<AJ(@a3EBtc-g&jZrbb9~<$pDL|{`;v|g5Ds(463fPSM
zm=(Tmv}GOHmgH~xEiPjNrsI49W+Lt;Y|s_8oO#!Gl`6G%j7@5CEtcX{_(sHs%n7>_
z*Rqc2dYnWJXD}iBw=gf73r6B1#zeF`(9?{aX#h6i0k(zjDHg{^z<3-B{dq(-GobCb
zg3n<O!BHFu`x(}uH|#Z782W)fbQJleu0K^R7gu5$ZYT3Qus!rUIG)VyZALSV3Hu?I
z;B2y{6H`Oqmu#mCFeCKYQM11_es-_WNd3!FrFIOhk>2(svV)U4d>z!(kPY#?pRmJU
zt<gc)8tH8f!rzkA><UZ{{ao_?vg81G`>&q->h=1!S2-uD<zh#a#09Jl|ITW;c(_!l
z4aJ*ixLypamWv;=rYC4CRca05zm)jrN8^16`fAYGluXXj`5k@^Gza>h0sRW1yPPwR
Si`nu30000<MNUMnLSTYRjXBBy
new file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..5966ee1b0c65c42635f6252c071c8b532ed04f23
GIT binary patch
literal 542
zc$@(q0^$9MP)<h;3K|Lk000e1NJLTq001fg001fo1^@s6#ly*400001b5ch_0Itp)
z=>Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L01FcU01FcV0GgZ_00007bV*G`2i^n&
z4krUjhY$n+00Ev!L_t(o!|j(lO9Md^g-?88AtDHA1RwYSL9h@D8x;i$8~=lr7FHIv
zHa4~vVk1f*Vj*H-A&8v_VlRjy7_byk@kKtHS-5UAm7Ce%99XhB_e}V5cXoC*1E?BQ
zqbgAV4WI$^k5u=Am$vmP6E1zFE`3ScJ&17E2hac-K&u$^NYZmPEcjEB>Lrb(>Va?G
znc5^3Qo=>KP05|4CrQVWmLzqhwWIv-uMQ<GOFBzaM{gt*V}WT&r;;`$wfLP3V2@^C
z2FL+#eAhKF7|SPhSP4+Ma1W3Nie}IwFy<RH2h0K4*q{mE-VA&IhJAuY`UJLs4pZv{
zu1w|@X!Q*m=@pna^>(0O>Um$Fv%o&^5eu%G%&6;lq;a1&Q!j853on@Zn(<G3fZ91%
z3v8S48_@4+S>VOg2An@cGAaRmcin1o-y33`Juuk~<F7a`6I&e;1C4ZHVEfISEdt%n
zpEUV%^BX&r1O-?({;u&Co!97Ag=jfzNcti0+YWzLQr4aC8GpomzDl}u=e3ghD!F?K
g?@R$Sfc^*2FBq&X=UzfRy#N3J07*qoM6N<$g6VD4L;wH)
--- a/mobile/android/themes/core/jar.mn
+++ b/mobile/android/themes/core/jar.mn
@@ -57,16 +57,18 @@ chrome.jar:
   skin/images/certerror-warning.png         (images/certerror-warning.png)
   skin/images/errorpage-larry-white.png     (images/errorpage-larry-white.png)
   skin/images/errorpage-larry-black.png     (images/errorpage-larry-black.png)
   skin/images/marketplace-logo.png          (images/marketplace-logo.png)
   skin/images/throbber.png                  (images/throbber.png)
   skin/images/search-clear-30.png           (images/search-clear-30.png)
   skin/images/play-hdpi.png                 (images/play-hdpi.png)
   skin/images/pause-hdpi.png                (images/pause-hdpi.png)
+  skin/images/cast-ready-hdpi.png           (images/cast-ready-hdpi.png)
+  skin/images/cast-active-hdpi.png          (images/cast-active-hdpi.png)
   skin/images/mute-hdpi.png                 (images/mute-hdpi.png)
   skin/images/unmute-hdpi.png               (images/unmute-hdpi.png)
   skin/images/scrubber-hdpi.png             (images/scrubber-hdpi.png)
   skin/images/about-btn-darkgrey.png        (images/about-btn-darkgrey.png)
   skin/images/logo-hdpi.png                 (images/logo-hdpi.png)
   skin/images/wordmark-hdpi.png             (images/wordmark-hdpi.png)
   skin/images/reader-plus-icon-mdpi.png     (images/reader-plus-icon-mdpi.png)
   skin/images/reader-plus-icon-hdpi.png     (images/reader-plus-icon-hdpi.png)
--- a/mobile/android/themes/core/touchcontrols.css
+++ b/mobile/android/themes/core/touchcontrols.css
@@ -23,32 +23,41 @@
 }
 
 .controlsSpacer {
   display: none;
   -moz-box-flex: 0;
 }
 
 .playButton,
+.castingButton,
 .muteButton {
   -moz-appearance: none;
   min-height: 42px;
   min-width: 42px;
   border: none !important;
 }
 
 .playButton {
   -moz-transform: translateX(21px);
   background: url("chrome://browser/skin/images/pause-hdpi.png") no-repeat center;
 }
 
 .playButton[paused="true"] {
   background: url("chrome://browser/skin/images/play-hdpi.png") no-repeat center;
 }
 
+.castingButton {
+  background: url("chrome://browser/skin/images/cast-ready-hdpi.png") no-repeat center;
+}
+
+.castingButton[active="true"] {
+  background: url("chrome://browser/skin/images/cast-active-hdpi.png") no-repeat center;
+}
+
 .muteButton {
   background: url("chrome://browser/skin/images/mute-hdpi.png") no-repeat center;
 }
 
 .muteButton[muted="true"] {
   background: url("chrome://browser/skin/images/unmute-hdpi.png") no-repeat center;
 }
 
--- a/toolkit/content/widgets/videocontrols.xml
+++ b/toolkit/content/widgets/videocontrols.xml
@@ -1522,16 +1522,18 @@
 
             <vbox class="controlsOverlay">
                 <spacer class="controlsSpacer" flex="1"/>
                 <box flex="1" hidden="true">
                     <box class="clickToPlay" hidden="true" flex="1"/>
                 </box>
                 <vbox class="controlBar" hidden="true">
                     <hbox class="buttonsBar">
+                        <button class="castingButton" hidden="true"
+                                aria-label="&castingButton.castingLabel;"/>
                         <button class="fullscreenButton"
                             enterfullscreenlabel="&fullscreenButton.enterfullscreenlabel;"
                             exitfullscreenlabel="&fullscreenButton.exitfullscreenlabel;"/>
                         <spacer flex="1"/>
                         <button class="playButton"
                                 playlabel="&playButton.playLabel;"
                                 pauselabel="&playButton.pauseLabel;"/>
                         <spacer flex="1"/>
@@ -1561,19 +1563,21 @@
 
     <implementation>
 
         <constructor>
           <![CDATA[
           this.isTouchControl = true;
           this.TouchUtils = {
             videocontrols: null,
-            controlsTimer : null,
-            controlsTimeout : 5000,
+            video: null,
+            controlsTimer: null,
+            controlsTimeout: 5000,
             positionLabel: null,
+            castingButton: null,
 
             get Utils() {
               return this.videocontrols.Utils;
             },
 
             get visible() {
               return !this.Utils.controlBar.hasAttribute("fadeout") &&
                      !(this.Utils.controlBar.getAttribute("hidden") == "true");
@@ -1635,55 +1639,113 @@
 
             },
 
             terminateEventListeners : function () {
               for each (var event in this.videoEvents)
                 this.Utils.video.removeEventListener(event, this, false);
             },
 
+            isVideoCasting : function () {
+              let unwrappedVideo = XPCNativeWrapper.unwrap(this.video);
+              if (unwrappedVideo.mozIsCasting)
+                return true;
+              return false;
+            },
+
+            updateCasting : function (eventDetail) {
+              let unwrappedVideo = XPCNativeWrapper.unwrap(this.video);
+              let castingData = JSON.parse(eventDetail);
+              if ("allow" in castingData) {
+                if (castingData.allow)
+                  unwrappedVideo.mozAllowCasting = true;
+                else
+                  delete unwrappedVideo.mozAllowCasting;
+              }
+
+              if ("active" in castingData) {
+                if (castingData.active)
+                  unwrappedVideo.mozIsCasting = true;
+                else
+                  delete unwrappedVideo.mozIsCasting;
+              }
+              this.setCastButtonState();
+            },
+
+            startCasting : function () {
+              this.videocontrols.dispatchEvent(new CustomEvent("VideoBindingCast"));
+            },
+
+            setCastButtonState : function () {
+              let unwrappedVideo = XPCNativeWrapper.unwrap(this.video);
+              if (this.isAudioOnly || !unwrappedVideo.mozAllowCasting) {
+                this.castingButton.hidden = true;
+                return;
+              }
+
+              if (unwrappedVideo.mozIsCasting) {
+                this.castingButton.setAttribute("active", "true");
+              } else {
+                this.castingButton.removeAttribute("active");
+              }
+
+              this.castingButton.hidden = false;
+            },
+
             init : function (binding) {
               this.videocontrols = binding;
-              var video = binding.parentNode;
+              this.video = binding.parentNode;
 
               let self = this;
               this.Utils.playButton.addEventListener("command", function() {
-                if (!self.Utils.video.paused)
+                if (!self.video.paused)
                     self.delayHideControls(0);
                 else
                     self.showControls();
               }, false);
               this.Utils.scrubber.addEventListener("touchstart", function() {
                 self.clearTimer();
               }, false);
               this.Utils.scrubber.addEventListener("touchend", function() {
                 self.delayHideControls(self.controlsTimeout);
               }, false);
               this.Utils.muteButton.addEventListener("click", function() { self.delayHideControls(self.controlsTimeout); }, false);
 
+              this.castingButton = document.getAnonymousElementByAttribute(binding, "class", "castingButton");
+              this.castingButton.addEventListener("command", function() {
+                self.startCasting();
+              }, false);
+
+              this.video.addEventListener("media-videoCasting", function (e) {
+                if (!e.isTrusted)
+                  return;
+                self.updateCasting(e.detail);
+              }, false, true);
+
               // The first time the controls appear we want to just display
               // a play button that does not fade away. The firstShow property
               // makes that happen. But because of bug 718107 this init() method
               // may be called again when we switch in or out of fullscreen
               // mode. So we only set firstShow if we're not autoplaying and
               // if we are at the beginning of the video and not already playing
-              if (!video.autoplay && this.Utils.dynamicControls && video.paused &&
-                  video.currentTime === 0)
+              if (!this.video.autoplay && this.Utils.dynamicControls && this.video.paused &&
+                  this.video.currentTime === 0)
                 this.firstShow = true;
 
               // If the video is not at the start, then we probably just
               // transitioned into or out of fullscreen mode, and we don't want
               // the controls to remain visible. this.controlsTimeout is a full
               // 5s, which feels too long after the transition.
-              if (video.currentTime !== 0) {
+              if (this.video.currentTime !== 0) {
                 this.delayHideControls(this.Utils.HIDE_CONTROLS_TIMEOUT_MS);
               }
             }
           };
           this.TouchUtils.init(this);
+          this.dispatchEvent(new CustomEvent("VideoBindingAttached"));
       ]]>
       </constructor>
       <destructor>
           <![CDATA[
           // XBL destructors don't appear to be inherited properly, so we need
           // to do this here in addition to the videoControls destructor. :-(
           delete this.randomID;
           ]]>
--- a/toolkit/locales/en-US/chrome/global/videocontrols.dtd
+++ b/toolkit/locales/en-US/chrome/global/videocontrols.dtd
@@ -3,16 +3,17 @@
    - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
 
 <!ENTITY playButton.playLabel "Play">
 <!ENTITY playButton.pauseLabel "Pause">
 <!ENTITY muteButton.muteLabel "Mute">
 <!ENTITY muteButton.unmuteLabel "Unmute">
 <!ENTITY fullscreenButton.enterfullscreenlabel "Full Screen">
 <!ENTITY fullscreenButton.exitfullscreenlabel "Exit Full Screen">
+<!ENTITY castingButton.castingLabel "Cast to Screen">
 
 <!ENTITY stats.media "Media">
 <!ENTITY stats.size "Size">
 <!ENTITY stats.activity "Activity">
 <!ENTITY stats.activityPaused "Paused">
 <!ENTITY stats.activityPlaying "Playing">
 <!ENTITY stats.activityEnded "Ended">
 <!ENTITY stats.activitySeeking "(seeking)">