Bug 1447544 - Remove the event listeners and timers when the videocontrols binding is destroyed draft
authorTimothy Guan-tin Chien <timdream@gmail.com>
Wed, 28 Mar 2018 15:35:33 +0800
changeset 778513 fc00b22b83e6325c5306304b455cf4ff50c45882
parent 777695 9bec5291be35070c838fb9802f9cb816963e34bc
push id105504
push usertimdream@gmail.com
push dateFri, 06 Apr 2018 11:55:17 +0000
bugs1447544
milestone61.0a1
Bug 1447544 - Remove the event listeners and timers when the videocontrols binding is destroyed Additionally, - Remove `self = this` usage - Remove access of the `Utils` object from the scope chain - Modify DevTools debugger test case because all event listeners are the one unwrap-able object MozReview-Commit-ID: 5kbLgD951CM
devtools/client/debugger/test/mochitest/browser_dbg_event-listeners-03.js
toolkit/content/widgets/videocontrols.xml
--- a/devtools/client/debugger/test/mochitest/browser_dbg_event-listeners-03.js
+++ b/devtools/client/debugger/test/mochitest/browser_dbg_event-listeners-03.js
@@ -61,19 +61,20 @@ function testEventListeners(aThreadClien
   aThreadClient.eventListeners(aPacket => {
     if (aPacket.error) {
       let msg = "Error getting event listeners: " + aPacket.message;
       ok(false, msg);
       deferred.reject(msg);
       return;
     }
 
-    // There are 3 event listeners in the page: button.onclick, window.onload
-    // and one more from the video element controls.
-    is(aPacket.listeners.length, 3, "Found all event listeners.");
+    // There are 2 event listeners in the page: button.onclick, window.onload.
+    // The video element controls listeners are skipped — they cannot be
+    // unwrapped but they shouldn't cause us to throw either.
+    is(aPacket.listeners.length, 2, "Found all event listeners.");
     aThreadClient.resume(deferred.resolve);
   });
 
   return deferred.promise;
 }
 
 registerCleanupFunction(function () {
   gClient = null;
--- a/toolkit/content/widgets/videocontrols.xml
+++ b/toolkit/content/widgets/videocontrols.xml
@@ -414,25 +414,38 @@
       _cancelShowThrobberWhileResumingVideoDecoder() {
         if (this._showThrobberTimer) {
           clearTimeout(this._showThrobberTimer);
           this._showThrobberTimer = null;
         }
       },
 
       handleEvent(aEvent) {
-        this.log("Got media event ----> " + aEvent.type);
+        if (!aEvent.isTrusted) {
+          this.log("Drop untrusted event ----> " + aEvent.type);
+          return;
+        }
+
+        this.log("Got event ----> " + aEvent.type);
 
         // If the binding is detached (or has been replaced by a
         // newer instance of the binding), nuke our event-listeners.
         if (this.videocontrols.randomID != this.randomID) {
-          this.terminateEventListeners();
+          this.terminate();
           return;
         }
 
+        if (this.videoEvents.includes(aEvent.type)) {
+          this.handleVideoEvent(aEvent);
+        } else {
+          this.handleControlEvent(aEvent);
+        }
+      },
+
+      handleVideoEvent(aEvent) {
         switch (aEvent.type) {
           case "play":
             this.setPlayButtonState(false);
             this.setupStatusFader();
             if (!this._triggeredByControls && this.dynamicControls && this.videocontrols.isTouchControls) {
               this.startFadeOut(this.controlBar);
             }
             if (!this._triggeredByControls) {
@@ -460,17 +473,18 @@
           case "volumechange":
             this.updateVolumeControls();
             // Show the controls to highlight the changing volume,
             // but only if the click-to-play overlay has already
             // been hidden (we don't hide controls when the overlay is visible).
             if (this.clickToPlay.hidden && !this.isAudioOnly) {
               this.startFadeIn(this.controlBar);
               clearTimeout(this._hideControlsTimeout);
-              this._hideControlsTimeout = setTimeout(this._hideControlsFn, this.HIDE_CONTROLS_TIMEOUT_MS);
+              this._hideControlsTimeout =
+                setTimeout(() => this._hideControlsFn(), this.HIDE_CONTROLS_TIMEOUT_MS);
             }
             break;
           case "loadedmetadata":
             // If a <video> doesn't have any video data, treat it as <audio>
             // and show the controls (they won't fade back out)
             if (this.video instanceof HTMLVideoElement &&
                 (this.video.videoWidth == 0 || this.video.videoHeight == 0)) {
               this.isAudioOnly = true;
@@ -587,42 +601,129 @@
           case "mozvideoonlyseekbegin":
             this._delayShowThrobberWhileResumingVideoDecoder();
             break;
           case "mozvideoonlyseekcompleted":
             this._cancelShowThrobberWhileResumingVideoDecoder();
             this.setupStatusFader();
             break;
           default:
-            this.log("!!! event " + aEvent.type + " not handled!");
+            this.log("!!! media event " + aEvent.type + " not handled!");
         }
       },
 
-      terminateEventListeners() {
+      handleControlEvent(aEvent) {
+        switch (aEvent.type) {
+          case "click":
+            switch (aEvent.currentTarget) {
+              case this.muteButton:
+                this.toggleMute();
+                break;
+              case this.castingButton:
+                this.toggleCasting();
+                break;
+              case this.closedCaptionButton:
+                this.toggleClosedCaption();
+                break;
+              case this.fullscreenButton:
+                this.toggleFullscreen();
+                break;
+              case this.playButton:
+              case this.clickToPlay:
+              case this.controlsSpacer:
+                this.clickToPlayClickHandler(aEvent);
+                break;
+              case this.textTrackList:
+                const index = +aEvent.originalTarget.getAttribute("index");
+                this.changeTextTrack(index);
+                break;
+              case this.videocontrols:
+                // Prevent any click event within media controls from dispatching through to video.
+                aEvent.stopPropagation();
+                break;
+            }
+            break;
+          case "dblclick":
+            this.toggleFullscreen();
+            break;
+          case "resizevideocontrols":
+            this.adjustControlSize();
+            break;
+          // See comment at onFullscreenChange on bug 718107.
+          /*
+          case "fullscreenchange":
+            this.onFullscreenChange();
+            break;
+          */
+          case "keypress":
+            this.keyHandler(aEvent);
+            break;
+          case "dragstart":
+            aEvent.preventDefault(); // prevent dragging of controls image (bug 517114)
+            break;
+          case "input":
+            switch (aEvent.currentTarget) {
+              case this.scrubber:
+                this.onScrubberInput(aEvent);
+                break;
+              case this.volumeControl:
+                this.updateVolume();
+                break;
+            }
+            break;
+          case "change":
+            switch (aEvent.currentTarget) {
+              case this.scrubber:
+                this.onScrubberChange(aEvent);
+                break;
+              case this.video.textTracks:
+                this.setClosedCaptionButtonState();
+                break;
+            }
+            break;
+          case "mouseup":
+            // add mouseup listener additionally to handle the case that `change` event
+            // isn't fired when the input value before/after dragging are the same. (bug 1328061)
+            this.onScrubberChange(aEvent);
+            break;
+          case "addtrack":
+            this.onTextTrackAdd(aEvent);
+            break;
+          case "removetrack":
+            this.onTextTrackRemove(aEvent);
+            break;
+          case "media-videoCasting":
+            this.updateCasting(aEvent.detail);
+            break;
+          default:
+            this.log("!!! control event " + aEvent.type + " not handled!");
+        }
+      },
+
+      terminate() {
         if (this.videoEvents) {
           for (let event of this.videoEvents) {
             try {
               this.video.removeEventListener(event, this, {
                 capture: true,
                 mozSystemGroup: true
               });
             } catch (ex) {}
           }
         }
 
-        if (this.controlListeners) {
-          for (let element of this.controlListeners) {
-            try {
-              element.item.removeEventListener(element.event, element.func,
-                { mozSystemGroup: element.mozSystemGroup, capture: element.capture });
-            } catch (ex) {}
+        try {
+          for (let { el, type, capture = false } of this.controlsEvents) {
+            el.removeEventListener(type, this, { mozSystemGroup: true, capture });
           }
+        } catch (ex) {}
 
-          delete this.controlListeners;
-        }
+        clearTimeout(this._showControlsTimeout);
+        clearTimeout(this._hideControlsTimeout);
+        this._cancelShowThrobberWhileResumingVideoDecoder();
 
         this.log("--- videocontrols terminated ---");
       },
 
       hasError() {
         // We either have an explicit error, or the resource selection
         // algorithm is running and we've tried to load something and failed.
         // Note: we don't consider the case where we've tried to load but
@@ -882,29 +983,29 @@
         this.bufferBar.max = duration;
         this.bufferBar.value = endTime;
       },
 
       _controlsHiddenByTimeout: false,
       _showControlsTimeout: 0,
       SHOW_CONTROLS_TIMEOUT_MS: 500,
       _showControlsFn() {
-        if (Utils.video.matches("video:hover")) {
-          Utils.startFadeIn(Utils.controlBar, false);
-          Utils._showControlsTimeout = 0;
-          Utils._controlsHiddenByTimeout = false;
+        if (this.video.matches("video:hover")) {
+          this.startFadeIn(this.controlBar, false);
+          this._showControlsTimeout = 0;
+          this._controlsHiddenByTimeout = false;
         }
       },
 
       _hideControlsTimeout: 0,
       _hideControlsFn() {
-        if (!Utils.scrubber.isDragging) {
-          Utils.startFade(Utils.controlBar, false);
-          Utils._hideControlsTimeout = 0;
-          Utils._controlsHiddenByTimeout = true;
+        if (!this.scrubber.isDragging) {
+          this.startFade(this.controlBar, false);
+          this._hideControlsTimeout = 0;
+          this._controlsHiddenByTimeout = true;
         }
       },
       HIDE_CONTROLS_TIMEOUT_MS: 2000,
       onMouseMove(event) {
         // If the controls are static, don't change anything.
         if (!this.dynamicControls) {
           return;
         }
@@ -915,27 +1016,29 @@
         // its first frame. But since autoplay videos start off with no
         // controls, let them fade-out so the controls don't get stuck on.
         if (!this.firstFrameShown &&
             !this.video.autoplay) {
           return;
         }
 
         if (this._controlsHiddenByTimeout) {
-          this._showControlsTimeout = setTimeout(this._showControlsFn, this.SHOW_CONTROLS_TIMEOUT_MS);
+          this._showControlsTimeout =
+            setTimeout(() => this._showControlsFn(), this.SHOW_CONTROLS_TIMEOUT_MS);
         } else {
           this.startFade(this.controlBar, true);
         }
 
         // Hide the controls if the mouse cursor is left on top of the video
         // but above the control bar and if the click-to-play overlay is hidden.
         if ((this._controlsHiddenByTimeout ||
             event.clientY < this.controlBar.getBoundingClientRect().top) &&
             this.clickToPlay.hidden) {
-          this._hideControlsTimeout = setTimeout(this._hideControlsFn, this.HIDE_CONTROLS_TIMEOUT_MS);
+          this._hideControlsTimeout =
+            setTimeout(() => this._hideControlsFn(), this.HIDE_CONTROLS_TIMEOUT_MS);
         }
       },
 
       onMouseInOut(event) {
         // If the controls are static, don't change anything.
         if (!this.dynamicControls) {
           return;
         }
@@ -973,17 +1076,17 @@
           // Keep the controls visible if the click-to-play is visible.
           if (!this.clickToPlay.hidden) {
             return;
           }
 
           this.startFadeOut(this.controlBar, false);
           this.textTrackList.hidden = true;
           clearTimeout(this._showControlsTimeout);
-          Utils._controlsHiddenByTimeout = false;
+          this._controlsHiddenByTimeout = false;
         }
       },
 
       startFadeIn(element, immediate) {
         this.startFade(element, true, immediate);
       },
 
       startFadeOut(element, immediate) {
@@ -1211,17 +1314,18 @@
         this.updateOrientationState(this.isVideoInFullScreen);
 
         // This is already broken by bug 718107 (controls will be hidden
         // as soon as the video enters fullscreen).
         // We can think about restoring the behavior here once the bug is
         // fixed, or we could simply acknowledge the current behavior
         // after-the-fact and try not to fix this.
         if (this.isVideoInFullScreen) {
-          Utils._hideControlsTimeout = setTimeout(this._hideControlsFn, this.HIDE_CONTROLS_TIMEOUT_MS);
+          this._hideControlsTimeout =
+            setTimeout(() => this._hideControlsFn(), this.HIDE_CONTROLS_TIMEOUT_MS);
         }
 
         // Constructor will handle this correctly on the new DOM content in
         // the new binding.
         this.setFullscreenButtonState();
       },
       */
 
@@ -1541,23 +1645,16 @@
         tt.index = this.textTracksCount++;
 
         const label = tt.label || "";
         const ttText = document.createTextNode(label);
         const ttBtn = document.createElement("button");
 
         ttBtn.classList.add("textTrackItem");
         ttBtn.setAttribute("index", tt.index);
-
-        ttBtn.addEventListener("click", event => {
-          event.stopPropagation();
-
-          this.changeTextTrack(tt.index);
-        });
-
         ttBtn.appendChild(ttText);
 
         this.textTrackList.appendChild(ttBtn);
 
         if (tt.mode === "showing" && tt.index) {
           this.changeTextTrack(tt.index);
         }
       },
@@ -1825,74 +1922,63 @@
         // for all events in order to simplify the event listener add/remove.
         for (let event of this.videoEvents) {
           this.video.addEventListener(event, this, {
             capture: true,
             mozSystemGroup: true
           });
         }
 
-        var self = this;
-        this.controlListeners = [];
+        this.controlsEvents = [
+          { el: this.muteButton, type: "click" },
+          { el: this.castingButton, type: "click" },
+          { el: this.closedCaptionButton, type: "click" },
+          { el: this.fullscreenButton, type: "click" },
+          { el: this.playButton, type: "click" },
+          { el: this.clickToPlay, type: "click" },
 
-        // Helper function to add an event listener to the given element
-        // Due to this helper function, "Utils" is made available to the event
-        // listener functions. Hence declare it as a global for ESLint.
-        /* global Utils */
-        function addListener(elem, eventName, func, {capture = false, mozSystemGroup = true} = {}) {
-          let boundFunc = evt => evt.isTrusted && func.call(self, evt);
-          self.controlListeners.push({
-            item: elem,
-            event: eventName,
-            func: boundFunc,
-            capture,
-            mozSystemGroup,
-          });
-          elem.addEventListener(eventName, boundFunc, {mozSystemGroup, capture});
-        }
+          // On touch videocontrols, tapping controlsSpacer should show/hide
+          // the control bar, instead of playing the video or toggle fullscreen.
+          { el: this.controlsSpacer, type: "click", nonTouchOnly: true },
+          { el: this.controlsSpacer, type: "dblclick", nonTouchOnly: true },
+
+          { el: this.textTrackList, type: "click" },
 
-        addListener(this.muteButton, "click", this.toggleMute);
-        addListener(this.castingButton, "click", this.toggleCasting);
-        addListener(this.closedCaptionButton, "click", this.toggleClosedCaption);
-        addListener(this.fullscreenButton, "click", this.toggleFullscreen);
-        addListener(this.playButton, "click", this.clickToPlayClickHandler);
-        addListener(this.clickToPlay, "click", this.clickToPlayClickHandler);
+          { el: this.videocontrols, type: "resizevideocontrols" },
+
+          // See comment at onFullscreenChange on bug 718107.
+          // { el: this.video.ownerDocument, type: "fullscreenchange" },
+          { el: this.video, type: "keypress", capture: true },
+
+          // Prevent any click event within media controls from dispatching through to video.
+          { el: this.videocontrols, type: "click", mozSystemGroup: false },
 
-        // On touch videocontrols, tapping controlsSpacer should show/hide
-        // the control bar, instead of playing the video or toggle fullscreen.
-        if (!this.videocontrols.isTouchControls) {
-          addListener(this.controlsSpacer, "click", this.clickToPlayClickHandler);
-          addListener(this.controlsSpacer, "dblclick", this.toggleFullscreen);
-        }
+          // prevent dragging of controls image (bug 517114)
+          { el: this.videocontrols, type: "dragstart" },
 
-        addListener(this.videocontrols, "resizevideocontrols", this.adjustControlSize);
-        // See comment at onFullscreenChange on bug 718107.
-        // addListener(this.video.ownerDocument, "fullscreenchange", this.onFullscreenChange);
-        addListener(this.video, "keypress", this.keyHandler, {capture: true});
-        // Prevent any click event within media controls from dispatching through to video.
-        addListener(this.videocontrols, "click", function(event) {
-          event.stopPropagation();
-        }, {mozSystemGroup: false});
-        addListener(this.videocontrols, "dragstart", function(event) {
-          event.preventDefault(); // prevent dragging of controls image (bug 517114)
-        });
+          { el: this.scrubber, type: "input" },
+          { el: this.scrubber, type: "change" },
+          // add mouseup listener additionally to handle the case that `change` event
+          // isn't fired when the input value before/after dragging are the same. (bug 1328061)
+          { el: this.scrubber, type: "mouseup" },
+          { el: this.volumeControl, type: "input" },
+          { el: this.video.textTracks, type: "addtrack" },
+          { el: this.video.textTracks, type: "removetrack" },
+          { el: this.video.textTracks, type: "change" },
 
-        addListener(this.scrubber, "input", this.onScrubberInput);
-        addListener(this.scrubber, "change", this.onScrubberChange);
-        // add mouseup listener additionally to handle the case that `change` event
-        // isn't fired when the input value before/after dragging are the same. (bug 1328061)
-        addListener(this.scrubber, "mouseup", this.onScrubberChange);
-        addListener(this.volumeControl, "input", this.updateVolume);
-        addListener(this.video.textTracks, "addtrack", this.onTextTrackAdd);
-        addListener(this.video.textTracks, "removetrack", this.onTextTrackRemove);
-        addListener(this.video.textTracks, "change", this.setClosedCaptionButtonState);
+          { el: this.video, type: "media-videoCasting", touchOnly: true }
+        ];
 
-        if (this.videocontrols.isTouchControls) {
-          addListener(this.video, "media-videoCasting",
-            (evt) => this.updateCasting(evt.detail));
+        for (let { el, type, nonTouchOnly = false, touchOnly = false,
+                   mozSystemGroup = true, capture = false } of this.controlsEvents) {
+          if ((this.videocontrols.isTouchControls && nonTouchOnly) ||
+              (!this.videocontrols.isTouchControls && touchOnly)) {
+            continue;
+          }
+          el.addEventListener(type, this, { mozSystemGroup, capture });
         }
 
         this.log("--- videocontrols initialized ---");
       }
     };
 
     this.TouchUtils = {
       videocontrols: null,
@@ -1904,22 +1990,17 @@
         return this.videocontrols.Utils;
       },
 
       get visible() {
         return !this.Utils.controlBar.hasAttribute("fadeout") &&
                !(this.Utils.controlBar.hidden);
       },
 
-      _firstShow: false,
-      get firstShow() { return this._firstShow; },
-      set firstShow(val) {
-        this._firstShow = val;
-        this.Utils.controlBar.setAttribute("firstshow", val);
-      },
+      firstShow: false,
 
       toggleControls() {
         if (!this.Utils.dynamicControls || !this.visible) {
           this.showControls();
         } else {
           this.delayHideControls(0);
         }
       },
@@ -1935,72 +2016,91 @@
         if (this.controlsTimer) {
           clearTimeout(this.controlsTimer);
           this.controlsTimer = null;
         }
       },
 
       delayHideControls(aTimeout) {
         this.clearTimer();
-        let self = this;
-        this.controlsTimer = setTimeout(function() {
-          self.hideControls();
-        }, aTimeout);
+        this.controlsTimer =
+          setTimeout(() => this.hideControls(), aTimeout);
       },
 
       hideControls() {
         if (!this.Utils.dynamicControls) {
           return;
         }
         this.Utils.startFadeOut(this.Utils.controlBar);
       },
 
       handleEvent(aEvent) {
+        switch (aEvent.type) {
+          case "click":
+            switch (aEvent.currentTarget) {
+              case this.Utils.playButton:
+                if (!this.video.paused) {
+                  this.delayHideControls(0);
+                } else {
+                  this.showControls();
+                }
+                break;
+              case this.Utils.muteButton:
+                this.delayHideControls(this.controlsTimeout);
+                break;
+            }
+            break;
+          case "touchstart":
+            this.clearTimer();
+            break;
+          case "touchend":
+            this.delayHideControls(this.controlsTimeout);
+            break;
+          case "mouseup":
+            if (aEvent.originalTarget == this.Utils.controlsSpacer) {
+              if (this.firstShow) {
+                this.Utils.video.play();
+                this.firstShow = false;
+              }
+              this.toggleControls();
+            }
+
+            break;
+        }
+
         if (this.videocontrols.randomID != this.Utils.randomID) {
-          this.terminateEventListeners();
+          this.terminate();
         }
       },
 
-      terminateEventListeners() {
-        for (var event of this.videoEvents) {
-          try {
-            this.Utils.video.removeEventListener(event, this);
-          } catch (ex) {}
-        }
+      terminate() {
+        try {
+          for (let { el, type, mozSystemGroup = true } of this.controlsEvents) {
+            el.removeEventListener(type, this, { mozSystemGroup });
+          }
+        } catch (ex) {}
+
+        this.clearTimer();
       },
 
       init(binding) {
         this.videocontrols = binding;
         this.video = binding.parentNode;
 
-        let self = this;
-        this.Utils.playButton.addEventListener("click", function() {
-          if (!self.video.paused) {
-            self.delayHideControls(0);
-          } else {
-            self.showControls();
-          }
-        });
-        this.Utils.scrubber.addEventListener("touchstart", function() {
-          self.clearTimer();
-        });
-        this.Utils.scrubber.addEventListener("touchend", function() {
-          self.delayHideControls(self.controlsTimeout);
-        });
-        this.Utils.muteButton.addEventListener("click", function() { self.delayHideControls(self.controlsTimeout); });
+        this.controlsEvents = [
+          { el: this.Utils.playButton, type: "click" },
+          { el: this.Utils.scrubber, type: "touchstart" },
+          { el: this.Utils.scrubber, type: "touchend" },
+          { el: this.Utils.muteButton, type: "click" },
+          { el: this.Utils.controlsSpacer, type: "mouseup" }
+        ];
 
-        this.Utils.controlsSpacer.addEventListener("mouseup", function(event) {
-          if (event.originalTarget == self.Utils.controlsSpacer) {
-            if (self.firstShow) {
-              self.Utils.video.play();
-              self.firstShow = false;
-            }
-            self.toggleControls();
-          }
-        });
+        for (let { el, type, mozSystemGroup = true } of this.controlsEvents) {
+          el.addEventListener(type, this, { mozSystemGroup });
+        }
 
         // 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 (!this.video.autoplay && this.Utils.dynamicControls && this.video.paused &&
@@ -2022,17 +2122,18 @@
     if (this.isTouchControls) {
       this.TouchUtils.init(this);
     }
     this.dispatchEvent(new CustomEvent("VideoBindingAttached"));
     ]]>
   </constructor>
   <destructor>
     <![CDATA[
-    this.Utils.terminateEventListeners();
+    this.Utils.terminate();
+    this.TouchUtils.terminate();
     this.Utils.updateOrientationState(false);
     // randomID used to be a <field>, which meant that the XBL machinery
     // undefined the property when the element was unbound. The code in
     // this file actually depends on this, so now that randomID is an
     // expando, we need to make sure to explicitly delete it.
     delete this.randomID;
     ]]>
   </destructor>
@@ -2076,71 +2177,75 @@
 
   <implementation>
   <constructor>
     <![CDATA[
     this.randomID = 0;
     this.Utils = {
       randomID: 0,
       videoEvents: ["play",
-                    "playing"],
-      controlListeners: [],
-      terminateEventListeners() {
+                    "playing",
+                    "MozNoControlsBlockedVideo"],
+      terminate() {
         for (let event of this.videoEvents) {
           try {
-            this.video.removeEventListener(event, this, { mozSystemGroup: true });
+            this.video.removeEventListener(event, this, {
+              capture: true,
+              mozSystemGroup: true
+            });
           } catch (ex) {}
         }
 
-        for (let element of this.controlListeners) {
-          try {
-            element.item.removeEventListener(element.event, element.func,
-              { mozSystemGroup: true });
-          } catch (ex) {}
-        }
-
-        delete this.controlListeners;
+        try {
+          this.clickToPlay.removeEventListener("click", this, { mozSystemGroup: true });
+        } catch (ex) {}
       },
 
       hasError() {
         return (this.video.error != null || this.video.networkState == this.video.NETWORK_NO_SOURCE);
       },
 
       handleEvent(aEvent) {
         // If the binding is detached (or has been replaced by a
         // newer instance of the binding), nuke our event-listeners.
         if (this.videocontrols.randomID != this.randomID) {
-          this.terminateEventListeners();
+          this.terminate();
           return;
         }
 
         switch (aEvent.type) {
           case "play":
             this.noControlsOverlay.hidden = true;
             break;
           case "playing":
             this.noControlsOverlay.hidden = true;
             break;
+          case "MozNoControlsBlockedVideo":
+            this.blockedVideoHandler();
+            break;
+          case "click":
+            this.clickToPlayClickHandler(aEvent);
+            break;
         }
       },
 
       blockedVideoHandler() {
         if (this.videocontrols.randomID != this.randomID) {
-          this.terminateEventListeners();
+          this.terminate();
           return;
         } else if (this.hasError()) {
           this.noControlsOverlay.hidden = true;
           return;
         }
         this.noControlsOverlay.hidden = false;
       },
 
       clickToPlayClickHandler(e) {
         if (this.videocontrols.randomID != this.randomID) {
-          this.terminateEventListeners();
+          this.terminate();
           return;
         } else if (e.button != 0) {
           return;
         }
 
         this.noControlsOverlay.hidden = true;
         this.video.play();
       },
@@ -2160,37 +2265,33 @@
         }
 
         // TODO: Switch to touch controls on touch-based desktops (bug 1447547)
         this.videocontrols.isTouchControls = isMobile;
         if (this.videocontrols.isTouchControls) {
           this.controlsContainer.classList.add("touch");
         }
 
-        let self = this;
-        function addListener(elem, eventName, func) {
-          let boundFunc = func.bind(self);
-          self.controlListeners.push({ item: elem, event: eventName, func: boundFunc });
-          elem.addEventListener(eventName, boundFunc, { mozSystemGroup: true });
-        }
-        addListener(this.clickToPlay, "click", this.clickToPlayClickHandler);
-        addListener(this.video, "MozNoControlsBlockedVideo", this.blockedVideoHandler);
+        this.clickToPlay.addEventListener("click", this, { mozSystemGroup: true });
 
         for (let event of this.videoEvents) {
-          this.video.addEventListener(event, this, { mozSystemGroup: true });
+          this.video.addEventListener(event, this, {
+            capture: true,
+            mozSystemGroup: true
+          });
         }
       }
     };
     this.Utils.init(this);
     this.Utils.video.dispatchEvent(new CustomEvent("MozNoControlsVideoBindingAttached"));
     ]]>
   </constructor>
   <destructor>
     <![CDATA[
-    this.Utils.terminateEventListeners();
+    this.Utils.terminate();
     // randomID used to be a <field>, which meant that the XBL machinery
     // undefined the property when the element was unbound. The code in
     // this file actually depends on this, so now that randomID is an
     // expando, we need to make sure to explicitly delete it.
     delete this.randomID;
     ]]>
   </destructor>
   </implementation>