Source code

Revision control

Copy as Markdown

Other Tools

/* 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.sys.mjs.
/*
* 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.onchange();
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.disablePictureInPicture
) {
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;
}
}
// 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);
}
this.updatePictureInPictureMessage();
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.
//
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.
// FIXME(emilio): We should rewrite this to just use
// ResizeObserver, probably.
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
!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())
);
},
updatePictureInPictureMessage() {
let showMessage =
!this.hasError() &&
VideoControlsWidget.isPictureInPictureVideo(this.video);
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() {
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() {
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");
}
if (this.isVideoInFullScreen) {
this.startFadeOut(this.controlBar, true);
}
this.setFullscreenButtonState();
},
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;
this.muteButton.toggleAttribute("muted", 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() {
this.castingButton.toggleAttribute("enabled", this.isCastingOn);
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() {
this.closedCaptionButton.toggleAttribute(
"enabled",
this.isClosedCaptionOn
);
let ttItems = this.textTrackList.childNodes;
for (let tti of ttItems) {
const idx = +tti.getAttribute("index");
tti.setAttribute("aria-checked", idx == this.currentTextTrackIndex);
}
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
!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._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();
}
onchange() {
this.Utils.updatePictureInPictureMessage();
this.shadowRoot.firstChild.removeAttribute("flipped");
}
teardown() {
this.Utils.terminate();
this.TouchUtils.terminate();
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);
}
onchange() {}
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);
}
onchange() {}
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.
//
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);
}
onchange() {}
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();
}
};