From 1afe5049e68e7962a37ebd838198447b279ee2b4 Mon Sep 17 00:00:00 2001 From: mister-ben <1676039+mister-ben@users.noreply.github.com> Date: Sat, 6 Jul 2024 07:40:31 +0100 Subject: [PATCH] feat: Adds a transient button component (#8629) ## Description Adds a `TransientButton` component for the types of button that are shown on top of the video briefly during playback and reappear when there is user activity. e.g. Unmute buttons, skip intro. It aims is to be a generic button type to be extended. Some basic styles are provided but kept light to not complicate customisation. It's important to insert a transient button before the control bar for the tab order to make sense. _Optionally_ takes focus when shown. ## Specific Changes proposed Adds `TransientButton` component. ## Requirements Checklist - [x] Feature implemented / Bug fixed - [ ] If necessary, more likely in a feature request than a bug fix - [x] Change has been verified in an actual browser (Chrome, Firefox, IE) - [x] Unit Tests updated or fixed - [ ] Docs/guides updated - [x] Example: https://deploy-preview-8629--videojs-preview.netlify.app/sandbox/transient-button.html - [x] Has no DOM changes which impact accessiblilty or trigger warnings (e.g. Chrome issues tab) - [x] Has no changes to JSDoc which cause `npm run docs:api` to error - [ ] Reviewed by Two Core Contributors --- index.html | 1 + sandbox/transient-button.html.example | 121 +++++++++++++++++++++ src/css/components/_transient-button.scss | 48 +++++++++ src/css/video-js.scss | 1 + src/js/player.js | 1 + src/js/transient-button.js | 124 ++++++++++++++++++++++ test/unit/transient-button.test.js | 89 ++++++++++++++++ 7 files changed, 385 insertions(+) create mode 100644 sandbox/transient-button.html.example create mode 100644 src/css/components/_transient-button.scss create mode 100644 src/js/transient-button.js create mode 100644 test/unit/transient-button.test.js diff --git a/index.html b/index.html index 328298c249..17d6b7f827 100644 --- a/index.html +++ b/index.html @@ -31,6 +31,7 @@

Navigation

  • noUITitleAttributes Demo
  • Document Picture-In-Picture Demo
  • Skip Buttons demo
  • +
  • Transient Button demo
  • Videojs debug build test page
  • diff --git a/sandbox/transient-button.html.example b/sandbox/transient-button.html.example new file mode 100644 index 0000000000..2006abb13d --- /dev/null +++ b/sandbox/transient-button.html.example @@ -0,0 +1,121 @@ + + + + + Video.js Sandbox + + + + + +
    +

    Transient button demo

    + + + + + + +
    +

    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(); +});