From 116d84af75ae06c32f9e8135a899acb1eb77a876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Beaufort?= Date: Tue, 18 Jun 2019 22:42:02 +0200 Subject: [PATCH] feat: add built-in Picture-in-Picture button (#6002) Adds a new PictureInPictureToggle component in the controls bar of the player. It depends on videojs-font 3.2.0 (videojs/font#41) for icons. Final spec piece from #5824. --- docs/guides/components.md | 1 + docs/legacy-docs/guides/components.html | 1 + package-lock.json | 6 +- package.json | 2 +- src/css/components/_picture-in-picture.scss | 12 +++ src/css/video-js.scss | 1 + src/js/control-bar/control-bar.js | 2 + .../control-bar/picture-in-picture-toggle.js | 93 +++++++++++++++++++ test/api/api.js | 1 + test/unit/controls.test.js | 17 ++++ 10 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/css/components/_picture-in-picture.scss create mode 100644 src/js/control-bar/picture-in-picture-toggle.js diff --git a/docs/guides/components.md b/docs/guides/components.md index a1c3c3f056..391a2b9c42 100644 --- a/docs/guides/components.md +++ b/docs/guides/components.md @@ -312,6 +312,7 @@ Player │ ├── SubtitlesButton (hidden, unless there are relevant tracks) │ ├── CaptionsButton (hidden, unless there are relevant tracks) │ ├── AudioTrackButton (hidden, unless there are relevant tracks) +│ └── PictureInPictureToggle │ └── FullscreenToggle ├── ErrorDisplay (hidden, until there is an error) ├── TextTrackSettings diff --git a/docs/legacy-docs/guides/components.html b/docs/legacy-docs/guides/components.html index c628da640f..860a7f72ec 100644 --- a/docs/legacy-docs/guides/components.html +++ b/docs/legacy-docs/guides/components.html @@ -53,6 +53,7 @@

Components

ChaptersButton (Hidden by default) SubtitlesButton (Hidden by default) CaptionsButton (Hidden by default) + PictureInPictureToggle FullscreenToggle ErrorDisplay TextTrackSettings diff --git a/package-lock.json b/package-lock.json index 907d8d89d9..3c973bd982 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14556,9 +14556,9 @@ } }, "videojs-font": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.1.1.tgz", - "integrity": "sha512-oozseAn5cVko/EobvXmk7MSlH14ujOmuf0rpXUEwifQLobIbQISZWME0RG3ALt//vnvvkVekuL7H2WkhCO7ynw==" + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/videojs-font/-/videojs-font-3.2.0.tgz", + "integrity": "sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA==" }, "videojs-generate-karma-config": { "version": "5.2.0", diff --git a/package.json b/package.json index 63211ce594..77a6017cb3 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "keycode": "^2.2.0", "safe-json-parse": "4.0.0", "tsml": "1.0.1", - "videojs-font": "3.1.1", + "videojs-font": "3.2.0", "videojs-vtt.js": "^0.14.1", "xhr": "2.4.0" }, diff --git a/src/css/components/_picture-in-picture.scss b/src/css/components/_picture-in-picture.scss new file mode 100644 index 0000000000..e3426571ef --- /dev/null +++ b/src/css/components/_picture-in-picture.scss @@ -0,0 +1,12 @@ +.video-js .vjs-picture-in-picture-control { + cursor: pointer; + @include flex(none); + + & .vjs-icon-placeholder { + @extend .vjs-icon-picture-in-picture-enter; + } +} +// Switch to the exit icon when the player is in Picture-in-Picture +.video-js.vjs-picture-in-picture .vjs-picture-in-picture-control .vjs-icon-placeholder { + @extend .vjs-icon-picture-in-picture-exit; +} diff --git a/src/css/video-js.scss b/src/css/video-js.scss index 5378e16735..330e06114e 100644 --- a/src/css/video-js.scss +++ b/src/css/video-js.scss @@ -28,6 +28,7 @@ @import "components/time"; @import "components/play-pause"; @import "components/text-track"; +@import "components/picture-in-picture"; @import "components/fullscreen"; @import "components/playback-rate"; @import "components/error"; diff --git a/src/js/control-bar/control-bar.js b/src/js/control-bar/control-bar.js index 0f2c5f638a..4c55522e49 100644 --- a/src/js/control-bar/control-bar.js +++ b/src/js/control-bar/control-bar.js @@ -12,6 +12,7 @@ import './time-controls/remaining-time-display.js'; import './live-display.js'; import './seek-to-live.js'; import './progress-control/progress-control.js'; +import './picture-in-picture-toggle.js'; import './fullscreen-toggle.js'; import './volume-panel.js'; import './text-track-controls/chapters-button.js'; @@ -67,6 +68,7 @@ ControlBar.prototype.options_ = { 'descriptionsButton', 'subsCapsButton', 'audioTrackButton', + 'pictureInPictureToggle', 'fullscreenToggle' ] }; diff --git a/src/js/control-bar/picture-in-picture-toggle.js b/src/js/control-bar/picture-in-picture-toggle.js new file mode 100644 index 0000000000..4c47d1cea4 --- /dev/null +++ b/src/js/control-bar/picture-in-picture-toggle.js @@ -0,0 +1,93 @@ +/** + * @file picture-in-picture-toggle.js + */ +import Button from '../button.js'; +import Component from '../component.js'; +import document from 'global/document'; + +/** + * Toggle Picture-in-Picture mode + * + * @extends Button + */ +class PictureInPictureToggle extends Button { + + /** + * 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.on(player, 'pictureinpicturechange', this.handlePictureInPictureChange); + + // TODO: Activate button on player loadedmetadata event. + // TODO: Deactivate button on player emptied event. + // TODO: Deactivate button if disablepictureinpicture attribute is present. + if (!document.pictureInPictureEnabled) { + this.disable(); + } + } + + /** + * Builds the default DOM `className`. + * + * @return {string} + * The DOM `className` for this object. + */ + buildCSSClass() { + return `vjs-picture-in-picture-control ${super.buildCSSClass()}`; + } + + /** + * Handles pictureinpicturechange on the player and change control text accordingly. + * + * @param {EventTarget~Event} [event] + * The {@link Player#pictureinpicturechange} event that caused this function to be + * called. + * + * @listens Player#pictureinpicturechange + */ + handlePictureInPictureChange(event) { + if (this.player_.isInPictureInPicture()) { + this.controlText('Exit Picture-in-Picture'); + } else { + this.controlText('Picture-in-Picture'); + } + } + + /** + * This gets called when an `PictureInPictureToggle` is "clicked". See + * {@link ClickableComponent} for more detailed information on what a click can be. + * + * @param {EventTarget~Event} [event] + * The `keydown`, `tap`, or `click` event that caused this function to be + * called. + * + * @listens tap + * @listens click + */ + handleClick(event) { + if (!this.player_.isInPictureInPicture()) { + this.player_.requestPictureInPicture(); + } else { + this.player_.exitPictureInPicture(); + } + } + +} + +/** + * The text that should display over the `PictureInPictureToggle`s controls. Added for localization. + * + * @type {string} + * @private + */ +PictureInPictureToggle.prototype.controlText_ = 'Picture-in-Picture'; + +Component.registerComponent('PictureInPictureToggle', PictureInPictureToggle); +export default PictureInPictureToggle; diff --git a/test/api/api.js b/test/api/api.js index 44344a5ee9..82159457d3 100644 --- a/test/api/api.js +++ b/test/api/api.js @@ -165,6 +165,7 @@ QUnit.test('should export useful components to the public', function(assert) { assert.ok(videojs.getComponent('ControlBar'), 'ControlBar should be public'); assert.ok(videojs.getComponent('Button'), 'Button should be public'); assert.ok(videojs.getComponent('PlayToggle'), 'PlayToggle should be public'); + assert.ok(videojs.getComponent('PictureInPictureToggle'), 'PictureInPictureToggle should be public'); assert.ok(videojs.getComponent('FullscreenToggle'), 'FullscreenToggle should be public'); assert.ok(videojs.getComponent('BigPlayButton'), 'BigPlayButton should be public'); assert.ok(videojs.getComponent('LoadingSpinner'), 'LoadingSpinner should be public'); diff --git a/test/unit/controls.test.js b/test/unit/controls.test.js index 88a06e1549..f618a58cb0 100644 --- a/test/unit/controls.test.js +++ b/test/unit/controls.test.js @@ -5,6 +5,7 @@ import VolumeBar from '../../src/js/control-bar/volume-control/volume-bar.js'; import PlayToggle from '../../src/js/control-bar/play-toggle.js'; import PlaybackRateMenuButton from '../../src/js/control-bar/playback-rate-menu/playback-rate-menu-button.js'; import Slider from '../../src/js/slider/slider.js'; +import PictureInPictureToggle from '../../src/js/control-bar/picture-in-picture-toggle.js'; import FullscreenToggle from '../../src/js/control-bar/fullscreen-toggle.js'; import ControlBar from '../../src/js/control-bar/control-bar.js'; import TestHelpers from './test-helpers.js'; @@ -152,6 +153,22 @@ QUnit.test('should hide playback rate control if it\'s not supported', function( playbackRate.dispose(); }); +QUnit.test('Picture-in-Picture control text should be correct when pictureinpicturechange is triggered', function(assert) { + const player = TestHelpers.makePlayer(); + const pictureInPictureToggle = new PictureInPictureToggle(player); + + player.isInPictureInPicture(true); + player.trigger('pictureinpicturechange'); + assert.equal(pictureInPictureToggle.controlText(), 'Exit Picture-in-Picture', 'Control Text is correct while switching to Picture-in-Picture mode'); + + player.isInPictureInPicture(false); + player.trigger('pictureinpicturechange'); + assert.equal(pictureInPictureToggle.controlText(), 'Picture-in-Picture', 'Control Text is correct while switching back to normal mode'); + + player.dispose(); + pictureInPictureToggle.dispose(); +}); + QUnit.test('Fullscreen control text should be correct when fullscreenchange is triggered', function(assert) { const player = TestHelpers.makePlayer(); const fullscreentoggle = new FullscreenToggle(player);