diff --git a/index.html b/index.html index 328298c249..17d6b7f827 100644 --- a/index.html +++ b/index.html @@ -31,6 +31,7 @@
An unmute transient button will show after playback starts if muted.
++ Transient buttons to skip into / credits / recap display at times defined + in a metadata track. +
+ + + diff --git a/src/css/components/_transient-button.scss b/src/css/components/_transient-button.scss new file mode 100644 index 0000000000..ddc866f5ae --- /dev/null +++ b/src/css/components/_transient-button.scss @@ -0,0 +1,48 @@ +.video-js .vjs-transient-button { + position: absolute; + height: 3em; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(50, 50, 50, 0.5); + cursor: pointer; + opacity: 1; + transition: opacity 1s; +} + +.video-js:not(.vjs-has-started) .vjs-transient-button { + display: none; +} + +.video-js.not-hover .vjs-transient-button:not(.force-display), +.video-js.vjs-user-inactive .vjs-transient-button:not(.force-display) { + opacity: 0; +} + +.video-js .vjs-transient-button span { + padding: 0 0.5em; +} + +.video-js .vjs-transient-button.vjs-left { + left: 1em; +} + +.video-js .vjs-transient-button.vjs-right { + right: 1em; +} + +.video-js .vjs-transient-button.vjs-top { + top: 1em; +} + +.video-js .vjs-transient-button.vjs-near-top { + top: 4em; +} + +.video-js .vjs-transient-button.vjs-bottom { + bottom: 4em; +} + +.video-js .vjs-transient-button:hover { + background-color: rgba(50, 50, 50, 0.9); +} diff --git a/src/css/video-js.scss b/src/css/video-js.scss index cf20e45241..fcb8388f20 100644 --- a/src/css/video-js.scss +++ b/src/css/video-js.scss @@ -44,6 +44,7 @@ @import "components/captions-settings"; @import "components/title-bar"; @import "components/skip-buttons"; +@import "components/transient-button"; @import "print"; diff --git a/src/js/player.js b/src/js/player.js index f25badeff6..beea9031fb 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -52,6 +52,7 @@ import './tracks/text-track-settings.js'; import './resize-manager.js'; import './live-tracker.js'; import './title-bar.js'; +import './transient-button.js'; // Import Html5 tech, at least for disposing the original video tag. import './tech/html5.js'; diff --git a/src/js/transient-button.js b/src/js/transient-button.js new file mode 100644 index 0000000000..08da37a419 --- /dev/null +++ b/src/js/transient-button.js @@ -0,0 +1,124 @@ +import Button from './button.js'; +import Component from './component.js'; +import {merge} from './utils/obj'; +import * as Dom from './utils/dom.js'; + +/** @import Player from './player' */ + +/** + * @typedef {object} TransientButtonOptions + * @property {string} [controlText] Control text, usually visible for these buttons + * @property {number} [initialDisplay=4000] Time in ms that button should initially remain visible + * @property {Array<'top'|'neartop'|'bottom'|'left'|'right'>} [position] Array of position strings to add basic styles for positioning + * @property {string} [className] Class(es) to add + * @property {boolean} [takeFocus=false] Whether element sohuld take focus when shown + * @property {Function} [clickHandler] Function called on button activation + */ + +/** @type {TransientButtonOptions} */ +const defaults = { + initialDisplay: 4000, + position: [], + takeFocus: false +}; + +/** + * A floating transient button. + * It's recommended to insert these buttons _before_ the control bar with the this argument to `addChild` + * for a logical tab order. + * + * @example + * ``` + * player.addChild( + * 'TransientButton', + * options, + * player.children().indexOf(player.getChild("ControlBar")) + * ) + * ``` + * + * @extends Button + */ +class TransientButton extends Button { + /** + * TransientButton constructor + * + * @param {Player} player The button's player + * @param {TransientButtonOptions} options Options for the transient button + */ + constructor(player, options) { + options = merge(defaults, options); + super(player, options); + this.controlText(options.controlText); + this.hide(); + + // When shown, the float button will be visible even if the user is inactive. + // Clear this if there is any interaction. + player.on(['useractive', 'userinactive'], (e) => { + this.removeClass('force-display'); + }); + } + + /** + * Return CSS class including position classes + * + * @return {string} CSS class list + */ + buildCSSClass() { + return `vjs-transient-button focus-visible ${this.options_.position.map((c) => `vjs-${c}`).join(' ')}`; + } + + /** + * Create the button element + * + * @return {HTMLButtonElement} The button element + */ + createEl() { + /** @type HTMLButtonElement */ + const el = Dom.createEl( + 'button', {}, { + type: 'button', + class: this.buildCSSClass() + }, + Dom.createEl('span') + ); + + this.controlTextEl_ = el.querySelector('span'); + + return el; + } + + /** + * Show the button. The button will remain visible for the `initialDisplay` time, default 4s, + * and when there is user activity. + */ + show() { + super.show(); + this.addClass('force-display'); + if (this.options_.takeFocus) { + this.el().focus({ preventScroll: true}); + } + + this.forceDisplayTimeout = this.player_.setTimeout(() => { + this.removeClass('force-display'); + }, this.options_.initialDisplay); + } + + /** + * Hide the display, even if during the `initialDisplay` time. + */ + hide() { + this.removeClass('force-display'); + super.hide(); + } + + /** + * Dispose the component + */ + dispose() { + this.player_.clearTimeout(this.forceDisplayTimeout); + super.dispose(); + } +} + +Component.registerComponent('TransientButton', TransientButton); +export default TransientButton; diff --git a/test/unit/transient-button.test.js b/test/unit/transient-button.test.js new file mode 100644 index 0000000000..8be130812f --- /dev/null +++ b/test/unit/transient-button.test.js @@ -0,0 +1,89 @@ +/* eslint-env qunit */ +import TransientButton from '../../src/js/transient-button.js'; +import TestHelpers from './test-helpers.js'; +import sinon from 'sinon'; + +QUnit.module('TransientButton'); + +QUnit.test('show and hide should add and remove force-display class', function(assert) { + const player = TestHelpers.makePlayer(); + + const testButton = new TransientButton(player, {}); + + player.addChild(testButton); + + assert.false(testButton.hasClass('force-display'), 'button is initially hidden'); + + testButton.show(); + assert.true(testButton.hasClass('force-display'), 'button has force-display after show()'); + + testButton.hide(); + assert.false(testButton.hasClass('force-display'), 'button no longer has force-display after hide()'); + + player.dispose(); +}); + +QUnit.test('show and hide should add and remove force-display class', function(assert) { + this.clock = sinon.useFakeTimers(); + + const player = TestHelpers.makePlayer(); + + const testButton = new TransientButton(player, {}); + + player.hasStarted(true); + player.userActive(false); + + player.addChild(testButton); + + assert.false(testButton.hasClass('force-display'), 'button is initially hidden'); + + testButton.show(); + assert.true(testButton.hasClass('force-display'), 'button has force-display after show()'); + + this.clock.tick(2000); + assert.true(testButton.hasClass('force-display'), 'button still has force-display until timeout'); + + this.clock.tick(2500); + assert.false(testButton.hasClass('force-display'), 'button no longer has force-display until timeout'); + + player.dispose(); + + this.clock.restore(); +}); + +QUnit.test('applies posiiton classes', function(assert) { + const player = TestHelpers.makePlayer(); + const testButton1 = new TransientButton(player, { position: ['top', 'left']}); + const testButton2 = new TransientButton(player, { position: ['bottom', 'right']}); + const testButton3 = new TransientButton(player, {}); + + assert.ok(testButton1.hasClass('vjs-top'), 'position top yields vjs-top class'); + assert.ok(testButton1.hasClass('vjs-left'), 'position left yields vjs-left class'); + assert.ok(testButton2.hasClass('vjs-bottom'), 'position bottom yields vjs-bottom class'); + assert.ok(testButton2.hasClass('vjs-right'), 'position right yields vjs-right class'); + ['vjs-top', 'vjs-neartop', 'vjs-bottom', 'vjs-left', 'vjs-right'].forEach(positionClass => { + assert.false(testButton3.hasClass(positionClass), `with no options should be no ${positionClass} class`); + }); + + player.dispose(); +}); + +QUnit.test('takes focus only when specified', function(assert) { + + const player = TestHelpers.makePlayer(); + const testButton1 = new TransientButton(player, {}); + const testButton2 = new TransientButton(player, {takeFocus: true}); + + const spy1 = sinon.spy(testButton1.el_, 'focus'); + const spy2 = sinon.spy(testButton2.el_, 'focus'); + + player.addChild(testButton1); + testButton1.show(); + assert.false(spy1.called, 'by default a button should not take focus'); + + player.addChild(testButton2); + testButton2.show(); + assert.true(spy2.called, 'when enabled button should take focus'); + + player.dispose(); +});