diff --git a/docs/guides/components.md b/docs/guides/components.md index b888ac3b67..a86c2973eb 100644 --- a/docs/guides/components.md +++ b/docs/guides/components.md @@ -316,24 +316,6 @@ Player ## Specific Component Details -### Progress Control - -The progress control has a grandchild component, the mouse time display, which shows a time tooltip that follows the mouse cursor. - -By default, the progress control is sandwiched inside the control bar between the volume menu button and the remaining time display. Some skins attempt to move the it above the control bar and have it span the full width of the player. In these cases, it is less than ideal to have the tooltips leave the bounds of the player. This can be prevented by setting the `keepTooltipsInside` option on the progress control. - -```js -let player = videojs('myplayer', { - controlBar: { - progressControl: { - keepTooltipsInside: true - } - } -}); -``` - -> **Note:** This makes the tooltips use a real element instead of pseudo-elements so targeting them with CSS is different. - ### Text Track Settings The text track settings component is only available when using emulated text tracks. diff --git a/src/css/components/_progress.scss b/src/css/components/_progress.scss index f684b7cf57..17001fd2ec 100644 --- a/src/css/components/_progress.scss +++ b/src/css/components/_progress.scss @@ -1,17 +1,6 @@ +// .vjs-progress-control / ProgressControl // -// Let's talk pixel math! -// Start with a base font size of 10px (assuming that hasn't changed) -// No Hover: -// - Progress holder is 3px -// - Progress handle is 9px -// - Progress handle is pulled up 3px to center it. -// -// Hover: -// - Progress holder becomes 5px -// - Progress handle becomes 15px -// - Progress handle is pulled up 5px to center it -// - +// This is the container for all progress bar-related components/elements. .video-js .vjs-progress-control { @include flex(auto); @include display-flex(center); @@ -22,35 +11,32 @@ display: none; } -// Box containing play and load progresses. Also acts as seek scrubber. +.vjs-no-flex .vjs-progress-control { + width: auto; +} + +// .vjs-progress-holder / SeekBar +// +// Box containing play and load progress bars. It also acts as seek scrubber. .video-js .vjs-progress-holder { @include flex(auto); @include transition(all 0.2s); height: 0.3em; } -// We need an increased hit area on hover +// This increases the size of the progress holder so there is an increased +// hit area for clicks/touches. .video-js .vjs-progress-control:hover .vjs-progress-holder { font-size: 1.666666666666666666em; } -/* If we let the font size grow as much as everything else, the current time tooltip ends up - ginormous. If you'd like to enable the current time tooltip all the time, this should be disabled - to avoid a weird hitch when you roll off the hover. */ - -// Also show the current time tooltip -.video-js .vjs-progress-control:hover .vjs-time-tooltip, -.video-js .vjs-progress-control:hover .vjs-mouse-display:after, -.video-js .vjs-progress-control:hover .vjs-play-progress:after { - font-family: $text-font-family; - visibility: visible; - font-size: 0.6em; -} - -// Progress Bars +// .vjs-play-progress / PlayProgressBar and .vjs-load-progress / LoadProgressBar +// +// These are bars that appear within the progress control to communicate the +// amount of media that has played back and the amount of media that has +// loaded, respectively. .video-js .vjs-progress-holder .vjs-play-progress, .video-js .vjs-progress-holder .vjs-load-progress, -.video-js .vjs-progress-holder .vjs-tooltip-progress-bar, .video-js .vjs-progress-holder .vjs-load-progress div { position: absolute; display: block; @@ -64,86 +50,80 @@ top: 0; } -.video-js .vjs-mouse-display { - @extend .vjs-icon-circle; - - &:before { - display: none; - } -} .video-js .vjs-play-progress { background-color: $primary-foreground-color; @extend .vjs-icon-circle; // Progress handle &:before { + font-size: 0.9em; position: absolute; - top: -0.333333333333333em; right: -0.5em; - font-size: 0.9em; + top: -0.333333333333333em; + z-index: 1; } } -// Current Time "tooltip" -// By default this is hidden and only shown when hovering over the progress control -.video-js .vjs-time-tooltip, -.video-js .vjs-mouse-display:after, -.video-js .vjs-play-progress:after { - visibility: hidden; - pointer-events: none; - position: absolute; - top: -3.4em; - right: -1.9em; - font-size: 0.9em; - color: #000; - content: attr(data-current-time); - padding: 6px 8px 8px 8px; - @include background-color-with-alpha(#fff, 0.8); - @include border-radius(0.3em); -} - -.video-js .vjs-time-tooltip, -.video-js .vjs-play-progress:before, -.video-js .vjs-play-progress:after { - z-index: 1; -} - -.video-js .vjs-progress-control .vjs-keep-tooltips-inside:after { - display: none; -} - .video-js .vjs-load-progress { - // For IE8 we'll lighten the color + // For IE8, we'll lighten the color background: lighten($secondary-background-color, 25%); - // Otherwise we'll rely on stacked opacities + // Otherwise, we'll rely on stacked opacities background: rgba($secondary-background-color, $secondary-background-transparency); } -// there are child elements of the load progress bar that represent the -// specific time ranges that have been buffered +// There are child elements of the load progress bar that represent the +// specific time ranges that have been buffered. .video-js .vjs-load-progress div { - // For IE8 we'll lighten the color + // For IE8, we'll lighten the color background: lighten($secondary-background-color, 50%); - // Otherwise we'll rely on stacked opacities + // Otherwise, we'll rely on stacked opacities background: rgba($secondary-background-color, 0.75); } -.video-js.vjs-no-flex .vjs-progress-control { - width: auto; -} - +// .vjs-time-tooltip +// +// These elements are displayed above the progress bar. They are not components +// themselves, but they are managed by the MouseTimeDisplay and PlayProgressBar +// components individually. +// +// By default, they are hidden and only shown when hovering over the progress +// control. .video-js .vjs-time-tooltip { + @include background-color-with-alpha(#fff, 0.8); + @include border-radius(0.3em); + color: #000; display: inline-block; - height: 2.4em; - position: relative; + + // By floating the tooltips to the right, their right edge becomes aligned + // with the right edge of their parent element. However, in order to have them + // centered, they must be pulled further to the right via positioning (e.g. + // `right: -10px;`. This part is left to JavaScript. float: right; - right: -1.9em; -} + font-family: $text-font-family; -.vjs-tooltip-progress-bar { + // The font-size should translate to a consistent 10px for time tooltips in + // all states. This is tricky because the .vjs-progress-holder element + // changes its font-size when the .vjs-progress-control is hovered. + font-size: 1em; + padding: 6px 8px 8px 8px; + pointer-events: none; + position: relative; + top: -3.4em; visibility: hidden; + z-index: 1; +} + +.video-js .vjs-progress-control:hover .vjs-time-tooltip { + + // Ensure that we maintain a font-size of ~10px. + font-size: 0.6em; + visibility: visible; } +// .vjs-mouse-display / MouseTimeDisplay +// +// This element tracks the mouse position along the progress control and +// includes a tooltip, which displays the time at that point in the media. .video-js .vjs-progress-control .vjs-mouse-display { display: none; position: absolute; @@ -152,25 +132,27 @@ background-color: #000; z-index: 1; } + .vjs-no-flex .vjs-progress-control .vjs-mouse-display { z-index: 0; } + .video-js .vjs-progress-control:hover .vjs-mouse-display { display: block; } -.video-js.vjs-user-inactive .vjs-progress-control .vjs-mouse-display, -.video-js.vjs-user-inactive .vjs-progress-control .vjs-mouse-display:after { + +.video-js.vjs-user-inactive .vjs-progress-control .vjs-mouse-display { visibility: hidden; opacity: 0; $trans: visibility 1.0s, opacity 1.0s; @include transition($trans); } -.video-js.vjs-user-inactive.vjs-no-flex .vjs-progress-control .vjs-mouse-display, -.video-js.vjs-user-inactive.vjs-no-flex .vjs-progress-control .vjs-mouse-display:after { + +.video-js.vjs-user-inactive.vjs-no-flex .vjs-progress-control .vjs-mouse-display { display: none; } -.vjs-mouse-display .vjs-time-tooltip, -.video-js .vjs-progress-control .vjs-mouse-display:after { + +.vjs-mouse-display .vjs-time-tooltip { color: #fff; @include background-color-with-alpha(#000, 0.8); } diff --git a/src/js/component.js b/src/js/component.js index 2629421c05..c43058a552 100644 --- a/src/js/component.js +++ b/src/js/component.js @@ -1442,6 +1442,81 @@ class Component { return intervalId; } + /** + * Queues up a callback to be passed to requestAnimationFrame (rAF), but + * with a few extra bonuses: + * + * - Supports browsers that do not support rAF by falling back to + * {@link Component#setTimeout}. + * + * - The callback is turned into a {@link Component~GenericCallback} (i.e. + * bound to the component). + * + * - Automatic cancellation of the rAF callback is handled if the component + * is disposed before it is called. + * + * @param {Component~GenericCallback} fn + * A function that will be bound to this component and executed just + * before the browser's next repaint. + * + * @return {number} + * Returns an rAF ID that gets used to identify the timeout. It can + * also be used in {@link Component#cancelAnimationFrame} to cancel + * the animation frame callback. + * + * @listens Component#dispose + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame} + */ + requestAnimationFrame(fn) { + if (this.supportsRaf_) { + fn = Fn.bind(this, fn); + + const id = window.requestAnimationFrame(fn); + const disposeFn = () => this.cancelAnimationFrame(id); + + disposeFn.guid = `vjs-raf-${id}`; + this.on('dispose', disposeFn); + + return id; + } + + // Fall back to using a timer. + return this.setTimeout(fn, 1000 / 60); + } + + /** + * Cancels a queued callback passed to {@link Component#requestAnimationFrame} + * (rAF). + * + * If you queue an rAF callback via {@link Component#requestAnimationFrame}, + * use this function instead of `window.cancelAnimationFrame`. If you don't, + * your dispose listener will not get cleaned up until {@link Component#dispose}! + * + * @param {number} id + * The rAF ID to clear. The return value of {@link Component#requestAnimationFrame}. + * + * @return {number} + * Returns the rAF ID that was cleared. + * + * @see [Similar to]{@link https://developer.mozilla.org/en-US/docs/Web/API/window/cancelAnimationFrame} + */ + cancelAnimationFrame(id) { + if (this.supportsRaf_) { + window.cancelAnimationFrame(id); + + const disposeFn = function() {}; + + disposeFn.guid = `vjs-raf-${id}`; + + this.off('dispose', disposeFn); + + return id; + } + + // Fall back to using a timer. + return this.clearTimeout(id); + } + /** * Register a `Component` with `videojs` given the name and the component. * @@ -1540,6 +1615,17 @@ class Component { } } +/** + * Whether or not this component supports `requestAnimationFrame`. + * + * This is exposed primarily for testing purposes. + * + * @private + * @type {Boolean} + */ +Component.prototype.supportsRaf_ = typeof window.requestAnimationFrame === 'function' && + typeof window.cancelAnimationFrame === 'function'; + Component.registerComponent('Component', Component); export default Component; diff --git a/src/js/control-bar/progress-control/mouse-time-display.js b/src/js/control-bar/progress-control/mouse-time-display.js index 837f5c4244..91c218481e 100644 --- a/src/js/control-bar/progress-control/mouse-time-display.js +++ b/src/js/control-bar/progress-control/mouse-time-display.js @@ -2,14 +2,18 @@ * @file mouse-time-display.js */ import Component from '../../component.js'; -import * as Dom from '../../utils/dom.js'; +// import * as Dom from '../../utils/dom.js'; import * as Fn from '../../utils/fn.js'; import formatTime from '../../utils/format-time.js'; -import computedStyle from '../../utils/computed-style.js'; +// import computedStyle from '../../utils/computed-style.js'; + +import './time-tooltip'; /** - * The Mouse Time Display component shows the time you will seek to - * when hovering over the progress bar + * The {@link MouseTimeDisplay} component tracks mouse movement over the + * {@link ProgressControl}. It displays an indicator and a {@link TimeTooltip} + * indicating the time which is represented by a given point in the + * {@link ProgressControl}. * * @extends Component */ @@ -19,36 +23,18 @@ class MouseTimeDisplay extends Component { * Creates an instance of this class. * * @param {Player} player - * The `Player` that this class should be attached to. + * The {@link Player} that this class should be attached to. * * @param {Object} [options] * The key/value store of player options. */ constructor(player, options) { super(player, options); - - if (options.playerOptions && - options.playerOptions.controlBar && - options.playerOptions.controlBar.progressControl && - options.playerOptions.controlBar.progressControl.keepTooltipsInside) { - this.keepTooltipsInside = options.playerOptions.controlBar.progressControl.keepTooltipsInside; - } - - if (this.keepTooltipsInside) { - this.tooltip = Dom.createEl('div', {className: 'vjs-time-tooltip'}); - this.el().appendChild(this.tooltip); - this.addClass('vjs-keep-tooltips-inside'); - } - - this.update(0, 0); - - player.on('ready', () => { - this.on(player.controlBar.progressControl.el(), 'mousemove', Fn.throttle(Fn.bind(this, this.handleMouseMove), 25)); - }); + this.update = Fn.throttle(Fn.bind(this, this.update), 25); } /** - * Create the `Component`'s DOM element + * Create the the DOM element for this class. * * @return {Element} * The element that was created. @@ -60,94 +46,44 @@ class MouseTimeDisplay extends Component { } /** - * Handle the mouse move event on the `MouseTimeDisplay`. - * - * @param {EventTarget~Event} event - * The `mousemove` event that caused this to event to run. - * - * @listen mousemove - */ - handleMouseMove(event) { - const duration = this.player_.duration(); - const newTime = this.calculateDistance(event) * duration; - const position = event.pageX - Dom.findElPosition(this.el().parentNode).left; - - this.update(newTime, position); - } - - /** - * Update the time and posistion of the `MouseTimeDisplay`. - * - * @param {number} newTime - * Time to change the `MouseTimeDisplay` to. - * - * @param {nubmer} position - * Postion from the left of the in pixels. - */ - update(newTime, position) { - const time = formatTime(newTime, this.player_.duration()); - - this.el().style.left = position + 'px'; - this.el().setAttribute('data-current-time', time); - - if (this.keepTooltipsInside) { - const clampedPosition = this.clampPosition_(position); - const difference = position - clampedPosition + 1; - const tooltipWidth = parseFloat(computedStyle(this.tooltip, 'width')); - const tooltipWidthHalf = tooltipWidth / 2; - - this.tooltip.innerHTML = time; - this.tooltip.style.right = `-${tooltipWidthHalf - difference}px`; - } - } - - /** - * Get the mouse pointers x coordinate in pixels. + * Enqueues updates to its own DOM as well as the DOM of its + * {@link TimeTooltip} child. * - * @param {EventTarget~Event} [event] - * The `mousemove` event that was passed to this function by - * {@link MouseTimeDisplay#handleMouseMove} + * @param {Object} seekBarRect + * The `ClientRect` for the {@link SeekBar} element. * - * @return {number} - * THe x position in pixels of the mouse pointer. + * @param {number} seekBarPoint + * A number from 0 to 1, representing a horizontal reference point + * from the left edge of the {@link SeekBar} */ - calculateDistance(event) { - return Dom.getPointerPosition(this.el().parentNode, event).x; - } + update(seekBarRect, seekBarPoint) { - /** - * This takes in a horizontal position for the bar and returns a clamped position. - * Clamped position means that it will keep the position greater than half the width - * of the tooltip and smaller than the player width minus half the width o the tooltip. - * It will only clamp the position if `keepTooltipsInside` option is set. - * - * @param {number} position - * The position the bar wants to be - * - * @return {number} - * The (potentially) new clamped position. - * - * @private - */ - clampPosition_(position) { - if (!this.keepTooltipsInside) { - return position; + // If there is an existing rAF ID, cancel it so we don't over-queue. + if (this.rafId_) { + this.cancelAnimationFrame(this.rafId_); } - const playerWidth = parseFloat(computedStyle(this.player().el(), 'width')); - const tooltipWidth = parseFloat(computedStyle(this.tooltip, 'width')); - const tooltipWidthHalf = tooltipWidth / 2; - let actualPosition = position; + this.rafId_ = this.requestAnimationFrame(() => { + const duration = this.player_.duration(); + const content = formatTime(seekBarPoint * duration, duration); - if (position < tooltipWidthHalf) { - actualPosition = Math.ceil(tooltipWidthHalf); - } else if (position > (playerWidth - tooltipWidthHalf)) { - actualPosition = Math.floor(playerWidth - tooltipWidthHalf); - } - - return actualPosition; + this.el_.style.left = `${seekBarRect.width * seekBarPoint}px`; + this.getChild('timeTooltip').update(seekBarRect, seekBarPoint, content); + }); } } +/** + * Default options for `MouseTimeDisplay` + * + * @type {Object} + * @private + */ +MouseTimeDisplay.prototype.options_ = { + children: [ + 'timeTooltip' + ] +}; + Component.registerComponent('MouseTimeDisplay', MouseTimeDisplay); export default MouseTimeDisplay; diff --git a/src/js/control-bar/progress-control/play-progress-bar.js b/src/js/control-bar/progress-control/play-progress-bar.js index b523a07d8d..1ee1cf506e 100644 --- a/src/js/control-bar/progress-control/play-progress-bar.js +++ b/src/js/control-bar/progress-control/play-progress-bar.js @@ -2,11 +2,13 @@ * @file play-progress-bar.js */ import Component from '../../component.js'; -import * as Fn from '../../utils/fn.js'; import formatTime from '../../utils/format-time.js'; +import './time-tooltip'; + /** - * Shows play progress + * Used by {@link SeekBar} to display media playback progress as part of the + * {@link ProgressControl}. * * @extends Component */ @@ -16,31 +18,17 @@ class PlayProgressBar extends Component { * Creates an instance of this class. * * @param {Player} player - * The `Player` that this class should be attached to. + * The {@link Player} that this class should be attached to. * * @param {Object} [options] * The key/value store of player options. */ constructor(player, options) { super(player, options); - this.updateDataAttr(); - this.on(player, 'timeupdate', this.updateDataAttr); - player.ready(Fn.bind(this, this.updateDataAttr)); - - if (options.playerOptions && - options.playerOptions.controlBar && - options.playerOptions.controlBar.progressControl && - options.playerOptions.controlBar.progressControl.keepTooltipsInside) { - this.keepTooltipsInside = options.playerOptions.controlBar.progressControl.keepTooltipsInside; - } - - if (this.keepTooltipsInside) { - this.addClass('vjs-keep-tooltips-inside'); - } } /** - * Create the `Component`'s DOM element + * Create the the DOM element for this class. * * @return {Element} * The element that was created. @@ -53,20 +41,46 @@ class PlayProgressBar extends Component { } /** - * Update the data-current-time attribute on the `PlayProgressBar`. + * Enqueues updates to its own DOM as well as the DOM of its + * {@link TimeTooltip} child. * - * @param {EventTarget~Event} [event] - * The `timeupdate` event that caused this to run. + * @param {Object} seekBarRect + * The `ClientRect` for the {@link SeekBar} element. * - * @listens Player#timeupdate + * @param {number} seekBarPoint + * A number from 0 to 1, representing a horizontal reference point + * from the left edge of the {@link SeekBar} */ - updateDataAttr(event) { - const time = (this.player_.scrubbing()) ? this.player_.getCache().currentTime : this.player_.currentTime(); + update(seekBarRect, seekBarPoint) { - this.el_.setAttribute('data-current-time', formatTime(time, this.player_.duration())); - } + // If there is an existing rAF ID, cancel it so we don't over-queue. + if (this.rafId_) { + this.cancelAnimationFrame(this.rafId_); + } + + this.rafId_ = this.requestAnimationFrame(() => { + const time = (this.player_.scrubbing()) ? + this.player_.getCache().currentTime : + this.player_.currentTime(); + const content = formatTime(time, this.player_.duration()); + + this.getChild('timeTooltip').update(seekBarRect, seekBarPoint, content); + }); + } } +/** + * Default options for {@link PlayProgressBar}. + * + * @type {Object} + * @private + */ +PlayProgressBar.prototype.options_ = { + children: [ + 'timeTooltip' + ] +}; + Component.registerComponent('PlayProgressBar', PlayProgressBar); export default PlayProgressBar; diff --git a/src/js/control-bar/progress-control/progress-control.js b/src/js/control-bar/progress-control/progress-control.js index b251d868fa..d54fa578eb 100644 --- a/src/js/control-bar/progress-control/progress-control.js +++ b/src/js/control-bar/progress-control/progress-control.js @@ -2,9 +2,10 @@ * @file progress-control.js */ import Component from '../../component.js'; +import * as Fn from '../../utils/fn.js'; +import * as Dom from '../../utils/dom.js'; import './seek-bar.js'; -import './mouse-time-display.js'; /** * The Progress Control component contains the seek bar, load progress, @@ -14,6 +15,21 @@ import './mouse-time-display.js'; */ class ProgressControl extends Component { + /** + * Creates an instance of this class. + * + * @param {Player} player + * The `Player` that this class should be attached to. + * + * @param {Object} [options] + * The key/value store of player options. + */ + constructor(player, options) { + super(player, options); + this.handleMouseMove = Fn.throttle(Fn.bind(this, this.handleMouseMove), 25); + this.on(this.el_, 'mousemove', this.handleMouseMove); + } + /** * Create the `Component`'s DOM element * @@ -25,6 +41,33 @@ class ProgressControl extends Component { className: 'vjs-progress-control vjs-control' }); } + + /** + * When the mouse moves over the `ProgressControl`, the pointer position + * gets passed down to the `MouseTimeDisplay` component. + * + * @param {EventTarget~Event} event + * The `mousemove` event that caused this function to run. + * + * @listen mousemove + */ + handleMouseMove(event) { + const seekBar = this.getChild('seekBar'); + const seekBarEl = seekBar.el(); + const seekBarRect = Dom.getBoundingClientRect(seekBarEl); + let seekBarPoint = Dom.getPointerPosition(seekBarEl, event).x; + + // The default skin has a gap on either side of the `SeekBar`. This means + // that it's possible to trigger this behavior outside the boundaries of + // the `SeekBar`. This ensures we stay within it at all times. + if (seekBarPoint > 1) { + seekBarPoint = 1; + } else if (seekBarPoint < 0) { + seekBarPoint = 0; + } + + seekBar.getChild('mouseTimeDisplay').update(seekBarRect, seekBarPoint); + } } /** diff --git a/src/js/control-bar/progress-control/seek-bar.js b/src/js/control-bar/progress-control/seek-bar.js index 430aa32806..9c078b67ae 100644 --- a/src/js/control-bar/progress-control/seek-bar.js +++ b/src/js/control-bar/progress-control/seek-bar.js @@ -3,16 +3,20 @@ */ import Slider from '../../slider/slider.js'; import Component from '../../component.js'; +import * as Dom from '../../utils/dom.js'; import * as Fn from '../../utils/fn.js'; import formatTime from '../../utils/format-time.js'; -import computedStyle from '../../utils/computed-style.js'; import './load-progress-bar.js'; import './play-progress-bar.js'; -import './tooltip-progress-bar.js'; +import './mouse-time-display.js'; + +// The number of seconds the `step*` functions move the timeline. +const STEP_SECONDS = 5; /** - * Seek Bar and holder for the progress bars + * Seek bar and container for the progress bars. Uses {@link PlayProgressBar} + * as its `bar`. * * @extends Slider */ @@ -29,20 +33,8 @@ class SeekBar extends Slider { */ constructor(player, options) { super(player, options); - this.on(player, 'timeupdate', this.updateProgress); - this.on(player, 'ended', this.updateProgress); - player.ready(Fn.bind(this, this.updateProgress)); - - if (options.playerOptions && - options.playerOptions.controlBar && - options.playerOptions.controlBar.progressControl && - options.playerOptions.controlBar.progressControl.keepTooltipsInside) { - this.keepTooltipsInside = options.playerOptions.controlBar.progressControl.keepTooltipsInside; - } - - if (this.keepTooltipsInside) { - this.tooltipProgressBar = this.addChild('TooltipProgressBar'); - } + this.update = Fn.throttle(Fn.bind(this, this.update), 50); + this.on(player, ['timeupdate', 'ended'], this.update); } /** @@ -60,7 +52,7 @@ class SeekBar extends Slider { } /** - * Update the seek bars tooltip and width. + * Update the seek bar's UI. * * @param {EventTarget~Event} [event] * The `timeupdate` or `ended` event that caused this to run. @@ -68,47 +60,41 @@ class SeekBar extends Slider { * @listens Player#timeupdate * @listens Player#ended */ - updateProgress(event) { - this.updateAriaAttributes(this.el_); - - if (this.keepTooltipsInside) { - this.updateAriaAttributes(this.tooltipProgressBar.el_); - this.tooltipProgressBar.el_.style.width = this.bar.el_.style.width; + update() { + const percent = super.update(); + const duration = this.player_.duration(); - const playerWidth = parseFloat(computedStyle(this.player().el(), 'width')); - const tooltipWidth = parseFloat(computedStyle(this.tooltipProgressBar.tooltip, 'width')); - const tooltipStyle = this.tooltipProgressBar.el().style; - - tooltipStyle.maxWidth = Math.floor(playerWidth - (tooltipWidth / 2)) + 'px'; - tooltipStyle.minWidth = Math.ceil(tooltipWidth / 2) + 'px'; - tooltipStyle.right = `-${tooltipWidth / 2}px`; - } - } - - /** - * Update ARIA accessibility attributes - * - * @param {Element} el - * The element to update with aria accessibility attributes. - */ - updateAriaAttributes(el) { // Allows for smooth scrubbing, when player can't keep up. - const time = (this.player_.scrubbing()) ? this.player_.getCache().currentTime : this.player_.currentTime(); + const time = (this.player_.scrubbing()) ? + this.player_.getCache().currentTime : + this.player_.currentTime(); // machine readable value of progress bar (percentage complete) - el.setAttribute('aria-valuenow', (this.getPercent() * 100).toFixed(2)); + this.el_.setAttribute('aria-valuenow', (percent * 100).toFixed(2)); + // human readable value of progress bar (time complete) - el.setAttribute('aria-valuetext', formatTime(time, this.player_.duration())); + this.el_.setAttribute('aria-valuetext', formatTime(time, duration)); + + // Update the `PlayProgressBar`. + this.bar.update(Dom.getBoundingClientRect(this.el_), percent); + + return percent; } /** - * Get percentage of video played + * Get the percentage of media played so far. * * @return {number} - * The percentage played + * The percentage of media played so far (0 to 1). */ getPercent() { - const percent = this.player_.currentTime() / this.player_.duration(); + + // Allows for smooth scrubbing, when player can't keep up. + const time = (this.player_.scrubbing()) ? + this.player_.getCache().currentTime : + this.player_.currentTime(); + + const percent = time / this.player_.duration(); return percent >= 1 ? 1 : percent; } @@ -171,18 +157,15 @@ class SeekBar extends Slider { * Move more quickly fast forward for keyboard-only users */ stepForward() { - // more quickly fast forward for keyboard-only users - this.player_.currentTime(this.player_.currentTime() + 5); + this.player_.currentTime(this.player_.currentTime() + STEP_SECONDS); } /** * Move more quickly rewind for keyboard-only users */ stepBack() { - // more quickly rewind for keyboard-only users - this.player_.currentTime(this.player_.currentTime() - 5); + this.player_.currentTime(this.player_.currentTime() - STEP_SECONDS); } - } /** diff --git a/src/js/control-bar/progress-control/time-tooltip.js b/src/js/control-bar/progress-control/time-tooltip.js new file mode 100644 index 0000000000..659842d9df --- /dev/null +++ b/src/js/control-bar/progress-control/time-tooltip.js @@ -0,0 +1,81 @@ +/** + * @file time-tooltip.js + */ +import Component from '../../component'; +import * as Dom from '../../utils/dom.js'; + +/** + * Time tooltips display a time above the progress bar. + * + * @extends Component + */ +class TimeTooltip extends Component { + + /** + * Create the time tooltip DOM element + * + * @return {Element} + * The element that was created. + */ + createEl() { + return super.createEl('div', { + className: 'vjs-time-tooltip' + }); + } + + /** + * Updates the position of the time tooltip relative to the `SeekBar`. + * + * @param {Object} seekBarRect + * The `ClientRect` for the {@link SeekBar} element. + * + * @param {number} seekBarPoint + * A number from 0 to 1, representing a horizontal reference point + * from the left edge of the {@link SeekBar} + */ + update(seekBarRect, seekBarPoint, content) { + const tooltipRect = Dom.getBoundingClientRect(this.el_); + const playerRect = Dom.getBoundingClientRect(this.player_.el()); + const seekBarPointPx = seekBarRect.width * seekBarPoint; + + // This is the space left of the `seekBarPoint` available within the bounds + // of the player. We calculate any gap between the left edge of the player + // and the left edge of the `SeekBar` and add the number of pixels in the + // `SeekBar` before hitting the `seekBarPoint` + const spaceLeftOfPoint = (seekBarRect.left - playerRect.left) + seekBarPointPx; + + // This is the space right of the `seekBarPoint` available within the bounds + // of the player. We calculate the number of pixels from the `seekBarPoint` + // to the right edge of the `SeekBar` and add to that any gap between the + // right edge of the `SeekBar` and the player. + const spaceRightOfPoint = (seekBarRect.width - seekBarPointPx) + + (playerRect.right - seekBarRect.right); + + // This is the number of pixels by which the tooltip will need to be pulled + // further to the right to center it over the `seekBarPoint`. + let pullTooltipBy = tooltipRect.width / 2; + + // Adjust the `pullTooltipBy` distance to the left or right depending on + // the results of the space calculations above. + if (spaceLeftOfPoint < pullTooltipBy) { + pullTooltipBy += pullTooltipBy - spaceLeftOfPoint; + } else if (spaceRightOfPoint < pullTooltipBy) { + pullTooltipBy = spaceRightOfPoint; + } + + // Due to the imprecision of decimal/ratio based calculations and varying + // rounding behaviors, there are cases where the spacing adjustment is off + // by a pixel or two. This adds insurance to these calculations. + if (pullTooltipBy < 0) { + pullTooltipBy = 0; + } else if (pullTooltipBy > tooltipRect.width) { + pullTooltipBy = tooltipRect.width; + } + + this.el_.style.right = `-${pullTooltipBy}px`; + Dom.textContent(this.el_, content); + } +} + +Component.registerComponent('TimeTooltip', TimeTooltip); +export default TimeTooltip; diff --git a/src/js/control-bar/progress-control/tooltip-progress-bar.js b/src/js/control-bar/progress-control/tooltip-progress-bar.js deleted file mode 100644 index e97d4c5b36..0000000000 --- a/src/js/control-bar/progress-control/tooltip-progress-bar.js +++ /dev/null @@ -1,68 +0,0 @@ -/** - * @file play-progress-bar.js - */ -import Component from '../../component.js'; -import * as Fn from '../../utils/fn.js'; -import formatTime from '../../utils/format-time.js'; - -/** - * Shows play progress - * - * @extends Component - */ -class TooltipProgressBar extends Component { - - /** - * Creates an instance of this class. - * - * @param {Player} player - * The `Player` that this class should be attached to. - * - * @param {Object} [options] - * The key/value store of player options. - */ - constructor(player, options) { - super(player, options); - this.updateDataAttr(); - this.on(player, 'timeupdate', this.updateDataAttr); - player.ready(Fn.bind(this, this.updateDataAttr)); - } - - /** - * Create the `Component`'s DOM element - * - * @return {Element} - * The element that was created. - */ - createEl() { - const el = super.createEl('div', { - className: 'vjs-tooltip-progress-bar vjs-slider-bar', - innerHTML: `
- ${this.localize('Progress')}: 0%` - }); - - this.tooltip = el.querySelector('.vjs-time-tooltip'); - - return el; - } - - /** - * Updatet the data-current-time attribute for TooltipProgressBar - * - * @param {EventTarget~Event} [event] - * The `timeupdate` event that caused this function to run. - * - * @listens Player#timeupdate - */ - updateDataAttr(event) { - const time = (this.player_.scrubbing()) ? this.player_.getCache().currentTime : this.player_.currentTime(); - const formattedTime = formatTime(time, this.player_.duration()); - - this.el_.setAttribute('data-current-time', formattedTime); - this.tooltip.innerHTML = formattedTime; - } - -} - -Component.registerComponent('TooltipProgressBar', TooltipProgressBar); -export default TooltipProgressBar; diff --git a/src/js/slider/slider.js b/src/js/slider/slider.js index 0dee6f3519..d0f8f71347 100644 --- a/src/js/slider/slider.js +++ b/src/js/slider/slider.js @@ -156,16 +156,23 @@ class Slider extends Component { /** * Update the progress bar of the `Slider`. + * + * @returns {number} + * The percentage of progress the progress bar represents as a + * number from 0 to 1. */ update() { - // In VolumeBar init we have a setTimeout for update that pops and update to the end of the - // execution stack. The player is destroyed before then update will cause an error + + // In VolumeBar init we have a setTimeout for update that pops and update + // to the end of the execution stack. The player is destroyed before then + // update will cause an error if (!this.el_) { return; } - // If scrubbing, we could use a cached value to make the handle keep up with the user's mouse. - // On HTML5 browsers scrubbing is really smooth, but some flash players are slow, so we might want to utilize this later. + // If scrubbing, we could use a cached value to make the handle keep up + // with the user's mouse. On HTML5 browsers scrubbing is really smooth, but + // some flash players are slow, so we might want to utilize this later. // var progress = (this.player_.scrubbing()) ? this.player_.getCache().currentTime / this.player_.duration() : this.player_.currentTime() / this.player_.duration(); let progress = this.getPercent(); const bar = this.bar; @@ -185,13 +192,16 @@ class Slider extends Component { // Convert to a percentage for setting const percentage = (progress * 100).toFixed(2) + '%'; + const style = bar.el().style; // Set the new bar width or height if (this.vertical()) { - bar.el().style.height = percentage; + style.height = percentage; } else { - bar.el().style.width = percentage; + style.width = percentage; } + + return progress; } /** diff --git a/src/js/utils/dom.js b/src/js/utils/dom.js index c8acc075fb..c6db4f9892 100644 --- a/src/js/utils/dom.js +++ b/src/js/utils/dom.js @@ -8,6 +8,7 @@ import * as Guid from './guid.js'; import log from './log.js'; import tsml from 'tsml'; import {isObject} from './obj'; +import computedStyle from './computed-style'; /** * Detect if a value is a string with any non-whitespace characters. @@ -574,6 +575,48 @@ export function unblockTextSelection() { }; } +/** + * Identical to the native `getBoundingClientRect` function, but ensures that + * the method is supported at all (it is in all browsers we claim to support) + * and that the element is in the DOM before continuing. + * + * This wrapper function also shims properties which are not provided by some + * older browsers (namely, IE8). + * + * Additionally, some browsers do not support adding properties to a + * `ClientRect`/`DOMRect` object; so, we shallow-copy it with the standard + * properties (except `x` and `y` which are not widely supported). This helps + * avoid implementations where keys are non-enumerable. + * + * @param {Element} el + * Element whose `ClientRect` we want to calculate. + * + * @return {Object|undefined} + * Always returns a plain + */ +export function getBoundingClientRect(el) { + if (el.getBoundingClientRect && el.parentNode) { + const rect = el.getBoundingClientRect(); + const result = {}; + + ['bottom', 'height', 'left', 'right', 'top', 'width'].forEach(k => { + if (rect[k] !== undefined) { + result[k] = rect[k]; + } + }); + + if (!result.height) { + result.height = parseFloat(computedStyle(el, 'height')); + } + + if (!result.width) { + result.width = parseFloat(computedStyle(el, 'width')); + } + + return result; + } +} + /** * The postion of a DOM element on the page. * diff --git a/test/unit/component.test.js b/test/unit/component.test.js index 43c706efcf..ae63de1261 100644 --- a/test/unit/component.test.js +++ b/test/unit/component.test.js @@ -1,4 +1,5 @@ /* eslint-env qunit */ +import window from 'global/window'; import Component from '../../src/js/component.js'; import * as Dom from '../../src/js/utils/dom.js'; import * as Events from '../../src/js/utils/events.js'; @@ -826,6 +827,70 @@ QUnit.test('should provide interval methods that automatically get cleared on co assert.ok(intervalsFired === 5, 'Interval was cleared when component was disposed'); }); +QUnit.test('should provide *AnimationFrame methods that automatically get cleared on component disposal', function(assert) { + const comp = new Component(getFakePlayer()); + const oldRAF = window.requestAnimationFrame; + const oldCAF = window.cancelAnimationFrame; + + // Stub the window.*AnimationFrame methods with window.setTimeout methods + // so we can control when the callbacks are called via sinon's timer stubs. + window.requestAnimationFrame = (fn) => window.setTimeout(fn, 1); + window.cancelAnimationFrame = (id) => window.clearTimeout(id); + + // Make sure the component thinks it supports rAF. + comp.supportsRaf_ = true; + + const spyRAF = sinon.spy(); + + comp.requestAnimationFrame(spyRAF); + + assert.strictEqual(spyRAF.callCount, 0, 'rAF callback was not called immediately'); + this.clock.tick(1); + assert.strictEqual(spyRAF.callCount, 1, 'rAF callback was called after a "repaint"'); + this.clock.tick(1); + assert.strictEqual(spyRAF.callCount, 1, 'rAF callback was not called after a second "repaint"'); + + comp.cancelAnimationFrame(comp.requestAnimationFrame(spyRAF)); + this.clock.tick(1); + assert.strictEqual(spyRAF.callCount, 1, 'second rAF callback was not called because it was cancelled'); + + comp.requestAnimationFrame(spyRAF); + comp.dispose(); + this.clock.tick(1); + assert.strictEqual(spyRAF.callCount, 1, 'third rAF callback was not called because the component was disposed'); + + window.requestAnimationFrame = oldRAF; + window.cancelAnimationFrame = oldCAF; +}); + +QUnit.test('*AnimationFrame methods fall back to timers if rAF not supported', function(assert) { + const comp = new Component(getFakePlayer()); + const oldRAF = window.requestAnimationFrame; + const oldCAF = window.cancelAnimationFrame; + + // Stub the window.*AnimationFrame methods with window.setTimeout methods + // so we can control when the callbacks are called via sinon's timer stubs. + const rAF = window.requestAnimationFrame = sinon.spy(); + const cAF = window.cancelAnimationFrame = sinon.spy(); + + // Make sure the component thinks it does not support rAF. + comp.supportsRaf_ = false; + + sinon.spy(comp, 'setTimeout'); + sinon.spy(comp, 'clearTimeout'); + + comp.cancelAnimationFrame(comp.requestAnimationFrame(() => {})); + + assert.strictEqual(rAF.callCount, 0, 'window.requestAnimationFrame was not called'); + assert.strictEqual(cAF.callCount, 0, 'window.cancelAnimationFrame was not called'); + assert.strictEqual(comp.setTimeout.callCount, 1, 'Component#setTimeout was called'); + assert.strictEqual(comp.clearTimeout.callCount, 1, 'Component#clearTimeout was called'); + + comp.dispose(); + window.requestAnimationFrame = oldRAF; + window.cancelAnimationFrame = oldCAF; +}); + QUnit.test('$ and $$ functions', function(assert) { const comp = new Component(getFakePlayer()); const contentEl = document.createElement('div'); diff --git a/test/unit/utils/dom.test.js b/test/unit/utils/dom.test.js index 1b5efeca6c..1e7ff582e3 100644 --- a/test/unit/utils/dom.test.js +++ b/test/unit/utils/dom.test.js @@ -1,5 +1,7 @@ /* eslint-env qunit */ +import window from 'global/window'; import document from 'global/document'; +import sinon from 'sinon'; import * as Dom from '../../../src/js/utils/dom.js'; QUnit.module('dom'); @@ -550,3 +552,57 @@ QUnit.test('$() and $$()', function(assert) { 0, 'returns 0 for missing elements'); }); + +QUnit.test('getBoundingClientRect() returns an object for elements that support it', function(assert) { + const mockEl = { + getBoundingClientRect: sinon.spy(() => { + return { + bottom: 3, + height: 10, + left: 4, + right: 2, + top: 1, + width: 20 + }; + }), + parentNode: true + }; + + const actual = Dom.getBoundingClientRect(mockEl); + + // The expected result is what is returned by the mock element. + const expected = mockEl.getBoundingClientRect.firstCall.returnValue; + + assert.notStrictEqual(actual, expected, 'the object returned by the mock element was cloned and not returned directly'); + + Object.keys(expected).forEach(k => { + assert.strictEqual(actual[k], expected[k], `the "${k}" returned by the Dom util matches what was returned by the mock element`); + }); +}); + +QUnit.test('getBoundingClientRect() shims only width and height for elements that do not return them', function(assert) { + const oldGCS = window.getComputedStyle; + + // This is done so that we fall back to looking for the `currentStyle` + // property on the mock element. + window.getComputedStyle = null; + + const mockEl = { + currentStyle: { + height: '123', + width: '456' + }, + getBoundingClientRect: sinon.spy(() => { + return {}; + }), + parentNode: true + }; + + const actual = Dom.getBoundingClientRect(mockEl); + + assert.deepEqual(Object.keys(actual).sort(), ['height', 'width'], 'only "height" and "width" were shimmed'); + assert.strictEqual(actual.height, 123, '"height" was shimmed because it was missing'); + assert.strictEqual(actual.width, 456, '"width" was shimmed because it was missing'); + + window.getComputedStyle = oldGCS; +});