diff --git a/docs/guides/options.md b/docs/guides/options.md index 3e27db329b..d1634ef64c 100644 --- a/docs/guides/options.md +++ b/docs/guides/options.md @@ -21,6 +21,7 @@ * [width](#width) * [Video.js-specific Options](#videojs-specific-options) * [aspectRatio](#aspectratio) + * [audioOnlyMode](#audioonlymode) * [audioPosterMode](#audiopostermode) * [autoSetup](#autosetup) * [breakpoints](#breakpoints) @@ -182,6 +183,13 @@ Puts the player in [fluid](#fluid) mode and the value is used when calculating t Alternatively, the classes `vjs-16-9`, `vjs-9-16`, `vjs-4-3` or `vjs-1-1` can be added to the player. +### `audioOnlyMode` + +> Type: `boolean` +> Default: `false` + +If set to true, it asynchronously hides all player components except the control bar, as well as any specific controls that are needed only for video. This option can be set to `true` or `false` by calling `audioOnlyMode([true|false])` at runtime. When used as a setter, it returns a Promise. When used as a getter, it returns a Boolean. + ### `audioPosterMode` > Type: `boolean` diff --git a/src/css/components/_captions.scss b/src/css/components/_captions.scss index 1fe22af244..8cffef9f68 100644 --- a/src/css/components/_captions.scss +++ b/src/css/components/_captions.scss @@ -1,3 +1,7 @@ .video-js .vjs-captions-button .vjs-icon-placeholder { @extend .vjs-icon-captions; } + +.video-js.vjs-audio-only-mode .vjs-captions-button { + display: none; +} diff --git a/src/css/components/_control-bar.scss b/src/css/components/_control-bar.scss index e10de8df1d..8c7d44d63d 100644 --- a/src/css/components/_control-bar.scss +++ b/src/css/components/_control-bar.scss @@ -10,8 +10,9 @@ @include background-color-with-alpha($primary-background-color, $primary-background-transparency); } -// Video has started playing -.vjs-has-started .vjs-control-bar { +// Video has started playing or we are in audioOnlyMode +.vjs-has-started .vjs-control-bar, +.vjs-audio-only-mode .vjs-control-bar { @include display-flex; visibility: visible; opacity: 1; @@ -41,8 +42,9 @@ display: none !important; } -// Don't hide the control bar if it's audio -.vjs-audio.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar { +// Don't hide the control bar if it's audio or in audioOnlyMode +.vjs-audio.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar, +.vjs-audio-only-mode.vjs-has-started.vjs-user-inactive.vjs-playing .vjs-control-bar { opacity: 1; visibility: visible; } diff --git a/src/css/components/_descriptions.scss b/src/css/components/_descriptions.scss index 854a1cbb9c..9cf8d95d6f 100644 --- a/src/css/components/_descriptions.scss +++ b/src/css/components/_descriptions.scss @@ -1,3 +1,7 @@ .video-js .vjs-descriptions-button .vjs-icon-placeholder { @extend .vjs-icon-audio-description; } + +.video-js.vjs-audio-only-mode .vjs-descriptions-button { + display: none; +} diff --git a/src/css/components/_fullscreen.scss b/src/css/components/_fullscreen.scss index c68343833a..a9deba41f1 100644 --- a/src/css/components/_fullscreen.scss +++ b/src/css/components/_fullscreen.scss @@ -6,6 +6,11 @@ @extend .vjs-icon-fullscreen-enter; } } + +.video-js.vjs-audio-only-mode .vjs-fullscreen-control { + display: none; +} + // Switch to the exit icon when the player is in fullscreen .video-js.vjs-fullscreen .vjs-fullscreen-control .vjs-icon-placeholder { @extend .vjs-icon-fullscreen-exit; diff --git a/src/css/components/_layout.scss b/src/css/components/_layout.scss index d6b5a18366..3b2c44e9fb 100644 --- a/src/css/components/_layout.scss +++ b/src/css/components/_layout.scss @@ -111,6 +111,10 @@ height: 100%; } +.video-js.vjs-audio-only-mode .vjs-tech { + display: none; +} + // Fullscreen Styles body.vjs-full-window { padding: 0; diff --git a/src/css/components/_picture-in-picture.scss b/src/css/components/_picture-in-picture.scss index e3426571ef..cfba09b928 100644 --- a/src/css/components/_picture-in-picture.scss +++ b/src/css/components/_picture-in-picture.scss @@ -6,6 +6,11 @@ @extend .vjs-icon-picture-in-picture-enter; } } + +.video-js.vjs-audio-only-mode .vjs-picture-in-picture-control { + display: none; +} + // 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/components/_subs-caps.scss b/src/css/components/_subs-caps.scss index d3b1ab759d..89bc353892 100644 --- a/src/css/components/_subs-caps.scss +++ b/src/css/components/_subs-caps.scss @@ -25,3 +25,7 @@ font-size: 1.5em; line-height: inherit; } + +.video-js.vjs-audio-only-mode .vjs-subs-caps-button { + display: none; +} diff --git a/src/js/player.js b/src/js/player.js index bdefc4848b..ebdeecfbbe 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -395,6 +395,15 @@ class Player extends Component { // Init debugEnabled_ this.debugEnabled_ = false; + // Init state audioOnlyMode_ + this.audioOnlyMode_ = false; + + // Init state audioOnlyCache_ + this.audioOnlyCache_ = { + playerHeight: null, + hiddenChildren: [] + }; + // if the global option object was accidentally blown away by // someone, bail early with an informative error if (!this.options_ || @@ -574,6 +583,7 @@ class Player extends Component { this.breakpoints(this.options_.breakpoints); this.responsive(this.options_.responsive); + this.audioOnlyMode(this.options_.audioOnlyMode); } /** @@ -4293,6 +4303,107 @@ class Player extends Component { return !!this.isAudio_; } + updateAudioOnlyModeState_(value) { + this.audioOnlyMode_ = value; + this.trigger('audioonlymodechange'); + } + + enableAudioOnlyUI_() { + // Update styling immediately to show the control bar so we can get its height + this.addClass('vjs-audio-only-mode'); + + const playerChildren = this.children(); + const controlBar = this.getChild('ControlBar'); + const controlBarHeight = controlBar && controlBar.currentHeight(); + + // Hide all player components except the control bar. Control bar components + // needed only for video are hidden with CSS + playerChildren.forEach(child => { + if (child === controlBar) { + return; + } + + if (child.el_ && !child.hasClass('vjs-hidden')) { + child.hide(); + + this.audioOnlyCache_.hiddenChildren.push(child); + } + }); + + this.audioOnlyCache_.playerHeight = this.currentHeight(); + + // Set the player height the same as the control bar + this.height(controlBarHeight); + this.updateAudioOnlyModeState_(true); + } + + disableAudioOnlyUI_() { + this.removeClass('vjs-audio-only-mode'); + + // Show player components that were previously hidden + this.audioOnlyCache_.hiddenChildren.forEach(child => child.show()); + + // Reset player height + this.height(this.audioOnlyCache_.playerHeight); + this.updateAudioOnlyModeState_(false); + } + + /** + * Get the current audioOnlyMode state or set audioOnlyMode to true or false. + * + * Setting this to `true` will hide all player components except the control bar, + * as well as control bar components needed only for video. + * + * @param {boolean} [value] + * The value to set audioOnlyMode to. + * + * @return {Promise|boolean} + * A Promise is returned when setting the state, and a boolean when getting + * the present state + */ + audioOnlyMode(value) { + if (typeof value !== 'boolean' || value === this.audioOnlyMode_) { + return this.audioOnlyMode_; + } + + const PromiseClass = this.options_.Promise || window.Promise; + + if (PromiseClass) { + // Enable Audio Only Mode + if (value) { + const exitPromises = []; + + // Fullscreen and PiP are not supported in audioOnlyMode, so exit if we need to. + if (this.isInPictureInPicture()) { + exitPromises.push(this.exitPictureInPicture()); + } + + if (this.isFullscreen()) { + exitPromises.push(this.exitFullscreen()); + } + + return PromiseClass.all(exitPromises).then(() => this.enableAudioOnlyUI_()); + } + + // Disable Audio Only Mode + return PromiseClass.resolve().then(() => this.disableAudioOnlyUI_()); + } + + if (value) { + if (this.isInPictureInPicture()) { + this.exitPictureInPicture(); + } + + if (this.isFullscreen()) { + this.exitFullscreen(); + } + + this.enableAudioOnlyUI_(); + } else { + this.disableAudioOnlyUI_(); + } + } + /** * Get the current audioPosterMode state or set audioPosterMode to true or false * @@ -5131,6 +5242,7 @@ Player.prototype.options_ = { breakpoints: {}, responsive: false, + audioOnlyMode: false, audioPosterMode: false }; diff --git a/test/unit/player.test.js b/test/unit/player.test.js index 851ce56570..e368c550a3 100644 --- a/test/unit/player.test.js +++ b/test/unit/player.test.js @@ -2132,6 +2132,7 @@ QUnit.test('Make sure that player\'s style el respects VIDEOJS_NO_DYNAMIC_STYLE QUnit.test('When VIDEOJS_NO_DYNAMIC_STYLE is set, apply sizing directly to the tech el', function(assert) { // clear the HEAD before running this test + const originalVjsNoDynamicStyling = window.VIDEOJS_NO_DYNAMIC_STYLE; const styles = document.querySelectorAll('style'); let i = styles.length; @@ -2161,6 +2162,7 @@ QUnit.test('When VIDEOJS_NO_DYNAMIC_STYLE is set, apply sizing directly to the t assert.equal(player.tech_.el().width, 600, 'the width is equal to 600'); assert.equal(player.tech_.el().height, 300, 'the height is equal 300'); player.dispose(); + window.VIDEOJS_NO_DYNAMIC_STYLE = originalVjsNoDynamicStyling; }); QUnit.test('should allow to register custom player when any player has not been created', function(assert) { @@ -2803,3 +2805,257 @@ QUnit.test('playbackRates only accepts arrays of numbers', function(assert) { player.dispose(); }); + +QUnit.test('audioOnlyMode can be set by option', function(assert) { + assert.expect(4); + + const player = TestHelpers.makePlayer({audioOnlyMode: true}); + + player.one('audioonlymodechange', () => { + assert.equal(player.audioOnlyMode(), true, 'asynchronously set via option'); + assert.equal(player.hasClass('vjs-audio-only-mode'), true, 'class added asynchronously'); + }); + + assert.equal(player.audioOnlyMode(), false, 'defaults to false'); + assert.equal(player.hasClass('vjs-audio-only-mode'), false, 'initially does not have class'); +}); + +QUnit.test('audioOnlyMode(true) returns Promise when promises are supported', function(assert) { + const player = TestHelpers.makePlayer({}); + const returnValTrue = player.audioOnlyMode(true); + + if (window.Promise) { + assert.ok(returnValTrue instanceof window.Promise, 'audioOnlyMode(true) returns Promise when supported'); + } + + return returnValTrue; +}); + +QUnit.test('audioOnlyMode(false) returns Promise when promises are supported', function(assert) { + const player = TestHelpers.makePlayer({audioOnlyMode: true}); + + player.one('audioonlymodechange', () => { + const returnValFalse = player.audioOnlyMode(false); + + if (window.Promise) { + assert.ok(returnValFalse instanceof window.Promise, 'audioOnlyMode(false) returns Promise when supported'); + } + + return returnValFalse; + }); +}); + +QUnit.test('audioOnlyMode() getter returns Boolean', function(assert) { + const player = TestHelpers.makePlayer({}); + + assert.ok(typeof player.audioOnlyMode() === 'boolean', 'getter correctly returns boolean'); +}); + +QUnit.test('audioOnlyMode(true/false) is synchronous and returns undefined when promises are unsupported', function(assert) { + const originalPromise = window.Promise; + const player = TestHelpers.makePlayer({}); + + window.Promise = undefined; + + const returnValTrue = player.audioOnlyMode(true); + + assert.equal(returnValTrue, undefined, 'return value is undefined'); + assert.ok(player.audioOnlyMode(), 'state synchronously set to true'); + + const returnValFalse = player.audioOnlyMode(false); + + assert.equal(returnValFalse, undefined, 'return value is undefined'); + assert.notOk(player.audioOnlyMode(), 'state synchronously set to false'); + + window.Promise = originalPromise; +}); + +QUnit.test('audioOnlyMode() gets the correct audioOnlyMode state', function(assert) { + const player = TestHelpers.makePlayer({}); + + assert.equal(player.audioOnlyMode(), false, 'defaults to false'); + + return player.audioOnlyMode(true) + .then(() => assert.equal(player.audioOnlyMode(), true, 'returns updated state after enabled')) + .then(() => player.audioOnlyMode(false)) + .then(() => assert.equal(player.audioOnlyMode(), false, 'returns updated state after disabled')) + .catch(() => assert.ok(false, 'test error')); +}); + +QUnit.test('audioOnlyMode(true/false) adds or removes vjs-audio-only-mode class to player', function(assert) { + const player = TestHelpers.makePlayer({}); + + assert.equal(player.hasClass('vjs-audio-only-mode'), false, 'class not initially present'); + + return player.audioOnlyMode(true) + .then(() => assert.equal(player.hasClass('vjs-audio-only-mode'), true, 'class was added')) + .then(() => player.audioOnlyMode(false)) + .then(() => assert.equal(player.hasClass('vjs-audio-only-mode'), false, 'class was removed')) + .catch(() => assert.ok(false, 'test error')); +}); + +QUnit.test('setting audioOnlyMode() triggers audioonlymodechange event', function(assert) { + const player = TestHelpers.makePlayer({}); + let audioOnlyModeState = false; + let audioOnlyModeChangeEvents = 0; + + player.on('audioonlymodechange', () => { + audioOnlyModeChangeEvents++; + audioOnlyModeState = player.audioOnlyMode(); + }); + + return player.audioOnlyMode(true) + .then(() => { + assert.equal(audioOnlyModeState, true, 'state is correct'); + assert.equal(audioOnlyModeChangeEvents, 1, 'event fired once'); + }) + .then(() => player.audioOnlyMode(false)) + .then(() => { + assert.equal(audioOnlyModeState, false, 'state is correct'); + assert.equal(audioOnlyModeChangeEvents, 2, 'event fired again'); + }) + .catch(() => assert.ok(false, 'test error')); +}); + +QUnit.test('audioOnlyMode(true/false) changes player height', function(assert) { + const player = TestHelpers.makePlayer({controls: true, height: 600}); + + player.hasStarted(true); + + const controlBarHeight = player.getChild('ControlBar').currentHeight(); + const playerHeight = player.currentHeight(); + + assert.notEqual(playerHeight, controlBarHeight, 'heights are not the same'); + assert.equal(player.currentHeight(), playerHeight, 'player initial height is correct'); + + return player.audioOnlyMode(true) + .then(() => assert.equal(player.currentHeight(), controlBarHeight, 'player height set to height of control bar in audioOnlyMode')) + .then(() => player.audioOnlyMode(false)) + .then(() => assert.equal(player.currentHeight(), playerHeight, 'player reset to original height when disabling audioOnlyMode')) + .catch(() => assert.ok(false, 'test error')); +}); + +QUnit.test('audioOnlyMode(true/false) hides/shows player components except control bar', function(assert) { + const player = TestHelpers.makePlayer({controls: true}); + + player.hasStarted(true); + + assert.equal(TestHelpers.getComputedStyle(player.getChild('TextTrackDisplay').el_, 'display'), 'block', 'TextTrackDisplay is initially visible'); + assert.equal(TestHelpers.getComputedStyle(player.tech(true).el_, 'display'), 'block', 'Tech is initially visible'); + assert.equal(TestHelpers.getComputedStyle(player.getChild('ControlBar').el_, 'display'), 'flex', 'ControlBar is initially visible'); + + return player.audioOnlyMode(true) + .then(() => { + assert.equal(TestHelpers.getComputedStyle(player.getChild('TextTrackDisplay').el_, 'display'), 'none', 'TextTrackDisplay is hidden'); + assert.equal(TestHelpers.getComputedStyle(player.tech(true).el_, 'display'), 'none', 'Tech is hidden'); + assert.equal(TestHelpers.getComputedStyle(player.getChild('ControlBar').el_, 'display'), 'flex', 'ControlBar is still visible'); + + // Sanity check that all non-ControlBar player children are hidden + player.children().forEach(child => { + const el = child.el_; + + if (el) { + if (child.name_ !== 'ControlBar') { + assert.equal(TestHelpers.getComputedStyle(child.el_, 'display') === 'none', true, 'non-controlBar component is hidden'); + } + } + }); + }) + .then(() => player.audioOnlyMode(false)) + .then(() => { + assert.equal(TestHelpers.getComputedStyle(player.getChild('TextTrackDisplay').el_, 'display'), 'block', 'TextTrackDisplay is visible again'); + assert.equal(TestHelpers.getComputedStyle(player.tech(true).el_, 'display'), 'block', 'Tech is visible again'); + assert.equal(TestHelpers.getComputedStyle(player.getChild('ControlBar').el_, 'display'), 'flex', 'ControlBar is still visible'); + }) + .catch(() => assert.ok(false, 'test error')); +}); + +QUnit.test('audioOnlyMode(true/false) hides/shows video-specific control bar components', function(assert) { + const tracks = ['captions', 'subtitles', 'descriptions', 'chapters'].map(kind => { + return { + kind, + label: 'English' + }; + }); + const player = TestHelpers.makePlayer({controls: true, tracks, playbackRates: [1, 2]}); + + this.clock.tick(1000); + + const controlBar = player.getChild('ControlBar'); + const childrenShownInAudioOnlyMode = [ + 'PlayToggle', + 'VolumePanel', + 'ProgressControl', + 'PlaybackRateMenuButton', + 'ChaptersButton', + 'RemainingTimeDisplay' + ]; + const childrenHiddenInAudioOnlyMode = [ + 'CaptionsButton', + 'DescriptionsButton', + 'FullscreenToggle', + 'PictureInPictureToggle', + 'SubsCapsButton' + ]; + + const allChildren = childrenShownInAudioOnlyMode.concat(childrenHiddenInAudioOnlyMode); + + const chapters = player.textTracks()[3]; + + chapters.addCue({ + startTime: 0, + endTime: 2, + text: 'Chapter 1' + }); + chapters.addCue({ + startTime: 2, + endTime: 4, + text: 'Chapter 2' + }); + + // ChaptersButton only shows once cues added and update() called + controlBar.getChild('ChaptersButton').update(); + + player.hasStarted(true); + + // Show all control bar children + allChildren.forEach(child => { + const el = controlBar.getChild(child) && controlBar.getChild(child).el_; + + if (el) { + // Sanity check that component is showing + assert.notEqual(TestHelpers.getComputedStyle(el, 'display'), 'none', `${child} is initially visible`); + } + }); + + return player.audioOnlyMode(true) + .then(() => { + childrenHiddenInAudioOnlyMode.forEach(child => { + const el = controlBar.getChild(child) && controlBar.getChild(child).el_; + + if (el) { + assert.equal(TestHelpers.getComputedStyle(el, 'display'), 'none', `${child} is hidden`); + } + }); + + childrenShownInAudioOnlyMode.forEach(child => { + const el = controlBar.getChild(child) && controlBar.getChild(child).el_; + + if (el) { + assert.notEqual(TestHelpers.getComputedStyle(el, 'display'), 'none', `${child} is still shown`); + } + }); + }) + .then(() => player.audioOnlyMode(false)) + .then(() => { + // Check that all are showing again + allChildren.concat(childrenHiddenInAudioOnlyMode).forEach(child => { + const el = controlBar.getChild(child) && controlBar.getChild(child).el_; + + if (el) { + assert.notEqual(TestHelpers.getComputedStyle(el, 'display'), 'none', `${child} is shown`); + } + }); + }) + .catch(() => assert.ok(false, 'test error')); +});