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 179662 45a45f4a228fa68e3748735b8c7c02c571ada78d
parent 179661 d8300867f3f219a3d183ee0577d548d04f802d8c
child 179663 e5b5eed30c6ec8887c464db72ff1f01ca69d9efd
child 179735 582b2d81ebe148856358b97f5395dd714c2efa5c
push id272
push userpvanderbeken@mozilla.com
push dateMon, 05 May 2014 16:31:18 +0000
reviewerswesj, dolske
bugs946454
milestone31.0a1
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)">