summaryrefslogtreecommitdiffstats
path: root/toolkit/content/widgets/videocontrols.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/content/widgets/videocontrols.js')
-rw-r--r--toolkit/content/widgets/videocontrols.js3402
1 files changed, 3402 insertions, 0 deletions
diff --git a/toolkit/content/widgets/videocontrols.js b/toolkit/content/widgets/videocontrols.js
new file mode 100644
index 0000000000..46c73d52b8
--- /dev/null
+++ b/toolkit/content/widgets/videocontrols.js
@@ -0,0 +1,3402 @@
+/* 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 is a UA widget. It runs in per-origin UA widget scope,
+// to be loaded by UAWidgetsChild.jsm.
+
+/*
+ * This is the class of entry. It will construct the actual implementation
+ * according to the value of the "controls" property.
+ */
+this.VideoControlsWidget = class {
+ constructor(shadowRoot, prefs) {
+ this.shadowRoot = shadowRoot;
+ this.prefs = prefs;
+ this.element = shadowRoot.host;
+ this.document = this.element.ownerDocument;
+ this.window = this.document.defaultView;
+
+ this.isMobile = this.window.navigator.appVersion.includes("Android");
+ }
+
+ /*
+ * Callback called by UAWidgets right after constructor.
+ */
+ onsetup() {
+ this.switchImpl();
+ }
+
+ /*
+ * Callback called by UAWidgets when the "controls" property changes.
+ */
+ onchange() {
+ this.switchImpl();
+ }
+
+ /*
+ * Actually switch the implementation.
+ * - With "controls" set, the VideoControlsImplWidget controls should load.
+ * - Without it, on mobile, the NoControlsMobileImplWidget should load, so
+ * the user could see the click-to-play button when the video/audio is blocked.
+ * - Without it, on desktop, the NoControlsPictureInPictureImpleWidget should load
+ * if the video is being viewed in Picture-in-Picture.
+ */
+ switchImpl() {
+ let newImpl;
+ let pageURI = this.document.documentURI;
+ if (this.element.controls) {
+ newImpl = VideoControlsImplWidget;
+ } else if (this.isMobile) {
+ newImpl = NoControlsMobileImplWidget;
+ } else if (VideoControlsWidget.isPictureInPictureVideo(this.element)) {
+ newImpl = NoControlsPictureInPictureImplWidget;
+ } else if (
+ pageURI.startsWith("http://") ||
+ pageURI.startsWith("https://")
+ ) {
+ newImpl = NoControlsDesktopImplWidget;
+ }
+
+ // Skip if we are asked to load the same implementation, and
+ // the underlying element state hasn't changed in ways that we
+ // care about. This can happen if the property is set again
+ // without a value change.
+ if (
+ this.impl &&
+ this.impl.constructor == newImpl &&
+ this.impl.elementStateMatches(this.element)
+ ) {
+ return;
+ }
+ if (this.impl) {
+ this.impl.teardown();
+ this.shadowRoot.firstChild.remove();
+ }
+ if (newImpl) {
+ this.impl = new newImpl(this.shadowRoot, this.prefs);
+
+ this.mDirection = "ltr";
+ let intlUtils = this.window.intlUtils;
+ if (intlUtils) {
+ this.mDirection = intlUtils.isAppLocaleRTL() ? "rtl" : "ltr";
+ }
+
+ this.impl.onsetup(this.mDirection);
+ } else {
+ this.impl = undefined;
+ }
+ }
+
+ teardown() {
+ if (!this.impl) {
+ return;
+ }
+ this.impl.teardown();
+ this.shadowRoot.firstChild.remove();
+ delete this.impl;
+ }
+
+ onPrefChange(prefName, prefValue) {
+ this.prefs[prefName] = prefValue;
+
+ if (!this.impl) {
+ return;
+ }
+
+ this.impl.onPrefChange(prefName, prefValue);
+ }
+
+ // If you change this, also change SEEK_TIME_SECS in PictureInPictureChild.sys.mjs
+ static SEEK_TIME_SECS = 5;
+
+ static isPictureInPictureVideo(someVideo) {
+ return someVideo.isCloningElementVisually;
+ }
+
+ /**
+ * Returns true if a <video> meets the requirements to show the Picture-in-Picture
+ * toggle. Those requirements currently are:
+ *
+ * 1. The video must be 45 seconds in length or longer.
+ * 2. Neither the width or the height of the video can be less than 140px.
+ * 3. The video must have audio.
+ * 4. The video must not a MediaStream video (Bug 1592539)
+ *
+ * This can be overridden via the
+ * media.videocontrols.picture-in-picture.video-toggle.always-show pref, which
+ * is mostly used for testing.
+ *
+ * @param {Object} prefs
+ * The preferences set that was passed to the UAWidget.
+ * @param {Element} someVideo
+ * The <video> to test.
+ * @param {Object} reflowedDimensions
+ * An object representing the reflowed dimensions of the <video>. Properties
+ * are:
+ *
+ * videoWidth (Number):
+ * The width of the video in pixels.
+ *
+ * videoHeight (Number):
+ * The height of the video in pixels.
+ *
+ * @return {Boolean}
+ */
+ static shouldShowPictureInPictureToggle(
+ prefs,
+ someVideo,
+ reflowedDimensions
+ ) {
+ if (
+ prefs[
+ "media.videocontrols.picture-in-picture.respect-disablePictureInPicture"
+ ] &&
+ someVideo.getAttribute("disablePictureInPicture") === "true"
+ ) {
+ return false;
+ }
+
+ if (isNaN(someVideo.duration)) {
+ return false;
+ }
+
+ if (
+ prefs["media.videocontrols.picture-in-picture.video-toggle.always-show"]
+ ) {
+ return true;
+ }
+
+ const MIN_VIDEO_LENGTH =
+ prefs[
+ "media.videocontrols.picture-in-picture.video-toggle.min-video-secs"
+ ];
+
+ if (someVideo.duration < MIN_VIDEO_LENGTH) {
+ return false;
+ }
+
+ const MIN_VIDEO_DIMENSION = 140; // pixels
+ if (
+ reflowedDimensions.videoWidth < MIN_VIDEO_DIMENSION ||
+ reflowedDimensions.videoHeight < MIN_VIDEO_DIMENSION
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Some variations on the Picture-in-Picture toggle are being experimented with.
+ * These variations have slightly different setup parameters from the currently
+ * shipping toggle, so this method sets up the experimental toggles in the event
+ * that they're being used. It also will enable the appropriate stylesheet for
+ * the preferred toggle experiment.
+ *
+ * @param {Object} prefs
+ * The preferences set that was passed to the UAWidget.
+ * @param {ShadowRoot} shadowRoot
+ * The shadowRoot of the <video> element where the video controls are.
+ * @param {Element} toggle
+ * The toggle element.
+ * @param {Object} reflowedDimensions
+ * An object representing the reflowed dimensions of the <video>. Properties
+ * are:
+ *
+ * videoWidth (Number):
+ * The width of the video in pixels.
+ *
+ * videoHeight (Number):
+ * The height of the video in pixels.
+ */
+ static setupToggle(prefs, toggle, reflowedDimensions) {
+ // These thresholds are all in pixels
+ const SMALL_VIDEO_WIDTH_MAX = 320;
+ const MEDIUM_VIDEO_WIDTH_MAX = 720;
+
+ let isSmall = reflowedDimensions.videoWidth <= SMALL_VIDEO_WIDTH_MAX;
+ toggle.toggleAttribute("small-video", isSmall);
+ toggle.toggleAttribute(
+ "medium-video",
+ !isSmall && reflowedDimensions.videoWidth <= MEDIUM_VIDEO_WIDTH_MAX
+ );
+
+ toggle.setAttribute(
+ "position",
+ prefs["media.videocontrols.picture-in-picture.video-toggle.position"]
+ );
+ toggle.toggleAttribute(
+ "has-used",
+ prefs["media.videocontrols.picture-in-picture.video-toggle.has-used"]
+ );
+ }
+};
+
+this.VideoControlsImplWidget = class {
+ constructor(shadowRoot, prefs) {
+ this.shadowRoot = shadowRoot;
+ this.prefs = prefs;
+ this.element = shadowRoot.host;
+ this.document = this.element.ownerDocument;
+ this.window = this.document.defaultView;
+ }
+
+ onsetup(direction) {
+ this.generateContent();
+
+ this.shadowRoot.firstElementChild.setAttribute("localedir", direction);
+
+ this.Utils = {
+ debug: false,
+ video: null,
+ videocontrols: null,
+ controlBar: null,
+ playButton: null,
+ muteButton: null,
+ volumeControl: null,
+ durationLabel: null,
+ positionLabel: null,
+ scrubber: null,
+ progressBar: null,
+ bufferBar: null,
+ statusOverlay: null,
+ controlsSpacer: null,
+ clickToPlay: null,
+ controlsOverlay: null,
+ fullscreenButton: null,
+ layoutControls: null,
+ isShowingPictureInPictureMessage: false,
+ l10n: this.l10n,
+
+ textTracksCount: 0,
+ videoEvents: [
+ "play",
+ "pause",
+ "ended",
+ "volumechange",
+ "loadeddata",
+ "loadstart",
+ "timeupdate",
+ "progress",
+ "playing",
+ "waiting",
+ "canplay",
+ "canplaythrough",
+ "seeking",
+ "seeked",
+ "emptied",
+ "loadedmetadata",
+ "error",
+ "suspend",
+ "stalled",
+ "mozvideoonlyseekbegin",
+ "mozvideoonlyseekcompleted",
+ "durationchange",
+ ],
+
+ firstFrameShown: false,
+ timeUpdateCount: 0,
+ maxCurrentTimeSeen: 0,
+ isPausedByDragging: false,
+ _isAudioOnly: false,
+
+ get isAudioTag() {
+ return this.video.localName == "audio";
+ },
+
+ get isAudioOnly() {
+ return this._isAudioOnly;
+ },
+ set isAudioOnly(val) {
+ this._isAudioOnly = val;
+ this.setFullscreenButtonState();
+ this.updatePictureInPictureToggleDisplay();
+
+ if (!this.isTopLevelSyntheticDocument) {
+ return;
+ }
+ if (this._isAudioOnly) {
+ this.video.style.height = this.controlBarMinHeight + "px";
+ this.video.style.width = "66%";
+ } else {
+ this.video.style.removeProperty("height");
+ this.video.style.removeProperty("width");
+ }
+ },
+
+ suppressError: false,
+
+ setupStatusFader(immediate) {
+ // Since the play button will be showing, we don't want to
+ // show the throbber behind it. The throbber here will
+ // only show if needed after the play button has been pressed.
+ if (!this.clickToPlay.hidden) {
+ this.startFadeOut(this.statusOverlay, true);
+ return;
+ }
+
+ var show = false;
+ if (
+ this.video.seeking ||
+ (this.video.error && !this.suppressError) ||
+ this.video.networkState == this.video.NETWORK_NO_SOURCE ||
+ (this.video.networkState == this.video.NETWORK_LOADING &&
+ (this.video.paused || this.video.ended
+ ? this.video.readyState < this.video.HAVE_CURRENT_DATA
+ : this.video.readyState < this.video.HAVE_FUTURE_DATA)) ||
+ (this.timeUpdateCount <= 1 &&
+ !this.video.ended &&
+ this.video.readyState < this.video.HAVE_FUTURE_DATA &&
+ this.video.networkState == this.video.NETWORK_LOADING)
+ ) {
+ show = true;
+ }
+
+ // Explicitly hide the status fader if this
+ // is audio only until bug 619421 is fixed.
+ if (this.isAudioOnly) {
+ show = false;
+ }
+
+ if (this._showThrobberTimer) {
+ show = true;
+ }
+
+ this.log(
+ "Status overlay: seeking=" +
+ this.video.seeking +
+ " error=" +
+ this.video.error +
+ " readyState=" +
+ this.video.readyState +
+ " paused=" +
+ this.video.paused +
+ " ended=" +
+ this.video.ended +
+ " networkState=" +
+ this.video.networkState +
+ " timeUpdateCount=" +
+ this.timeUpdateCount +
+ " _showThrobberTimer=" +
+ this._showThrobberTimer +
+ " --> " +
+ (show ? "SHOW" : "HIDE")
+ );
+ this.startFade(this.statusOverlay, show, immediate);
+ },
+
+ /*
+ * Set the initial state of the controls. The UA widget is normally created along
+ * with video element, but could be attached at any point (eg, if the video is
+ * removed from the document and then reinserted). Thus, some one-time events may
+ * have already fired, and so we'll need to explicitly check the initial state.
+ */
+ setupInitialState() {
+ this.setPlayButtonState(this.video.paused);
+
+ this.setFullscreenButtonState();
+
+ var duration = Math.round(this.video.duration * 1000); // in ms
+ var currentTime = Math.round(this.video.currentTime * 1000); // in ms
+ this.log(
+ "Initial playback position is at " + currentTime + " of " + duration
+ );
+ // It would be nice to retain maxCurrentTimeSeen, but it would be difficult
+ // to determine if the media source changed while we were detached.
+ this.maxCurrentTimeSeen = currentTime;
+ this.showPosition(currentTime, duration);
+
+ // If we have metadata, check if this is a <video> without
+ // video data, or a video with no audio track.
+ if (this.video.readyState >= this.video.HAVE_METADATA) {
+ if (
+ this.video.localName == "video" &&
+ (this.video.videoWidth == 0 || this.video.videoHeight == 0)
+ ) {
+ this.isAudioOnly = true;
+ }
+
+ // We have to check again if the media has audio here.
+ if (!this.isAudioOnly && !this.video.mozHasAudio) {
+ this.muteButton.setAttribute("noAudio", "true");
+ this.muteButton.disabled = true;
+ }
+ }
+
+ // We should lock the orientation if we are already in
+ // fullscreen.
+ this.updateOrientationState(this.isVideoInFullScreen);
+
+ // The video itself might not be fullscreen, but part of the
+ // document might be, in which case we set this attribute to
+ // apply any styles for the DOM fullscreen case.
+ if (this.document.fullscreenElement) {
+ this.videocontrols.setAttribute("inDOMFullscreen", true);
+ }
+
+ if (this.isAudioOnly) {
+ this.startFadeOut(this.clickToPlay, true);
+ }
+
+ // If the first frame hasn't loaded, kick off a throbber fade-in.
+ if (this.video.readyState >= this.video.HAVE_CURRENT_DATA) {
+ this.firstFrameShown = true;
+ }
+
+ // We can't determine the exact buffering status, but do know if it's
+ // fully loaded. (If it's still loading, it will fire a progress event
+ // and we'll figure out the exact state then.)
+ this.bufferBar.max = 100;
+ if (this.video.readyState >= this.video.HAVE_METADATA) {
+ this.showBuffered();
+ } else {
+ this.bufferBar.value = 0;
+ }
+
+ // Set the current status icon.
+ if (this.hasError()) {
+ this.startFadeOut(this.clickToPlay, true);
+ this.statusIcon.setAttribute("type", "error");
+ this.updateErrorText();
+ this.setupStatusFader(true);
+ } else if (VideoControlsWidget.isPictureInPictureVideo(this.video)) {
+ this.setShowPictureInPictureMessage(true);
+ }
+
+ if (this.video.readyState >= this.video.HAVE_METADATA) {
+ // According to the spec[1], at the HAVE_METADATA (or later) state, we know
+ // the video duration and dimensions, which means we can calculate whether or
+ // not to show the Picture-in-Picture toggle now.
+ //
+ // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata
+ this.updatePictureInPictureToggleDisplay();
+ }
+
+ let adjustableControls = [
+ ...this.prioritizedControls,
+ this.controlBar,
+ this.clickToPlay,
+ ];
+
+ for (let control of adjustableControls) {
+ if (!control) {
+ break;
+ }
+
+ this.defineControlProperties(control);
+ }
+ this.adjustControlSize();
+
+ // Can only update the volume controls once we've computed
+ // _volumeControlWidth, since the volume slider implementation
+ // depends on it.
+ this.updateVolumeControls();
+ },
+
+ defineControlProperties(control) {
+ let throwOnGet = {
+ get() {
+ throw new Error("Please don't trigger reflow. See bug 1493525.");
+ },
+ };
+ Object.defineProperties(control, {
+ // We should directly access CSSOM to get pre-defined style instead of
+ // retrieving computed dimensions from layout.
+ minWidth: {
+ get: () => {
+ let controlId = control.id;
+ let propertyName = `--${controlId}-width`;
+ if (control.modifier) {
+ propertyName += "-" + control.modifier;
+ }
+ let preDefinedSize =
+ this.controlBarComputedStyles.getPropertyValue(propertyName);
+
+ // The stylesheet from <link> might not be loaded if the
+ // element was inserted into a hidden iframe.
+ // We can safely return 0 here for now, given that the controls
+ // will be resized again, by the resizevideocontrols event,
+ // from nsVideoFrame, when the element is visible.
+ if (!preDefinedSize) {
+ return 0;
+ }
+
+ return parseInt(preDefinedSize, 10);
+ },
+ },
+ offsetLeft: throwOnGet,
+ offsetTop: throwOnGet,
+ offsetWidth: throwOnGet,
+ offsetHeight: throwOnGet,
+ offsetParent: throwOnGet,
+ clientLeft: throwOnGet,
+ clientTop: throwOnGet,
+ clientWidth: throwOnGet,
+ clientHeight: throwOnGet,
+ getClientRects: throwOnGet,
+ getBoundingClientRect: throwOnGet,
+ isAdjustableControl: {
+ value: true,
+ },
+ modifier: {
+ value: "",
+ writable: true,
+ },
+ isWanted: {
+ value: true,
+ writable: true,
+ },
+ hidden: {
+ set: v => {
+ control._isHiddenExplicitly = v;
+ control._updateHiddenAttribute();
+ },
+ get: () => {
+ return (
+ control.hasAttribute("hidden") ||
+ control.classList.contains("fadeout")
+ );
+ },
+ },
+ hiddenByAdjustment: {
+ set: v => {
+ control._isHiddenByAdjustment = v;
+ control._updateHiddenAttribute();
+ },
+ get: () => control._isHiddenByAdjustment,
+ },
+ _isHiddenByAdjustment: {
+ value: false,
+ writable: true,
+ },
+ _isHiddenExplicitly: {
+ value: false,
+ writable: true,
+ },
+ _updateHiddenAttribute: {
+ value: () => {
+ control.toggleAttribute(
+ "hidden",
+ control._isHiddenExplicitly || control._isHiddenByAdjustment
+ );
+ },
+ },
+ });
+ },
+
+ updatePictureInPictureToggleDisplay() {
+ if (this.isAudioOnly) {
+ this.pictureInPictureToggle.hidden = true;
+ return;
+ }
+
+ // We only want to show the toggle when the closed captions menu
+ // is closed, in order to avoid visual overlap.
+ if (
+ this.pipToggleEnabled &&
+ !this.isShowingPictureInPictureMessage &&
+ this.textTrackListContainer.hidden &&
+ VideoControlsWidget.shouldShowPictureInPictureToggle(
+ this.prefs,
+ this.video,
+ this.reflowedDimensions
+ )
+ ) {
+ this.pictureInPictureToggle.hidden = false;
+ VideoControlsWidget.setupToggle(
+ this.prefs,
+ this.pictureInPictureToggle,
+ this.reflowedDimensions
+ );
+ } else {
+ this.pictureInPictureToggle.hidden = true;
+ }
+ },
+
+ setupNewLoadState() {
+ // For videos with |autoplay| set, we'll leave the controls initially hidden,
+ // so that they don't get in the way of the playing video. Otherwise we'll
+ // go ahead and reveal the controls now, so they're an obvious user cue.
+ var shouldShow =
+ !this.dynamicControls || (this.video.paused && !this.video.autoplay);
+ // Hide the overlay if the video time is non-zero or if an error occurred to workaround bug 718107.
+ let shouldClickToPlayShow =
+ shouldShow &&
+ !this.isAudioOnly &&
+ this.video.currentTime == 0 &&
+ !this.hasError() &&
+ !this.isShowingPictureInPictureMessage;
+ this.startFade(this.clickToPlay, shouldClickToPlayShow, true);
+ this.startFade(this.controlBar, shouldShow, true);
+ },
+
+ get dynamicControls() {
+ // Don't fade controls for <audio> elements.
+ var enabled = !this.isAudioOnly;
+
+ // Allow tests to explicitly suppress the fading of controls.
+ if (this.video.hasAttribute("mozNoDynamicControls")) {
+ enabled = false;
+ }
+
+ // If the video hits an error, suppress controls if it
+ // hasn't managed to do anything else yet.
+ if (!this.firstFrameShown && this.hasError()) {
+ enabled = false;
+ }
+
+ return enabled;
+ },
+
+ updateVolume() {
+ const volume = this.volumeControl.value;
+ this.setVolume(volume / 100);
+ },
+
+ updateVolumeControls() {
+ var volume = this.video.muted ? 0 : this.video.volume;
+ var volumePercentage = Math.round(volume * 100);
+ this.updateMuteButtonState();
+ this.volumeControl.value = volumePercentage;
+ },
+
+ /*
+ * We suspend a video element's video decoder if the video
+ * element is invisible. However, resuming the video decoder
+ * takes time and we show the throbber UI if it takes more than
+ * 250 ms.
+ *
+ * When an already-suspended video element becomes visible, we
+ * resume its video decoder immediately and queue a video-only seek
+ * task to seek the resumed video decoder to the current position;
+ * meanwhile, we also file a "mozvideoonlyseekbegin" event which
+ * we used to start the timer here.
+ *
+ * Once the queued seek operation is done, we dispatch a
+ * "canplay" event which indicates that the resuming operation
+ * is completed.
+ */
+ SHOW_THROBBER_TIMEOUT_MS: 250,
+ _showThrobberTimer: null,
+ _delayShowThrobberWhileResumingVideoDecoder() {
+ this._showThrobberTimer = this.window.setTimeout(() => {
+ this.statusIcon.setAttribute("type", "throbber");
+ // Show the throbber immediately since we have waited for SHOW_THROBBER_TIMEOUT_MS.
+ // We don't want to wait for another animation delay(750ms) and the
+ // animation duration(300ms).
+ this.setupStatusFader(true);
+ }, this.SHOW_THROBBER_TIMEOUT_MS);
+ },
+ _cancelShowThrobberWhileResumingVideoDecoder() {
+ if (this._showThrobberTimer) {
+ this.window.clearTimeout(this._showThrobberTimer);
+ this._showThrobberTimer = null;
+ }
+ },
+
+ handleEvent(aEvent) {
+ if (!aEvent.isTrusted) {
+ this.log("Drop untrusted event ----> " + aEvent.type);
+ return;
+ }
+
+ this.log("Got event ----> " + aEvent.type);
+
+ 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.isTouchControls
+ ) {
+ this.startFadeOut(this.controlBar);
+ }
+ if (!this._triggeredByControls) {
+ this.startFadeOut(this.clickToPlay, true);
+ }
+ this._triggeredByControls = false;
+ break;
+ case "pause":
+ // Little white lie: if we've internally paused the video
+ // while dragging the scrubber, don't change the button state.
+ if (!this.scrubber.isDragging) {
+ this.setPlayButtonState(true);
+ }
+ this.setupStatusFader();
+ break;
+ case "ended":
+ this.setPlayButtonState(true);
+ // We throttle timechange events, so the thumb might not be
+ // exactly at the end when the video finishes.
+ this.showPosition(
+ Math.round(this.video.currentTime * 1000),
+ Math.round(this.video.duration * 1000)
+ );
+ this.startFadeIn(this.controlBar);
+ this.setupStatusFader();
+ break;
+ 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);
+ this.window.clearTimeout(this._hideControlsTimeout);
+ this._hideControlsTimeout = this.window.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.localName == "video" &&
+ (this.video.videoWidth == 0 || this.video.videoHeight == 0)
+ ) {
+ this.isAudioOnly = true;
+ this.startFadeOut(this.clickToPlay, true);
+ this.startFadeIn(this.controlBar);
+ this.setFullscreenButtonState();
+ }
+ this.showPosition(
+ Math.round(this.video.currentTime * 1000),
+ Math.round(this.video.duration * 1000)
+ );
+ if (!this.isAudioOnly && !this.video.mozHasAudio) {
+ this.muteButton.setAttribute("noAudio", "true");
+ this.muteButton.disabled = true;
+ }
+ this.adjustControlSize();
+ this.updatePictureInPictureToggleDisplay();
+ break;
+ case "durationchange":
+ this.updatePictureInPictureToggleDisplay();
+ break;
+ case "loadeddata":
+ this.firstFrameShown = true;
+ this.setupStatusFader();
+ break;
+ case "loadstart":
+ this.maxCurrentTimeSeen = 0;
+ this.controlsSpacer.removeAttribute("aria-label");
+ this.statusOverlay.removeAttribute("status");
+ this.statusIcon.setAttribute("type", "throbber");
+ this.isAudioOnly = this.isAudioTag;
+ this.setPlayButtonState(true);
+ this.setupNewLoadState();
+ this.setupStatusFader();
+ break;
+ case "progress":
+ this.statusIcon.removeAttribute("stalled");
+ this.showBuffered();
+ this.setupStatusFader();
+ break;
+ case "stalled":
+ this.statusIcon.setAttribute("stalled", "true");
+ this.statusIcon.setAttribute("type", "throbber");
+ this.setupStatusFader();
+ break;
+ case "suspend":
+ this.setupStatusFader();
+ break;
+ case "timeupdate":
+ var currentTime = Math.round(this.video.currentTime * 1000); // in ms
+ var duration = Math.round(this.video.duration * 1000); // in ms
+
+ // If playing/seeking after the video ended, we won't get a "play"
+ // event, so update the button state here.
+ if (!this.video.paused) {
+ this.setPlayButtonState(false);
+ }
+
+ this.timeUpdateCount++;
+ // Whether we show the statusOverlay sometimes depends
+ // on whether we've seen more than one timeupdate
+ // event (if we haven't, there hasn't been any
+ // "playback activity" and we may wish to show the
+ // statusOverlay while we wait for HAVE_ENOUGH_DATA).
+ // If we've seen more than 2 timeupdate events,
+ // the count is no longer relevant to setupStatusFader.
+ if (this.timeUpdateCount <= 2) {
+ this.setupStatusFader();
+ }
+
+ // If the user is dragging the scrubber ignore the delayed seek
+ // responses (don't yank the thumb away from the user)
+ if (this.scrubber.isDragging) {
+ return;
+ }
+ this.showPosition(currentTime, duration);
+ this.showBuffered();
+ break;
+ case "emptied":
+ this.bufferBar.value = 0;
+ this.showPosition(0, 0);
+ break;
+ case "seeking":
+ this.showBuffered();
+ this.statusIcon.setAttribute("type", "throbber");
+ this.setupStatusFader();
+ break;
+ case "waiting":
+ this.statusIcon.setAttribute("type", "throbber");
+ this.setupStatusFader();
+ break;
+ case "seeked":
+ case "playing":
+ case "canplay":
+ case "canplaythrough":
+ this.setupStatusFader();
+ break;
+ case "error":
+ // We'll show the error status icon when we receive an error event
+ // under either of the following conditions:
+ // 1. The video has its error attribute set; this means we're loading
+ // from our src attribute, and the load failed, or we we're loading
+ // from source children and the decode or playback failed after we
+ // determined our selected resource was playable.
+ // 2. The video's networkState is NETWORK_NO_SOURCE. This means we we're
+ // loading from child source elements, but we were unable to select
+ // any of the child elements for playback during resource selection.
+ if (this.hasError()) {
+ this.suppressError = false;
+ this.startFadeOut(this.clickToPlay, true);
+ this.statusIcon.setAttribute("type", "error");
+ this.updateErrorText();
+ this.setupStatusFader(true);
+ // If video hasn't shown anything yet, disable the controls.
+ if (!this.firstFrameShown && !this.isAudioOnly) {
+ this.startFadeOut(this.controlBar);
+ }
+ this.controlsSpacer.removeAttribute("hideCursor");
+ }
+ break;
+ case "mozvideoonlyseekbegin":
+ this._delayShowThrobberWhileResumingVideoDecoder();
+ break;
+ case "mozvideoonlyseekcompleted":
+ this._cancelShowThrobberWhileResumingVideoDecoder();
+ this.setupStatusFader();
+ break;
+ default:
+ this.log("!!! media event " + aEvent.type + " not handled!");
+ }
+ },
+
+ 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);
+ this.closedCaptionButton.focus();
+ 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":
+ // Since this event come from the layout, this is the only place
+ // we are sure of that probing into layout won't trigger or force
+ // reflow.
+ this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = true;
+ this.updateReflowedDimensions();
+ this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = false;
+
+ let scrubberWasHidden = this.scrubberStack.hidden;
+ this.adjustControlSize();
+ if (scrubberWasHidden && !this.scrubberStack.hidden) {
+ // requestAnimationFrame + setTimeout of 0ms is a best effort way to avoid
+ // triggering reflows, but cannot fully guarantee a reflow will not happen.
+ this.window.requestAnimationFrame(() =>
+ this.window.setTimeout(() => {
+ this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = true;
+ this.updateReflowedDimensions();
+ this.reflowTriggeringCallValidator.isReflowTriggeringPropsAllowed = false;
+ }, 0)
+ );
+ }
+ this.updatePictureInPictureToggleDisplay();
+ break;
+ 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;
+ case "focusin":
+ // Show the controls to highlight the focused control, but only
+ // under certain conditions:
+ if (
+ this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] &&
+ // The click-to-play overlay must already be hidden (we don't
+ // hide controls when the overlay is visible).
+ this.clickToPlay.hidden &&
+ // Don't do this if the controls are static.
+ this.dynamicControls &&
+ // If the mouse is hovering over the control bar, the controls
+ // are already showing and they shouldn't hide, so don't mess
+ // with them.
+ // We use "div:hover" instead of just ":hover" so this works in
+ // quirks mode documents. See
+ // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk
+ !this.controlBar.matches("div:hover")
+ ) {
+ this.startFadeIn(this.controlBar);
+ this.window.clearTimeout(this._hideControlsTimeout);
+ this._hideControlsTimeout = this.window.setTimeout(
+ () => this._hideControlsFn(),
+ this.HIDE_CONTROLS_TIMEOUT_MS
+ );
+ }
+ break;
+ case "mousedown":
+ // We only listen for mousedown on sliders.
+ // If this slider isn't focused already, mousedown will focus it.
+ // We don't want that because it will then handle additional keys.
+ // For example, we don't want the up/down arrow keys to seek after
+ // the scrubber is clicked. To prevent that, we need to redirect
+ // focus. However, dragging only works while the slider is focused,
+ // so we must redirect focus after mouseup.
+ if (
+ this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] &&
+ !aEvent.currentTarget.matches(":focus")
+ ) {
+ aEvent.currentTarget.addEventListener(
+ "mouseup",
+ aEvent => {
+ if (aEvent.currentTarget.matches(":focus")) {
+ // We can't use target.blur() because that will blur the
+ // video element as well.
+ this.video.focus();
+ }
+ },
+ { once: true }
+ );
+ }
+ 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) {}
+ }
+ }
+
+ try {
+ for (let { el, type, capture = false } of this.controlsEvents) {
+ el.removeEventListener(type, this, {
+ mozSystemGroup: true,
+ capture,
+ });
+ }
+ } catch (ex) {}
+
+ this.window.clearTimeout(this._showControlsTimeout);
+ this.window.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
+ // there's no sources to load as an error condition, as sites may
+ // do this intentionally to work around requires-user-interaction to
+ // play restrictions, and we don't want to display a debug message
+ // if that's the case.
+ return (
+ this.video.error != null ||
+ (this.video.networkState == this.video.NETWORK_NO_SOURCE &&
+ this.hasSources())
+ );
+ },
+
+ setShowPictureInPictureMessage(showMessage) {
+ this.pictureInPictureOverlay.hidden = !showMessage;
+ this.isShowingPictureInPictureMessage = showMessage;
+ },
+
+ hasSources() {
+ if (
+ this.video.hasAttribute("src") &&
+ this.video.getAttribute("src") !== ""
+ ) {
+ return true;
+ }
+ for (
+ var child = this.video.firstChild;
+ child !== null;
+ child = child.nextElementSibling
+ ) {
+ if (child instanceof this.window.HTMLSourceElement) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ updateErrorText() {
+ let error;
+ let v = this.video;
+ // It is possible to have both v.networkState == NETWORK_NO_SOURCE
+ // as well as v.error being non-null. In this case, we will show
+ // the v.error.code instead of the v.networkState error.
+ if (v.error) {
+ switch (v.error.code) {
+ case v.error.MEDIA_ERR_ABORTED:
+ error = "errorAborted";
+ break;
+ case v.error.MEDIA_ERR_NETWORK:
+ error = "errorNetwork";
+ break;
+ case v.error.MEDIA_ERR_DECODE:
+ error = "errorDecode";
+ break;
+ case v.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
+ error =
+ v.networkState == v.NETWORK_NO_SOURCE
+ ? "errorNoSource"
+ : "errorSrcNotSupported";
+ break;
+ default:
+ error = "errorGeneric";
+ break;
+ }
+ } else if (v.networkState == v.NETWORK_NO_SOURCE) {
+ error = "errorNoSource";
+ } else {
+ return; // No error found.
+ }
+
+ let label = this.shadowRoot.getElementById(error);
+ this.controlsSpacer.setAttribute("aria-label", label.textContent);
+ this.statusOverlay.setAttribute("status", error);
+ },
+
+ formatTime(aTime) {
+ aTime = Math.round(aTime / 1000);
+ let hours = Math.floor(aTime / 3600);
+ let mins = Math.floor((aTime % 3600) / 60);
+ let secs = Math.floor(aTime % 60);
+ let timeString;
+ if (secs < 10) {
+ secs = "0" + secs;
+ }
+ if (hours) {
+ if (mins < 10) {
+ mins = "0" + mins;
+ }
+ timeString = hours + ":" + mins + ":" + secs;
+ } else {
+ timeString = mins + ":" + secs;
+ }
+ return timeString;
+ },
+
+ pauseVideoDuringDragging() {
+ if (
+ !this.video.paused &&
+ !this.isPausedByDragging &&
+ this.scrubber.isDragging
+ ) {
+ this.isPausedByDragging = true;
+ this.video.pause();
+ }
+ },
+
+ onScrubberInput(e) {
+ const duration = Math.round(this.video.duration * 1000); // in ms
+ let time = this.scrubber.value;
+
+ this.seekToPosition(time);
+ this.showPosition(time, duration);
+ this.updateScrubberProgress();
+
+ this.scrubber.isDragging = true;
+ this.pauseVideoDuringDragging();
+ },
+
+ onScrubberChange(e) {
+ this.scrubber.isDragging = false;
+
+ if (this.isPausedByDragging) {
+ this.video.play();
+ this.isPausedByDragging = false;
+ }
+ },
+
+ updateScrubberProgress() {
+ const positionPercent = (this.scrubber.value / this.scrubber.max) * 100;
+
+ if (!isNaN(positionPercent) && positionPercent != Infinity) {
+ this.progressBar.value = positionPercent;
+ } else {
+ this.progressBar.value = 0;
+ }
+ },
+
+ seekToPosition(newPosition) {
+ newPosition /= 1000; // convert from ms
+ this.log("+++ seeking to " + newPosition);
+ this.video.currentTime = newPosition;
+ },
+
+ setVolume(newVolume) {
+ this.log("*** setting volume to " + newVolume);
+ this.video.volume = newVolume;
+ this.video.muted = false;
+ },
+
+ showPosition(currentTimeMs, durationMs) {
+ // If the duration is unknown (because the server didn't provide
+ // it, or the video is a stream), then we want to fudge the duration
+ // by using the maximum playback position that's been seen.
+ if (currentTimeMs > this.maxCurrentTimeSeen) {
+ this.maxCurrentTimeSeen = currentTimeMs;
+ }
+ this.log(
+ "time update @ " + currentTimeMs + "ms of " + durationMs + "ms"
+ );
+
+ let durationIsInfinite = durationMs == Infinity;
+ if (isNaN(durationMs) || durationIsInfinite) {
+ durationMs = this.maxCurrentTimeSeen;
+ }
+ this.log("durationMs is " + durationMs + "ms.\n");
+
+ let scrubberProgress = Math.abs(
+ currentTimeMs / durationMs - this.scrubber.value / this.scrubber.max
+ );
+ let devPxProgress =
+ scrubberProgress *
+ this.reflowedDimensions.scrubberWidth *
+ this.window.devicePixelRatio;
+ // Hack: if we haven't updated the scrubber width to be non-0, but
+ // the scrubber stack is visible, assume there is progress.
+ // This should be rectified by the next time we do layout (see handling
+ // of resizevideocontrols events in handleEvent).
+ if (
+ !this.reflowedDimensions.scrubberWidth &&
+ !this.scrubberStack.hidden
+ ) {
+ devPxProgress = 1;
+ }
+ // Update the scrubber only if it will move by at least 1 pixel
+ // Note that this.scrubber.max can be "" if unitialized,
+ // and either or both of currentTimeMs or durationMs can be 0, leading
+ // to NaN or Infinity values for devPxProgress.
+ if (!this.scrubber.max || isNaN(devPxProgress) || devPxProgress > 0.5) {
+ this.scrubber.max = durationMs;
+ this.scrubber.value = currentTimeMs;
+ this.updateScrubberProgress();
+ }
+
+ // If the duration is over an hour, thumb should show h:mm:ss instead
+ // of mm:ss, which makes it bigger. We set the modifier prop which
+ // informs CSS custom properties used elsewhere to determine minimum
+ // widths we need to show stuff.
+ let modifier = durationMs >= 3600000 ? "long" : "";
+ this.positionDurationBox.modifier = this.durationSpan.modifier =
+ modifier;
+
+ // Update the text-based labels:
+ let position = this.formatTime(currentTimeMs);
+ let duration = durationIsInfinite ? "" : this.formatTime(durationMs);
+ if (
+ this.positionString != position ||
+ this.durationString != duration
+ ) {
+ // Only update the DOM if there is a visible change.
+ this._updatePositionLabels(position, duration);
+ }
+ },
+
+ _updatePositionLabels(position, duration) {
+ this.positionString = position;
+ this.durationString = duration;
+
+ this.l10n.setAttributes(
+ this.positionDurationBox,
+ "videocontrols-position-and-duration-labels",
+ { position, duration }
+ );
+ this.l10n.setAttributes(
+ this.scrubber,
+ "videocontrols-scrubber-position-and-duration",
+ { position, duration }
+ );
+ },
+
+ showBuffered() {
+ function bsearch(haystack, needle, cmp) {
+ var length = haystack.length;
+ var low = 0;
+ var high = length;
+ while (low < high) {
+ var probe = low + ((high - low) >> 1);
+ var r = cmp(haystack, probe, needle);
+ if (r == 0) {
+ return probe;
+ } else if (r > 0) {
+ low = probe + 1;
+ } else {
+ high = probe;
+ }
+ }
+ return -1;
+ }
+
+ function bufferedCompare(buffered, i, time) {
+ if (time > buffered.end(i)) {
+ return 1;
+ } else if (time >= buffered.start(i)) {
+ return 0;
+ }
+ return -1;
+ }
+
+ var duration = Math.round(this.video.duration * 1000);
+ if (isNaN(duration) || duration == Infinity) {
+ duration = this.maxCurrentTimeSeen;
+ }
+
+ // Find the range that the current play position is in and use that
+ // range for bufferBar. At some point we may support multiple ranges
+ // displayed in the bar.
+ var currentTime = this.video.currentTime;
+ var buffered = this.video.buffered;
+ var index = bsearch(buffered, currentTime, bufferedCompare);
+ var endTime = 0;
+ if (index >= 0) {
+ endTime = Math.round(buffered.end(index) * 1000);
+ }
+ if (this.duration == duration && this.buffered == endTime) {
+ // Avoid modifying the DOM if there is no update to show.
+ return;
+ }
+
+ this.bufferBar.max = this.duration = duration;
+ this.bufferBar.value = this.buffered = endTime;
+ // Progress bars are automatically reported by screen readers even when
+ // they aren't focused, which intrudes on the audio being played.
+ // Ideally, we'd just change the a11y role of bufferBar, but there's
+ // no role which will let us just expose text via an ARIA attribute.
+ // Therefore, we hide bufferBar for a11y and expose the info as
+ // off-screen text.
+ this.bufferA11yVal.textContent =
+ (this.bufferBar.position * 100).toFixed() + "%";
+ },
+
+ _controlsHiddenByTimeout: false,
+ _showControlsTimeout: 0,
+ SHOW_CONTROLS_TIMEOUT_MS: 500,
+ _showControlsFn() {
+ if (this.video.matches("video:hover")) {
+ this.startFadeIn(this.controlBar, false);
+ this._showControlsTimeout = 0;
+ this._controlsHiddenByTimeout = false;
+ }
+ },
+
+ _hideControlsTimeout: 0,
+ _hideControlsFn() {
+ if (!this.scrubber.isDragging) {
+ this.startFade(this.controlBar, false);
+ this._hideControlsTimeout = 0;
+ this._controlsHiddenByTimeout = true;
+ }
+ },
+ HIDE_CONTROLS_TIMEOUT_MS: 2000,
+
+ // By "Video" we actually mean the video controls container,
+ // because we don't want to consider the padding of <video> added
+ // by the web content.
+ isMouseOverVideo(event) {
+ // XXX: this triggers reflow too, but the layout should only be dirty
+ // if the web content touches it while the mouse is moving.
+ let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY);
+
+ // As long as this is not null, the cursor is over something within our
+ // Shadow DOM.
+ return !!el;
+ },
+
+ isMouseOverControlBar(event) {
+ // XXX: this triggers reflow too, but the layout should only be dirty
+ // if the web content touches it while the mouse is moving.
+ let el = this.shadowRoot.elementFromPoint(event.clientX, event.clientY);
+ while (el && el !== this.shadowRoot) {
+ if (el == this.controlBar) {
+ return true;
+ }
+ el = el.parentNode;
+ }
+ return false;
+ },
+
+ onMouseMove(event) {
+ // If the controls are static, don't change anything.
+ if (!this.dynamicControls) {
+ return;
+ }
+
+ this.window.clearTimeout(this._hideControlsTimeout);
+
+ // Suppress fading out the controls until the video has rendered
+ // 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 = this.window.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 ||
+ !this.isMouseOverControlBar(event)) &&
+ this.clickToPlay.hidden
+ ) {
+ this._hideControlsTimeout = this.window.setTimeout(
+ () => this._hideControlsFn(),
+ this.HIDE_CONTROLS_TIMEOUT_MS
+ );
+ }
+ },
+
+ onMouseInOut(event) {
+ // If the controls are static, don't change anything.
+ if (!this.dynamicControls) {
+ return;
+ }
+
+ this.window.clearTimeout(this._hideControlsTimeout);
+
+ let isMouseOverVideo = this.isMouseOverVideo(event);
+
+ // Suppress fading out the controls until the video has rendered
+ // 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 &&
+ !isMouseOverVideo &&
+ !this.video.autoplay
+ ) {
+ return;
+ }
+
+ if (!isMouseOverVideo && !this.isMouseOverControlBar(event)) {
+ this.adjustControlSize();
+
+ // Keep the controls visible if the click-to-play is visible.
+ if (!this.clickToPlay.hidden) {
+ return;
+ }
+
+ this.startFadeOut(this.controlBar, false);
+ this.hideClosedCaptionMenu();
+ this.window.clearTimeout(this._showControlsTimeout);
+ this._controlsHiddenByTimeout = false;
+ }
+ },
+
+ startFadeIn(element, immediate) {
+ this.startFade(element, true, immediate);
+ },
+
+ startFadeOut(element, immediate) {
+ this.startFade(element, false, immediate);
+ },
+
+ animationMap: new WeakMap(),
+
+ animationProps: {
+ clickToPlay: {
+ keyframes: [
+ { transform: "scale(3)", opacity: 0 },
+ { transform: "scale(1)", opacity: 0.55 },
+ ],
+ options: {
+ easing: "ease",
+ duration: 400,
+ // The fill mode here and below is a workaround to avoid flicker
+ // due to bug 1495350.
+ fill: "both",
+ },
+ },
+ controlBar: {
+ keyframes: [{ opacity: 0 }, { opacity: 1 }],
+ options: {
+ easing: "ease",
+ duration: 200,
+ fill: "both",
+ },
+ },
+ statusOverlay: {
+ keyframes: [
+ { opacity: 0 },
+ { opacity: 0, offset: 0.72 }, // ~750ms into animation
+ { opacity: 1 },
+ ],
+ options: {
+ duration: 1050,
+ fill: "both",
+ },
+ },
+ },
+
+ startFade(element, fadeIn, immediate = false) {
+ let animationProp = this.animationProps[element.id];
+ if (!animationProp) {
+ throw new Error(
+ "Element " +
+ element.id +
+ " has no transition. Toggle the hidden property directly."
+ );
+ }
+
+ let animation = this.animationMap.get(element);
+ if (!animation) {
+ animation = new this.window.Animation(
+ new this.window.KeyframeEffect(
+ element,
+ animationProp.keyframes,
+ animationProp.options
+ )
+ );
+
+ this.animationMap.set(element, animation);
+ }
+
+ if (fadeIn) {
+ if (element == this.controlBar) {
+ this.controlsSpacer.removeAttribute("hideCursor");
+ // Ensure the Full Screen button is in the tab order.
+ this.fullscreenButton.removeAttribute("tabindex");
+ }
+
+ // hidden state should be controlled by adjustControlSize
+ if (element.isAdjustableControl && element.hiddenByAdjustment) {
+ return;
+ }
+
+ // No need to fade in again if the hidden property returns false
+ // (not hidden and not fading out.)
+ if (!element.hidden) {
+ return;
+ }
+
+ // Unhide
+ element.hidden = false;
+ } else {
+ if (element == this.controlBar) {
+ if (!this.hasError() && this.isVideoInFullScreen) {
+ this.controlsSpacer.setAttribute("hideCursor", true);
+ }
+ if (
+ !this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]
+ ) {
+ // The Full Screen button is currently the only tabbable button
+ // when the controls are shown. Remove it from the tab order when
+ // visually hidden to prevent visual confusion.
+ this.fullscreenButton.setAttribute("tabindex", "-1");
+ }
+ }
+
+ // No need to fade out if the hidden property returns true
+ // (hidden or is fading out)
+ if (element.hidden) {
+ return;
+ }
+ }
+
+ element.classList.toggle("fadeout", !fadeIn);
+ element.classList.toggle("fadein", fadeIn);
+ let finishedPromise;
+ if (!immediate) {
+ // At this point, if there is a pending animation, we just stop it to avoid it happening.
+ // If there is a running animation, we reverse it, to have it rewind to the beginning.
+ // If there is an idle/finished animation, we schedule a new one that reverses the finished one.
+ if (animation.pending) {
+ // Animation is running but pending.
+ // Just cancel the pending animation to stop its effect.
+ animation.cancel();
+ finishedPromise = Promise.resolve();
+ } else {
+ switch (animation.playState) {
+ case "idle":
+ case "finished":
+ // There is no animation currently playing.
+ // Schedule a new animation with the desired playback direction.
+ animation.playbackRate = fadeIn ? 1 : -1;
+ animation.play();
+ break;
+ case "running":
+ // Allow the animation to play from its current position in
+ // reverse to finish.
+ animation.reverse();
+ break;
+ case "pause":
+ throw new Error("Animation should never reach pause state.");
+ default:
+ throw new Error(
+ "Unknown Animation playState: " + animation.playState
+ );
+ }
+ finishedPromise = animation.finished;
+ }
+ } else {
+ // immediate
+ animation.cancel();
+ finishedPromise = Promise.resolve();
+ }
+ finishedPromise.then(
+ animation => {
+ if (element == this.controlBar) {
+ this.onControlBarAnimationFinished();
+ }
+ element.classList.remove(fadeIn ? "fadein" : "fadeout");
+ if (!fadeIn) {
+ element.hidden = true;
+ }
+ if (animation) {
+ // Explicitly clear the animation effect so that filling animations
+ // stop overwriting stylesheet styles. Remove when bug 1495350 is
+ // fixed and animations are no longer filling animations.
+ // This also stops them from accumulating (See bug 1253476).
+ animation.cancel();
+ }
+ },
+ () => {
+ /* Do nothing on rejection */
+ }
+ );
+ },
+
+ _triggeredByControls: false,
+
+ startPlay() {
+ this._triggeredByControls = true;
+ this.hideClickToPlay();
+ this.video.play();
+ },
+
+ togglePause() {
+ if (this.video.paused || this.video.ended) {
+ this.startPlay();
+ } else {
+ this.video.pause();
+ }
+
+ // We'll handle style changes in the event listener for
+ // the "play" and "pause" events, same as if content
+ // script was controlling video playback.
+ },
+
+ get isVideoWithoutAudioTrack() {
+ return (
+ this.video.readyState >= this.video.HAVE_METADATA &&
+ !this.isAudioOnly &&
+ !this.video.mozHasAudio
+ );
+ },
+
+ toggleMute() {
+ if (this.isVideoWithoutAudioTrack) {
+ return;
+ }
+ this.video.muted = !this.isEffectivelyMuted;
+ if (this.video.volume === 0) {
+ this.video.volume = 0.5;
+ }
+
+ // We'll handle style changes in the event listener for
+ // the "volumechange" event, same as if content script was
+ // controlling volume.
+ },
+
+ get isVideoInFullScreen() {
+ return this.video.isSameNode(
+ this.video.getRootNode().fullscreenElement
+ );
+ },
+
+ toggleFullscreen() {
+ // audio tags cannot toggle fullscreen
+ if (!this.isAudioTag) {
+ this.isVideoInFullScreen
+ ? this.document.exitFullscreen()
+ : this.video.requestFullscreen();
+ }
+ },
+
+ setFullscreenButtonState() {
+ if (this.isAudioOnly || !this.document.fullscreenEnabled) {
+ this.controlBar.setAttribute("fullscreen-unavailable", true);
+ this.adjustControlSize();
+ return;
+ }
+ this.controlBar.removeAttribute("fullscreen-unavailable");
+ this.adjustControlSize();
+
+ var id = this.isVideoInFullScreen
+ ? "videocontrols-exitfullscreen-button"
+ : "videocontrols-enterfullscreen-button";
+ this.l10n.setAttributes(this.fullscreenButton, id);
+
+ if (this.isVideoInFullScreen) {
+ this.fullscreenButton.setAttribute("fullscreened", "true");
+ } else {
+ this.fullscreenButton.removeAttribute("fullscreened");
+ }
+ },
+
+ onFullscreenChange() {
+ if (this.document.fullscreenElement) {
+ this.videocontrols.setAttribute("inDOMFullscreen", true);
+ } else {
+ this.videocontrols.removeAttribute("inDOMFullscreen");
+ }
+
+ this.updateOrientationState(this.isVideoInFullScreen);
+
+ if (this.isVideoInFullScreen) {
+ this.startFadeOut(this.controlBar, true);
+ }
+
+ this.setFullscreenButtonState();
+ },
+
+ updateOrientationState(lock) {
+ if (!this.video.mozOrientationLockEnabled) {
+ return;
+ }
+ if (lock) {
+ if (this.video.mozIsOrientationLocked) {
+ return;
+ }
+ let dimenDiff = this.video.videoWidth - this.video.videoHeight;
+ if (dimenDiff > 0) {
+ this.video.mozIsOrientationLocked =
+ this.window.screen.mozLockOrientation("landscape");
+ } else if (dimenDiff < 0) {
+ this.video.mozIsOrientationLocked =
+ this.window.screen.mozLockOrientation("portrait");
+ } else {
+ this.video.mozIsOrientationLocked =
+ this.window.screen.mozLockOrientation(
+ this.window.screen.orientation
+ );
+ }
+ } else {
+ if (!this.video.mozIsOrientationLocked) {
+ return;
+ }
+ this.window.screen.mozUnlockOrientation();
+ this.video.mozIsOrientationLocked = false;
+ }
+ },
+
+ clickToPlayClickHandler(e) {
+ if (e.button != 0) {
+ return;
+ }
+ if (this.hasError() && !this.suppressError) {
+ // Errors that can be dismissed should be placed here as we discover them.
+ if (this.video.error.code != this.video.error.MEDIA_ERR_ABORTED) {
+ return;
+ }
+ this.startFadeOut(this.statusOverlay, true);
+ this.suppressError = true;
+ return;
+ }
+ if (e.defaultPrevented) {
+ return;
+ }
+ if (this.playButton.hasAttribute("paused")) {
+ this.startPlay();
+ } else {
+ this.video.pause();
+ }
+ },
+ hideClickToPlay() {
+ let videoHeight = this.reflowedDimensions.videoHeight;
+ let videoWidth = this.reflowedDimensions.videoWidth;
+
+ // The play button will animate to 3x its size. This
+ // shows the animation unless the video is too small
+ // to show 2/3 of the animation.
+ let animationScale = 2;
+ let animationMinSize = this.clickToPlay.minWidth * animationScale;
+
+ let immediate =
+ animationMinSize > videoWidth ||
+ animationMinSize > videoHeight - this.controlBarMinHeight;
+ this.startFadeOut(this.clickToPlay, immediate);
+ },
+
+ setPlayButtonState(aPaused) {
+ if (aPaused) {
+ this.playButton.setAttribute("paused", "true");
+ } else {
+ this.playButton.removeAttribute("paused");
+ }
+
+ var id = aPaused
+ ? "videocontrols-play-button"
+ : "videocontrols-pause-button";
+ this.l10n.setAttributes(this.playButton, id);
+ this.l10n.setAttributes(this.clickToPlay, id);
+ },
+
+ get isEffectivelyMuted() {
+ return this.video.muted || !this.video.volume;
+ },
+
+ updateMuteButtonState() {
+ var muted = this.isEffectivelyMuted;
+
+ if (muted) {
+ this.muteButton.setAttribute("muted", "true");
+ } else {
+ this.muteButton.removeAttribute("muted");
+ }
+
+ var id = muted
+ ? "videocontrols-unmute-button"
+ : "videocontrols-mute-button";
+ this.l10n.setAttributes(this.muteButton, id);
+ },
+
+ keyboardVolumeDecrease() {
+ const oldval = this.video.volume;
+ this.video.volume = oldval < 0.1 ? 0 : oldval - 0.1;
+ this.video.muted = false;
+ },
+
+ keyboardVolumeIncrease() {
+ const oldval = this.video.volume;
+ this.video.volume = oldval > 0.9 ? 1 : oldval + 0.1;
+ this.video.muted = false;
+ },
+
+ keyboardSeekBack(tenPercent) {
+ const oldval = this.video.currentTime;
+ let newval;
+ if (tenPercent) {
+ newval =
+ oldval -
+ (this.video.duration || this.maxCurrentTimeSeen / 1000) / 10;
+ } else {
+ newval = oldval - VideoControlsWidget.SEEK_TIME_SECS;
+ }
+ this.video.currentTime = Math.max(0, newval);
+ },
+
+ keyboardSeekForward(tenPercent) {
+ const oldval = this.video.currentTime;
+ const maxtime = this.video.duration || this.maxCurrentTimeSeen / 1000;
+ let newval;
+ if (tenPercent) {
+ newval = oldval + maxtime / 10;
+ } else {
+ newval = oldval + VideoControlsWidget.SEEK_TIME_SECS;
+ }
+ this.video.currentTime = Math.min(newval, maxtime);
+ },
+
+ keyHandler(event) {
+ // Ignore keys when content might be providing its own.
+ if (!this.video.hasAttribute("controls")) {
+ return;
+ }
+
+ let keystroke = "";
+ if (event.altKey) {
+ keystroke += "alt-";
+ }
+ if (event.shiftKey) {
+ keystroke += "shift-";
+ }
+ if (this.window.navigator.platform.startsWith("Mac")) {
+ if (event.metaKey) {
+ keystroke += "accel-";
+ }
+ if (event.ctrlKey) {
+ keystroke += "control-";
+ }
+ } else {
+ if (event.metaKey) {
+ keystroke += "meta-";
+ }
+ if (event.ctrlKey) {
+ keystroke += "accel-";
+ }
+ }
+ if (event.key == " ") {
+ keystroke += "Space";
+ } else {
+ keystroke += event.key;
+ }
+
+ this.log("Got keystroke: " + keystroke);
+
+ // If unmodified cursor keys are pressed when a slider is focused, we
+ // should act on that slider. For example, if we're focused on the
+ // volume slider, rightArrow should increase the volume, not seek.
+ // Normally, we'd just pass the keys through to the slider in this case.
+ // However, the native adjustment is too small, so we override it.
+ try {
+ const target = event.originalTarget;
+ const allTabbable =
+ this.prefs["media.videocontrols.keyboard-tab-to-all-controls"];
+ switch (keystroke) {
+ case "Space" /* Play */:
+ if (target.localName === "button" && !target.disabled) {
+ break;
+ }
+ this.togglePause();
+ break;
+ case "ArrowDown" /* Volume decrease */:
+ if (allTabbable && target == this.scrubber) {
+ this.keyboardSeekBack(/* tenPercent */ false);
+ } else if (target.classList.contains("textTrackItem")) {
+ target.nextSibling?.focus();
+ } else {
+ this.keyboardVolumeDecrease();
+ }
+ break;
+ case "ArrowUp" /* Volume increase */:
+ if (allTabbable && target == this.scrubber) {
+ this.keyboardSeekForward(/* tenPercent */ false);
+ } else if (target.classList.contains("textTrackItem")) {
+ target.previousSibling?.focus();
+ } else {
+ this.keyboardVolumeIncrease();
+ }
+ break;
+ case "accel-ArrowDown" /* Mute */:
+ this.video.muted = true;
+ break;
+ case "accel-ArrowUp" /* Unmute */:
+ this.video.muted = false;
+ break;
+ case "ArrowLeft" /* Seek back 5 seconds */:
+ if (allTabbable && target == this.volumeControl) {
+ this.keyboardVolumeDecrease();
+ } else {
+ this.keyboardSeekBack(/* tenPercent */ false);
+ }
+ break;
+ case "accel-ArrowLeft" /* Seek back 10% */:
+ this.keyboardSeekBack(/* tenPercent */ true);
+ break;
+ case "ArrowRight" /* Seek forward 5 seconds */:
+ if (allTabbable && target == this.volumeControl) {
+ this.keyboardVolumeIncrease();
+ } else {
+ this.keyboardSeekForward(/* tenPercent */ false);
+ }
+ break;
+ case "accel-ArrowRight" /* Seek forward 10% */:
+ this.keyboardSeekForward(/* tenPercent */ true);
+ break;
+ case "Home" /* Seek to beginning */:
+ this.video.currentTime = 0;
+ break;
+ case "End" /* Seek to end */:
+ if (this.video.currentTime != this.video.duration) {
+ this.video.currentTime =
+ this.video.duration || this.maxCurrentTimeSeen / 1000;
+ }
+ break;
+ case "Escape" /* Escape */:
+ if (
+ target.classList.contains("textTrackItem") &&
+ !this.textTrackListContainer.hidden
+ ) {
+ this.toggleClosedCaption();
+ this.closedCaptionButton.focus();
+ }
+ break;
+ default:
+ return;
+ }
+ } catch (e) {
+ /* ignore any exception from setting .currentTime */
+ }
+
+ event.preventDefault(); // Prevent page scrolling
+ },
+
+ checkTextTrackSupport(textTrack) {
+ return textTrack.kind == "subtitles" || textTrack.kind == "captions";
+ },
+
+ get isCastingAvailable() {
+ return !this.isAudioOnly && this.video.mozAllowCasting;
+ },
+
+ get isClosedCaptionAvailable() {
+ // There is no rendering area, no need to show the caption.
+ if (this.isAudioOnly) {
+ return false;
+ }
+ return this.overlayableTextTracks.length;
+ },
+
+ get overlayableTextTracks() {
+ return Array.prototype.filter.call(
+ this.video.textTracks,
+ this.checkTextTrackSupport
+ );
+ },
+
+ get currentTextTrackIndex() {
+ const showingTT = this.overlayableTextTracks.find(
+ tt => tt.mode == "showing"
+ );
+
+ // fallback to off button if there's no showing track.
+ return showingTT ? showingTT.index : 0;
+ },
+
+ get isCastingOn() {
+ return this.isCastingAvailable && this.video.mozIsCasting;
+ },
+
+ setCastingButtonState() {
+ if (this.isCastingOn) {
+ this.castingButton.setAttribute("enabled", "true");
+ } else {
+ this.castingButton.removeAttribute("enabled");
+ }
+
+ this.adjustControlSize();
+ },
+
+ updateCasting(eventDetail) {
+ let castingData = JSON.parse(eventDetail);
+ if ("allow" in castingData) {
+ this.video.mozAllowCasting = !!castingData.allow;
+ }
+
+ if ("active" in castingData) {
+ this.video.mozIsCasting = !!castingData.active;
+ }
+ this.setCastingButtonState();
+ },
+
+ get isClosedCaptionOn() {
+ for (let tt of this.overlayableTextTracks) {
+ if (tt.mode === "showing") {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ setClosedCaptionButtonState() {
+ if (this.isClosedCaptionOn) {
+ this.closedCaptionButton.setAttribute("enabled", "true");
+ } else {
+ this.closedCaptionButton.removeAttribute("enabled");
+ }
+
+ let ttItems = this.textTrackList.childNodes;
+
+ for (let tti of ttItems) {
+ const idx = +tti.getAttribute("index");
+
+ if (idx == this.currentTextTrackIndex) {
+ tti.setAttribute("aria-checked", "true");
+ } else {
+ tti.setAttribute("aria-checked", "false");
+ }
+ }
+
+ this.adjustControlSize();
+ },
+
+ addNewTextTrack(tt) {
+ if (!this.checkTextTrackSupport(tt)) {
+ return;
+ }
+
+ if (tt.index && tt.index < this.textTracksCount) {
+ // Don't create items for initialized tracks. However, we
+ // still need to care about mode since TextTrackManager would
+ // turn on the first available track automatically.
+ if (tt.mode === "showing") {
+ this.changeTextTrack(tt.index);
+ }
+ return;
+ }
+
+ tt.index = this.textTracksCount++;
+
+ const ttBtn = this.shadowRoot.createElementAndAppendChildAt(
+ this.textTrackList,
+ "button"
+ );
+ ttBtn.textContent = tt.label || "";
+
+ ttBtn.classList.add("textTrackItem");
+ ttBtn.setAttribute("index", tt.index);
+ ttBtn.setAttribute("role", "menuitemradio");
+
+ if (tt.mode === "showing" && tt.index) {
+ this.changeTextTrack(tt.index);
+ }
+ },
+
+ changeTextTrack(index) {
+ for (let tt of this.overlayableTextTracks) {
+ if (tt.index === index) {
+ tt.mode = "showing";
+ } else {
+ tt.mode = "disabled";
+ }
+ }
+
+ if (!this.textTrackListContainer.hidden) {
+ this.toggleClosedCaption();
+ }
+ },
+
+ onControlBarAnimationFinished() {
+ this.hideClosedCaptionMenu();
+ this.video.dispatchEvent(
+ new this.window.CustomEvent("controlbarchange")
+ );
+ this.adjustControlSize();
+ },
+
+ toggleCasting() {
+ this.videocontrols.dispatchEvent(
+ new this.window.CustomEvent("VideoBindingCast")
+ );
+ },
+
+ hideClosedCaptionMenu() {
+ this.textTrackListContainer.hidden = true;
+ this.closedCaptionButton.setAttribute("aria-expanded", "false");
+ this.updatePictureInPictureToggleDisplay();
+ },
+
+ showClosedCaptionMenu() {
+ this.textTrackListContainer.hidden = false;
+ this.closedCaptionButton.setAttribute("aria-expanded", "true");
+ this.updatePictureInPictureToggleDisplay();
+ },
+
+ toggleClosedCaption() {
+ if (this.textTrackListContainer.hidden) {
+ this.showClosedCaptionMenu();
+ if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) {
+ // If we're about to hide the controls after focus, prevent that, as
+ // that will dismiss the CC menu before the user can use it.
+ this.textTrackList.firstChild.focus();
+ this.window.clearTimeout(this._hideControlsTimeout);
+ this._hideControlsTimeout = 0;
+ }
+ } else {
+ this.hideClosedCaptionMenu();
+ // If the CC menu was shown via the keyboard, we may have prevented
+ // the controls from hiding. We can now hide them.
+ if (
+ this.prefs["media.videocontrols.keyboard-tab-to-all-controls"] &&
+ !this.controlBar.hidden &&
+ // The click-to-play overlay must already be hidden (we don't
+ // hide controls when the overlay is visible).
+ this.clickToPlay.hidden &&
+ // Don't do this if the controls are static.
+ this.dynamicControls &&
+ // If the mouse is hovering over the control bar, the controls
+ // shouldn't hide.
+ // We use "div:hover" instead of just ":hover" so this works in
+ // quirks mode documents. See
+ // https://quirks.spec.whatwg.org/#the-active-and-hover-quirk
+ !this.controlBar.matches("div:hover")
+ ) {
+ this.window.clearTimeout(this._hideControlsTimeout);
+ this._hideControlsTimeout = this.window.setTimeout(
+ () => this._hideControlsFn(),
+ this.HIDE_CONTROLS_TIMEOUT_MS
+ );
+ }
+ }
+ },
+
+ onTextTrackAdd(trackEvent) {
+ this.addNewTextTrack(trackEvent.track);
+ this.setClosedCaptionButtonState();
+ },
+
+ onTextTrackRemove(trackEvent) {
+ const toRemoveIndex = trackEvent.track.index;
+ const ttItems = this.textTrackList.childNodes;
+
+ if (!ttItems) {
+ return;
+ }
+
+ for (let tti of ttItems) {
+ const idx = +tti.getAttribute("index");
+
+ if (idx === toRemoveIndex) {
+ tti.remove();
+ this.textTracksCount--;
+ }
+
+ this.video.dispatchEvent(
+ new this.window.CustomEvent("texttrackchange")
+ );
+ }
+
+ this.setClosedCaptionButtonState();
+ },
+
+ initTextTracks() {
+ // add 'off' button anyway as new text track might be
+ // dynamically added after initialization.
+ const offLabel = this.textTrackList.getAttribute("offlabel");
+ this.addNewTextTrack({
+ label: offLabel,
+ kind: "subtitles",
+ });
+
+ for (let tt of this.overlayableTextTracks) {
+ this.addNewTextTrack(tt);
+ }
+
+ this.setClosedCaptionButtonState();
+ // Hide the Closed Caption menu when the user moves focus
+ this.hideClosedCaptionMenu = this.hideClosedCaptionMenu.bind(this);
+ this.closedCaptionButton.addEventListener(
+ "focus",
+ this.hideClosedCaptionMenu
+ );
+ this.fullscreenButton.addEventListener(
+ "focus",
+ this.hideClosedCaptionMenu
+ );
+ },
+
+ log(msg) {
+ if (this.debug) {
+ this.window.console.log("videoctl: " + msg + "\n");
+ }
+ },
+
+ get isTopLevelSyntheticDocument() {
+ return (
+ this.document.mozSyntheticDocument && this.window === this.window.top
+ );
+ },
+
+ controlBarMinHeight: 40,
+ controlBarMinVisibleHeight: 28,
+
+ reflowTriggeringCallValidator: {
+ isReflowTriggeringPropsAllowed: false,
+ reflowTriggeringProps: Object.freeze([
+ "offsetLeft",
+ "offsetTop",
+ "offsetWidth",
+ "offsetHeight",
+ "offsetParent",
+ "clientLeft",
+ "clientTop",
+ "clientWidth",
+ "clientHeight",
+ "getClientRects",
+ "getBoundingClientRect",
+ ]),
+ get(obj, prop) {
+ if (
+ !this.isReflowTriggeringPropsAllowed &&
+ this.reflowTriggeringProps.includes(prop)
+ ) {
+ throw new Error("Please don't trigger reflow. See bug 1493525.");
+ }
+ let val = obj[prop];
+ if (typeof val == "function") {
+ return function () {
+ return val.apply(obj, arguments);
+ };
+ }
+ return val;
+ },
+
+ set(obj, prop, value) {
+ return Reflect.set(obj, prop, value);
+ },
+ },
+
+ installReflowCallValidator(element) {
+ return new Proxy(element, this.reflowTriggeringCallValidator);
+ },
+
+ reflowedDimensions: {
+ // Set the dimensions to intrinsic <video> dimensions before the first
+ // update.
+ // These values are not picked up by <audio> in adjustControlSize()
+ // (except for the fact that they are non-zero),
+ // it takes controlBarMinHeight and the value below instead.
+ videoHeight: 150,
+ videoWidth: 300,
+
+ // <audio> takes this width to grow/shrink controls.
+ // The initial value has to be smaller than the calculated minRequiredWidth
+ // so that we don't run into bug 1495821 (see comment on adjustControlSize()
+ // below)
+ videocontrolsWidth: 0,
+
+ // Used to decide if updating the scrubber progress will make a visible
+ // change (ie. make it move by at least one pixel).
+ // The default value is set to Infinity so that any small change is
+ // assumed to cause a visible change until updateReflowedDimensions
+ // has been called. (See bug 1817604)
+ scrubberWidth: Infinity,
+ },
+
+ updateReflowedDimensions() {
+ this.reflowedDimensions.videoHeight = this.video.clientHeight;
+ this.reflowedDimensions.videoWidth = this.video.clientWidth;
+ this.reflowedDimensions.videocontrolsWidth =
+ this.videocontrols.clientWidth;
+ this.reflowedDimensions.scrubberWidth = this.scrubber.clientWidth;
+ },
+
+ /**
+ * adjustControlSize() considers outer dimensions of the <video>/<audio> element
+ * from layout, and accordingly, sets/hides the controls, and adjusts
+ * the width/height of the control bar.
+ *
+ * It's important to remember that for <audio>, layout (specifically,
+ * nsVideoFrame) rely on us to expose the intrinsic dimensions of the
+ * control bar to properly size the <audio> element. We interact with layout
+ * by:
+ *
+ * 1) When the element has a non-zero height, explicitly set the height
+ * of the control bar to a size between controlBarMinHeight and
+ * controlBarMinVisibleHeight in response.
+ * Note: the logic here is flawed and had caused the end height to be
+ * depend on its previous state, see bug 1495817.
+ * 2) When the element has a outer width smaller or equal to minControlBarPaddingWidth,
+ * explicitly set the control bar to minRequiredWidth, so that when the
+ * outer width is unset, the audio element could go back to minRequiredWidth.
+ * Otherwise, set the width of the control bar to be the current outer width.
+ * Note: the logic here is also flawed; when the control bar is set to
+ * the current outer width, it never go back when the width is unset,
+ * see bug 1495821.
+ */
+ adjustControlSize() {
+ const minControlBarPaddingWidth = 18;
+
+ this.fullscreenButton.isWanted = !this.controlBar.hasAttribute(
+ "fullscreen-unavailable"
+ );
+ this.castingButton.isWanted = this.isCastingAvailable;
+ this.closedCaptionButton.isWanted = this.isClosedCaptionAvailable;
+ this.volumeStack.isWanted = !this.muteButton.hasAttribute("noAudio");
+
+ let minRequiredWidth = this.prioritizedControls
+ .filter(control => control && control.isWanted)
+ .reduce(
+ (accWidth, cc) => accWidth + cc.minWidth,
+ minControlBarPaddingWidth
+ );
+ // Skip the adjustment in case the stylesheets haven't been loaded yet.
+ if (!minRequiredWidth) {
+ return;
+ }
+
+ let givenHeight = this.reflowedDimensions.videoHeight;
+ let videoWidth =
+ (this.isAudioOnly
+ ? this.reflowedDimensions.videocontrolsWidth
+ : this.reflowedDimensions.videoWidth) || minRequiredWidth;
+ let videoHeight = this.isAudioOnly
+ ? this.controlBarMinHeight
+ : givenHeight;
+ let videocontrolsWidth = this.reflowedDimensions.videocontrolsWidth;
+
+ let widthUsed = minControlBarPaddingWidth;
+ let preventAppendControl = false;
+
+ for (let [index, control] of this.prioritizedControls.entries()) {
+ // The "durationSpan" element is disconnected from the document during l10n so
+ // we check if our reference to "durationSpan" is the connected one and if not we
+ // replace it with the correct one
+ if (control.id === "durationSpan" && !control.isConnected) {
+ const durationSpan = this.durationSpan;
+ if (durationSpan) {
+ this.defineControlProperties(durationSpan);
+ this.prioritizedControls[index] = durationSpan;
+ control = durationSpan;
+ }
+ }
+ if (!control.isWanted) {
+ control.hiddenByAdjustment = true;
+ continue;
+ }
+
+ control.hiddenByAdjustment =
+ preventAppendControl || widthUsed + control.minWidth > videoWidth;
+
+ if (control.hiddenByAdjustment) {
+ preventAppendControl = true;
+ } else {
+ widthUsed += control.minWidth;
+ }
+ }
+
+ // Use flexible spacer to separate controls when scrubber is hidden.
+ // As long as muteButton hidden, which means only play button presents,
+ // hide spacer and make playButton centered.
+ this.controlBarSpacer.hidden =
+ !this.scrubberStack.hidden || this.muteButton.hidden;
+
+ // Since the size of videocontrols is expanded with controlBar in <audio>, we
+ // should fix the dimensions in order not to recursively trigger reflow afterwards.
+ if (this.isAudioTag) {
+ if (givenHeight) {
+ // The height of controlBar should be capped with the bounds between controlBarMinHeight
+ // and controlBarMinVisibleHeight.
+ let controlBarHeight = Math.max(
+ Math.min(givenHeight, this.controlBarMinHeight),
+ this.controlBarMinVisibleHeight
+ );
+ this.controlBar.style.height = `${controlBarHeight}px`;
+ }
+ // Bug 1367875: Set minimum required width to controlBar if the given size is smaller than padding.
+ // This can help us expand the control and restore to the default size the next time we need
+ // to adjust the sizing.
+ if (videocontrolsWidth <= minControlBarPaddingWidth) {
+ this.controlBar.style.width = `${minRequiredWidth}px`;
+ } else {
+ this.controlBar.style.width = `${videoWidth}px`;
+ }
+ return;
+ }
+
+ if (
+ videoHeight < this.controlBarMinHeight ||
+ widthUsed === minControlBarPaddingWidth
+ ) {
+ this.controlBar.setAttribute("size", "hidden");
+ this.controlBar.hiddenByAdjustment = true;
+ } else {
+ this.controlBar.removeAttribute("size");
+ this.controlBar.hiddenByAdjustment = false;
+ }
+
+ // Adjust clickToPlayButton size.
+ const minVideoSideLength = Math.min(videoWidth, videoHeight);
+ const clickToPlayViewRatio = 0.15;
+ const clickToPlayScaledSize = Math.max(
+ this.clickToPlay.minWidth,
+ minVideoSideLength * clickToPlayViewRatio
+ );
+
+ if (
+ clickToPlayScaledSize >= videoWidth ||
+ clickToPlayScaledSize + this.controlBarMinHeight / 2 >=
+ videoHeight / 2
+ ) {
+ this.clickToPlay.hiddenByAdjustment = true;
+ } else {
+ if (
+ this.clickToPlay.hidden &&
+ !this.video.played.length &&
+ this.video.paused
+ ) {
+ this.clickToPlay.hiddenByAdjustment = false;
+ }
+ this.clickToPlay.style.width = `${clickToPlayScaledSize}px`;
+ this.clickToPlay.style.height = `${clickToPlayScaledSize}px`;
+ }
+ },
+
+ get pipToggleEnabled() {
+ return (
+ this.prefs[
+ "media.videocontrols.picture-in-picture.video-toggle.enabled"
+ ] && this.prefs["media.videocontrols.picture-in-picture.enabled"]
+ );
+ },
+
+ get positionDurationBox() {
+ return this.shadowRoot.getElementById("positionDurationBox");
+ },
+
+ get durationSpan() {
+ return this.positionDurationBox?.getElementsByTagName("span")[0];
+ },
+
+ init(shadowRoot, prefs) {
+ this.shadowRoot = shadowRoot;
+ this.video = this.installReflowCallValidator(shadowRoot.host);
+ this.videocontrols = this.installReflowCallValidator(
+ shadowRoot.firstChild
+ );
+ this.document = this.videocontrols.ownerDocument;
+ this.window = this.document.defaultView;
+ this.shadowRoot = shadowRoot;
+ this.prefs = prefs;
+
+ this.controlsContainer =
+ this.shadowRoot.getElementById("controlsContainer");
+ this.statusIcon = this.shadowRoot.getElementById("statusIcon");
+ this.controlBar = this.shadowRoot.getElementById("controlBar");
+ this.playButton = this.shadowRoot.getElementById("playButton");
+ this.controlBarSpacer =
+ this.shadowRoot.getElementById("controlBarSpacer");
+ this.muteButton = this.shadowRoot.getElementById("muteButton");
+ this.volumeStack = this.shadowRoot.getElementById("volumeStack");
+ this.volumeControl = this.shadowRoot.getElementById("volumeControl");
+ this.progressBar = this.shadowRoot.getElementById("progressBar");
+ this.bufferBar = this.shadowRoot.getElementById("bufferBar");
+ this.bufferA11yVal = this.shadowRoot.getElementById("bufferA11yVal");
+ this.scrubberStack = this.shadowRoot.getElementById("scrubberStack");
+ this.scrubber = this.shadowRoot.getElementById("scrubber");
+ this.durationLabel = this.shadowRoot.getElementById("durationLabel");
+ this.positionLabel = this.shadowRoot.getElementById("positionLabel");
+ this.statusOverlay = this.shadowRoot.getElementById("statusOverlay");
+ this.controlsOverlay =
+ this.shadowRoot.getElementById("controlsOverlay");
+ this.pictureInPictureOverlay = this.shadowRoot.getElementById(
+ "pictureInPictureOverlay"
+ );
+ this.controlsSpacer = this.shadowRoot.getElementById("controlsSpacer");
+ this.clickToPlay = this.shadowRoot.getElementById("clickToPlay");
+ this.fullscreenButton =
+ this.shadowRoot.getElementById("fullscreenButton");
+ this.castingButton = this.shadowRoot.getElementById("castingButton");
+ this.closedCaptionButton = this.shadowRoot.getElementById(
+ "closedCaptionButton"
+ );
+ this.textTrackList = this.shadowRoot.getElementById("textTrackList");
+ this.textTrackListContainer = this.shadowRoot.getElementById(
+ "textTrackListContainer"
+ );
+ this.pictureInPictureToggle = this.shadowRoot.getElementById(
+ "pictureInPictureToggle"
+ );
+
+ let isMobile = this.window.navigator.appVersion.includes("Android");
+ if (isMobile) {
+ this.controlsContainer.classList.add("mobile");
+ }
+
+ // TODO: Switch to touch controls on touch-based desktops (bug 1447547)
+ this.isTouchControls = isMobile;
+ if (this.isTouchControls) {
+ this.controlsContainer.classList.add("touch");
+ }
+
+ // XXX: Calling getComputedStyle() here by itself doesn't cause any reflow,
+ // but there is no guard proventing accessing any properties and methods
+ // of this saved CSSStyleDeclaration instance that could trigger reflow.
+ this.controlBarComputedStyles = this.window.getComputedStyle(
+ this.controlBar
+ );
+
+ // Hide and show control in certain order.
+ this.prioritizedControls = [
+ this.playButton,
+ this.muteButton,
+ this.fullscreenButton,
+ this.castingButton,
+ this.closedCaptionButton,
+ this.positionDurationBox,
+ this.scrubberStack,
+ this.durationSpan,
+ this.volumeStack,
+ ];
+
+ this.isAudioOnly = this.isAudioTag;
+ this.setupInitialState();
+ this.setupNewLoadState();
+ this.initTextTracks();
+
+ // Use the handleEvent() callback for all media events.
+ // Only the "error" event listener must capture, so that it can trap error
+ // events from <source> children, which don't bubble. But we use capture
+ // 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,
+ });
+ }
+
+ 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" },
+
+ // 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" },
+
+ { el: this.videocontrols, type: "resizevideocontrols" },
+
+ { el: this.document, 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 },
+
+ // prevent dragging of controls image (bug 517114)
+ { el: this.videocontrols, type: "dragstart" },
+
+ { 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" },
+
+ { el: this.video, type: "media-videoCasting", touchOnly: true },
+
+ { el: this.controlBar, type: "focusin" },
+ { el: this.scrubber, type: "mousedown" },
+ { el: this.volumeControl, type: "mousedown" },
+ ];
+
+ for (let {
+ el,
+ type,
+ nonTouchOnly = false,
+ touchOnly = false,
+ mozSystemGroup = true,
+ capture = false,
+ } of this.controlsEvents) {
+ if (
+ (this.isTouchControls && nonTouchOnly) ||
+ (!this.isTouchControls && touchOnly)
+ ) {
+ continue;
+ }
+ el.addEventListener(type, this, { mozSystemGroup, capture });
+ }
+
+ this.log("--- videocontrols initialized ---");
+ },
+ };
+
+ this.TouchUtils = {
+ videocontrols: null,
+ video: null,
+ controlsTimer: null,
+ controlsTimeout: 5000,
+
+ get visible() {
+ return (
+ !this.Utils.controlBar.hasAttribute("fadeout") &&
+ !this.Utils.controlBar.hidden
+ );
+ },
+
+ firstShow: false,
+
+ toggleControls() {
+ if (!this.Utils.dynamicControls || !this.visible) {
+ this.showControls();
+ } else {
+ this.delayHideControls(0);
+ }
+ },
+
+ showControls() {
+ if (this.Utils.dynamicControls) {
+ this.Utils.startFadeIn(this.Utils.controlBar);
+ this.delayHideControls(this.controlsTimeout);
+ }
+ },
+
+ clearTimer() {
+ if (this.controlsTimer) {
+ this.window.clearTimeout(this.controlsTimer);
+ this.controlsTimer = null;
+ }
+ },
+
+ delayHideControls(aTimeout) {
+ this.clearTimer();
+ this.controlsTimer = this.window.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;
+ }
+ },
+
+ terminate() {
+ try {
+ for (let { el, type, mozSystemGroup = true } of this.controlsEvents) {
+ el.removeEventListener(type, this, { mozSystemGroup });
+ }
+ } catch (ex) {}
+
+ this.clearTimer();
+ },
+
+ init(shadowRoot, utils) {
+ this.Utils = utils;
+ this.videocontrols = this.Utils.videocontrols;
+ this.video = this.Utils.video;
+ this.document = this.videocontrols.ownerDocument;
+ this.window = this.document.defaultView;
+ this.shadowRoot = shadowRoot;
+
+ 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" },
+ ];
+
+ 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 &&
+ 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 (this.video.currentTime !== 0) {
+ this.delayHideControls(this.Utils.HIDE_CONTROLS_TIMEOUT_MS);
+ }
+ },
+ };
+
+ this.Utils.init(this.shadowRoot, this.prefs);
+ if (this.Utils.isTouchControls) {
+ this.TouchUtils.init(this.shadowRoot, this.Utils);
+ }
+ this.shadowRoot.firstChild.dispatchEvent(
+ new this.window.CustomEvent("VideoBindingAttached")
+ );
+
+ this._setupEventListeners();
+ }
+
+ generateContent() {
+ const parser = new this.window.DOMParser();
+ let parserDoc = parser.parseFromString(
+ `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none">
+ <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" />
+ <link rel="stylesheet" href="chrome://global/skin/media/pipToggle.css" />
+
+ <div id="controlsContainer" class="controlsContainer" role="none">
+ <div id="statusOverlay" class="statusOverlay stackItem" hidden="true">
+ <div id="statusIcon" class="statusIcon"></div>
+ <bdi class="statusLabel" id="errorAborted" data-l10n-id="videocontrols-error-aborted"></bdi>
+ <bdi class="statusLabel" id="errorNetwork" data-l10n-id="videocontrols-error-network"></bdi>
+ <bdi class="statusLabel" id="errorDecode" data-l10n-id="videocontrols-error-decode"></bdi>
+ <bdi class="statusLabel" id="errorSrcNotSupported" data-l10n-id="videocontrols-error-src-not-supported"></bdi>
+ <bdi class="statusLabel" id="errorNoSource" data-l10n-id="videocontrols-error-no-source"></bdi>
+ <bdi class="statusLabel" id="errorGeneric" data-l10n-id="videocontrols-error-generic"></bdi>
+ </div>
+
+ <div id="pictureInPictureOverlay" class="pictureInPictureOverlay stackItem" status="pictureInPicture" hidden="true">
+ <div class="statusIcon" type="pictureInPicture"></div>
+ <bdi class="statusLabel" id="pictureInPicture" data-l10n-id="videocontrols-status-picture-in-picture"></bdi>
+ </div>
+
+ <div id="controlsOverlay" class="controlsOverlay stackItem" role="none">
+ <div class="controlsSpacerStack">
+ <div id="controlsSpacer" class="controlsSpacer stackItem" role="none"></div>
+ <button id="clickToPlay" class="clickToPlay" hidden="true"></button>
+ </div>
+
+ <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true">
+ <div class="pip-small clickable"></div>
+ <div class="pip-expanded clickable">
+ <span class="pip-icon-label clickable">
+ <span class="pip-icon"></span>
+ <span class="pip-label" data-l10n-id="videocontrols-picture-in-picture-toggle-label2"></span>
+ </span>
+ <div class="pip-explainer clickable" data-l10n-id="videocontrols-picture-in-picture-explainer3"></div>
+ </div>
+ <div class="pip-icon clickable"></div>
+ </button>
+
+ <div id="controlBar" class="controlBar" role="none" hidden="true">
+ <button id="playButton"
+ class="button playButton"
+ tabindex="-1"/>
+ <div id="scrubberStack" class="scrubberStack progressContainer" role="none">
+ <div class="progressBackgroundBar stackItem" role="none">
+ <div class="progressStack" role="none">
+ <progress id="bufferBar" class="bufferBar" value="0" max="100" aria-hidden="true"></progress>
+ <span class="a11y-only" role="status" aria-live="off">
+ <span data-l10n-id="videocontrols-buffer-bar-label"></span>
+ <span id="bufferA11yVal"></span>
+ </span>
+ <progress id="progressBar" class="progressBar" value="0" max="100" aria-hidden="true"></progress>
+ </div>
+ </div>
+ <input type="range" id="scrubber" class="scrubber" tabindex="-1" data-l10n-attrs="aria-valuetext" value="0"/>
+ </div>
+ <bdi id="positionLabel" class="positionLabel" role="presentation"></bdi>
+ <bdi id="durationLabel" class="durationLabel" role="presentation"></bdi>
+ <bdi id="positionDurationBox" class="positionDurationBox" aria-hidden="true">
+ <span id="durationSpan" class="duration" role="none"
+ data-l10n-name="position-duration-format"></span>
+ </bdi>
+ <div id="controlBarSpacer" class="controlBarSpacer" hidden="true" role="none"></div>
+ <button id="muteButton"
+ class="button muteButton"
+ tabindex="-1"/>
+ <div id="volumeStack" class="volumeStack progressContainer" role="none">
+ <input type="range" id="volumeControl" class="volumeControl" min="0" max="100" step="1" tabindex="-1"
+ data-l10n-id="videocontrols-volume-control"/>
+ </div>
+ <button id="castingButton" class="button castingButton"
+ data-l10n-id="videocontrols-casting-button-label"/>
+ <button id="closedCaptionButton" class="button closedCaptionButton" aria-controls="textTrackList"
+ aria-haspopup="menu" aria-expanded="false" data-l10n-id="videocontrols-closed-caption-button"/>
+ <div id="textTrackListContainer" class="textTrackListContainer" hidden="true" role="presentation">
+ <div id="textTrackList" role="menu" class="textTrackList"
+ data-l10n-id="videocontrols-closed-caption-off" data-l10n-attrs="offlabel"/>
+ </div>
+ <button id="fullscreenButton"
+ class="button fullscreenButton"/>
+ </div>
+ </div>
+ </div>
+ </div>`,
+ "application/xml"
+ );
+ this.l10n = new this.window.DOMLocalization(
+ ["branding/brand.ftl", "toolkit/global/videocontrols.ftl"],
+ true
+ );
+ this.l10n.connectRoot(this.shadowRoot);
+ if (this.prefs["media.videocontrols.keyboard-tab-to-all-controls"]) {
+ // Make all of the individual controls tabbable.
+ for (const el of parserDoc.documentElement.querySelectorAll(
+ '[tabindex="-1"]'
+ )) {
+ el.removeAttribute("tabindex");
+ }
+ }
+ this.shadowRoot.importNodeAndAppendChildAt(
+ this.shadowRoot,
+ parserDoc.documentElement,
+ true
+ );
+ this.l10n.translateRoots();
+ }
+
+ elementStateMatches(element) {
+ let elementInPiP = VideoControlsWidget.isPictureInPictureVideo(element);
+ return this.isShowingPictureInPictureMessage == elementInPiP;
+ }
+
+ teardown() {
+ this.Utils.terminate();
+ this.TouchUtils.terminate();
+ this.Utils.updateOrientationState(false);
+ this.l10n.disconnectRoot(this.shadowRoot);
+ this.l10n = null;
+ }
+
+ onPrefChange(prefName, prefValue) {
+ this.prefs[prefName] = prefValue;
+ this.Utils.updatePictureInPictureToggleDisplay();
+ }
+
+ _setupEventListeners() {
+ this.shadowRoot.firstChild.addEventListener("mouseover", event => {
+ if (!this.Utils.isTouchControls) {
+ this.Utils.onMouseInOut(event);
+ }
+ });
+
+ this.shadowRoot.firstChild.addEventListener("mouseout", event => {
+ if (!this.Utils.isTouchControls) {
+ this.Utils.onMouseInOut(event);
+ }
+ });
+
+ this.shadowRoot.firstChild.addEventListener("mousemove", event => {
+ if (!this.Utils.isTouchControls) {
+ this.Utils.onMouseMove(event);
+ }
+ });
+ }
+};
+
+this.NoControlsMobileImplWidget = class {
+ constructor(shadowRoot) {
+ this.shadowRoot = shadowRoot;
+ this.element = shadowRoot.host;
+ this.document = this.element.ownerDocument;
+ this.window = this.document.defaultView;
+ }
+
+ onsetup(direction) {
+ this.generateContent();
+
+ this.shadowRoot.firstElementChild.setAttribute("localedir", direction);
+
+ this.Utils = {
+ videoEvents: ["play", "playing"],
+ videoControlEvents: ["MozNoControlsBlockedVideo"],
+ terminate() {
+ for (let event of this.videoEvents) {
+ try {
+ this.video.removeEventListener(event, this, {
+ capture: true,
+ mozSystemGroup: true,
+ });
+ } catch (ex) {}
+ }
+
+ for (let event of this.videoControlEvents) {
+ try {
+ this.videocontrols.removeEventListener(event, this);
+ } catch (ex) {}
+ }
+
+ 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) {
+ 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.hasError()) {
+ this.noControlsOverlay.hidden = true;
+ return;
+ }
+ this.noControlsOverlay.hidden = false;
+ },
+
+ clickToPlayClickHandler(e) {
+ if (e.button != 0) {
+ return;
+ }
+
+ this.noControlsOverlay.hidden = true;
+ this.video.play();
+ },
+
+ init(shadowRoot) {
+ this.shadowRoot = shadowRoot;
+ this.video = shadowRoot.host;
+ this.videocontrols = shadowRoot.firstChild;
+ this.document = this.videocontrols.ownerDocument;
+ this.window = this.document.defaultView;
+ this.shadowRoot = shadowRoot;
+
+ this.controlsContainer =
+ this.shadowRoot.getElementById("controlsContainer");
+ this.clickToPlay = this.shadowRoot.getElementById("clickToPlay");
+ this.noControlsOverlay =
+ this.shadowRoot.getElementById("controlsContainer");
+
+ let isMobile = this.window.navigator.appVersion.includes("Android");
+ if (isMobile) {
+ this.controlsContainer.classList.add("mobile");
+ }
+
+ // TODO: Switch to touch controls on touch-based desktops (bug 1447547)
+ this.isTouchControls = isMobile;
+ if (this.isTouchControls) {
+ this.controlsContainer.classList.add("touch");
+ }
+
+ this.clickToPlay.addEventListener("click", this, {
+ mozSystemGroup: true,
+ });
+
+ for (let event of this.videoEvents) {
+ this.video.addEventListener(event, this, {
+ capture: true,
+ mozSystemGroup: true,
+ });
+ }
+
+ for (let event of this.videoControlEvents) {
+ this.videocontrols.addEventListener(event, this);
+ }
+ },
+ };
+ this.Utils.init(this.shadowRoot);
+ this.Utils.video.dispatchEvent(
+ new this.window.CustomEvent("MozNoControlsVideoBindingAttached")
+ );
+ }
+
+ elementStateMatches(element) {
+ return true;
+ }
+
+ teardown() {
+ this.Utils.terminate();
+ }
+
+ onPrefChange(prefName, prefValue) {
+ this.prefs[prefName] = prefValue;
+ }
+
+ generateContent() {
+ const parser = new this.window.DOMParser();
+ let parserDoc = parser.parseFromString(
+ `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none">
+ <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" />
+ <div id="controlsContainer" class="controlsContainer" role="none" hidden="true">
+ <div class="controlsOverlay stackItem">
+ <div class="controlsSpacerStack">
+ <button id="clickToPlay" class="clickToPlay"></button>
+ </div>
+ </div>
+ </div>
+ </div>`,
+ "application/xml"
+ );
+ this.shadowRoot.importNodeAndAppendChildAt(
+ this.shadowRoot,
+ parserDoc.documentElement,
+ true
+ );
+ }
+};
+
+this.NoControlsPictureInPictureImplWidget = class {
+ constructor(shadowRoot, prefs) {
+ this.shadowRoot = shadowRoot;
+ this.prefs = prefs;
+ this.element = shadowRoot.host;
+ this.document = this.element.ownerDocument;
+ this.window = this.document.defaultView;
+ }
+
+ onsetup(direction) {
+ this.generateContent();
+
+ this.shadowRoot.firstElementChild.setAttribute("localedir", direction);
+ }
+
+ elementStateMatches(element) {
+ return true;
+ }
+
+ teardown() {}
+
+ onPrefChange(prefName, prefValue) {
+ this.prefs[prefName] = prefValue;
+ }
+
+ generateContent() {
+ const parser = new this.window.DOMParser();
+ let parserDoc = parser.parseFromString(
+ `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none">
+ <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" />
+ <div id="controlsContainer" class="controlsContainer" role="none">
+ <div class="pictureInPictureOverlay stackItem" status="pictureInPicture">
+ <div id="statusIcon" class="statusIcon" type="pictureInPicture"></div>
+ <bdi class="statusLabel" id="pictureInPicture" data-l10n-id="videocontrols-status-picture-in-picture"></bdi>
+ </div>
+ <div class="controlsOverlay stackItem"></div>
+ </div>
+ </div>`,
+ "application/xml"
+ );
+ this.shadowRoot.importNodeAndAppendChildAt(
+ this.shadowRoot,
+ parserDoc.documentElement,
+ true
+ );
+ this.l10n = new this.window.DOMLocalization([
+ "branding/brand.ftl",
+ "toolkit/global/videocontrols.ftl",
+ ]);
+ this.l10n.connectRoot(this.shadowRoot);
+ this.l10n.translateRoots();
+ }
+};
+
+this.NoControlsDesktopImplWidget = class {
+ constructor(shadowRoot, prefs) {
+ this.shadowRoot = shadowRoot;
+ this.element = shadowRoot.host;
+ this.document = this.element.ownerDocument;
+ this.window = this.document.defaultView;
+ this.prefs = prefs;
+ }
+
+ onsetup(direction) {
+ this.generateContent();
+
+ this.shadowRoot.firstElementChild.setAttribute("localedir", direction);
+
+ this.Utils = {
+ handleEvent(event) {
+ switch (event.type) {
+ case "fullscreenchange": {
+ if (this.document.fullscreenElement) {
+ this.videocontrols.setAttribute("inDOMFullscreen", true);
+ } else {
+ this.videocontrols.removeAttribute("inDOMFullscreen");
+ }
+ break;
+ }
+ case "resizevideocontrols": {
+ this.updateReflowedDimensions();
+ this.updatePictureInPictureToggleDisplay();
+ break;
+ }
+ case "durationchange":
+ // Intentional fall-through
+ case "emptied":
+ // Intentional fall-through
+ case "loadedmetadata": {
+ this.updatePictureInPictureToggleDisplay();
+ break;
+ }
+ }
+ },
+
+ updatePictureInPictureToggleDisplay() {
+ if (
+ this.pipToggleEnabled &&
+ VideoControlsWidget.shouldShowPictureInPictureToggle(
+ this.prefs,
+ this.video,
+ this.reflowedDimensions
+ )
+ ) {
+ this.pictureInPictureToggle.hidden = false;
+ VideoControlsWidget.setupToggle(
+ this.prefs,
+ this.pictureInPictureToggle,
+ this.reflowedDimensions
+ );
+ } else {
+ this.pictureInPictureToggle.hidden = true;
+ }
+ },
+
+ init(shadowRoot, prefs) {
+ this.shadowRoot = shadowRoot;
+ this.prefs = prefs;
+ this.video = shadowRoot.host;
+ this.videocontrols = shadowRoot.firstChild;
+ this.document = this.videocontrols.ownerDocument;
+ this.window = this.document.defaultView;
+ this.shadowRoot = shadowRoot;
+
+ this.pictureInPictureToggle = this.shadowRoot.getElementById(
+ "pictureInPictureToggle"
+ );
+
+ if (this.document.fullscreenElement) {
+ this.videocontrols.setAttribute("inDOMFullscreen", true);
+ }
+
+ // Default the Picture-in-Picture toggle button to being hidden. We might unhide it
+ // later if we determine that this video is qualified to show it.
+ this.pictureInPictureToggle.hidden = true;
+
+ if (this.video.readyState >= this.video.HAVE_METADATA) {
+ // According to the spec[1], at the HAVE_METADATA (or later) state, we know
+ // the video duration and dimensions, which means we can calculate whether or
+ // not to show the Picture-in-Picture toggle now.
+ //
+ // [1]: https://www.w3.org/TR/html50/embedded-content-0.html#dom-media-have_metadata
+ this.updatePictureInPictureToggleDisplay();
+ }
+
+ this.document.addEventListener("fullscreenchange", this, {
+ capture: true,
+ });
+
+ this.video.addEventListener("emptied", this);
+ this.video.addEventListener("loadedmetadata", this);
+ this.video.addEventListener("durationchange", this);
+ this.videocontrols.addEventListener("resizevideocontrols", this);
+ },
+
+ terminate() {
+ this.document.removeEventListener("fullscreenchange", this, {
+ capture: true,
+ });
+
+ this.video.removeEventListener("emptied", this);
+ this.video.removeEventListener("loadedmetadata", this);
+ this.video.removeEventListener("durationchange", this);
+ this.videocontrols.removeEventListener("resizevideocontrols", this);
+ },
+
+ updateReflowedDimensions() {
+ this.reflowedDimensions.videoHeight = this.video.clientHeight;
+ this.reflowedDimensions.videoWidth = this.video.clientWidth;
+ this.reflowedDimensions.videocontrolsWidth =
+ this.videocontrols.clientWidth;
+ },
+
+ reflowedDimensions: {
+ // Set the dimensions to intrinsic <video> dimensions before the first
+ // update.
+ videoHeight: 150,
+ videoWidth: 300,
+ videocontrolsWidth: 0,
+ },
+
+ get pipToggleEnabled() {
+ return (
+ this.prefs[
+ "media.videocontrols.picture-in-picture.video-toggle.enabled"
+ ] && this.prefs["media.videocontrols.picture-in-picture.enabled"]
+ );
+ },
+ };
+ this.Utils.init(this.shadowRoot, this.prefs);
+ }
+
+ elementStateMatches(element) {
+ return true;
+ }
+
+ teardown() {
+ this.Utils.terminate();
+ }
+
+ onPrefChange(prefName, prefValue) {
+ this.prefs[prefName] = prefValue;
+ this.Utils.updatePictureInPictureToggleDisplay();
+ }
+
+ generateContent() {
+ const parser = new this.window.DOMParser();
+ let parserDoc = parser.parseFromString(
+ `<div class="videocontrols" xmlns="http://www.w3.org/1999/xhtml" role="none">
+ <link rel="stylesheet" href="chrome://global/skin/media/videocontrols.css" />
+ <link rel="stylesheet" href="chrome://global/skin/media/pipToggle.css" />
+
+ <div id="controlsContainer" class="controlsContainer" role="none">
+ <div class="controlsOverlay stackItem">
+ <button id="pictureInPictureToggle" class="pip-wrapper" position="left" hidden="true">
+ <div class="pip-small clickable"></div>
+ <div class="pip-expanded clickable">
+ <span class="pip-icon-label clickable">
+ <span class="pip-icon"></span>
+ <span class="pip-label" data-l10n-id="videocontrols-picture-in-picture-toggle-label2"></span>
+ </span>
+ <div class="pip-explainer clickable" data-l10n-id="videocontrols-picture-in-picture-explainer3"></div>
+ </div>
+ <div class="pip-icon"></div>
+ </button>
+ </div>
+ </div>
+ </div>`,
+ "application/xml"
+ );
+ this.shadowRoot.importNodeAndAppendChildAt(
+ this.shadowRoot,
+ parserDoc.documentElement,
+ true
+ );
+ this.l10n = new this.window.DOMLocalization([
+ "branding/brand.ftl",
+ "toolkit/global/videocontrols.ftl",
+ ]);
+ this.l10n.connectRoot(this.shadowRoot);
+ this.l10n.translateRoots();
+ }
+};