1
0
mirror of https://github.com/videojs/video.js.git synced 2025-02-08 12:05:47 +02:00

feat: Audio Only Mode (#7647)

* audioOnlyMode wip

* fix incorrect logs

* add tests

* minor code changes and add another test

* update docs

* fix formatting

* fix typo

* Consolidate conditions

Co-authored-by: Pat O'Neill <pgoneill@gmail.com>

* Compare objects instead of name string

Co-authored-by: Pat O'Neill <pgoneill@gmail.com>

* code review changes

* remove unnecessary equivalence check

Co-authored-by: Gary Katsevman <git@gkatsev.com>

* replace height() with currentHeight()

Co-authored-by: Gary Katsevman <git@gkatsev.com>

* rewrite for async pip and fs handling

* asyncify tests

* update doc

* add test

Co-authored-by: Pat O'Neill <pgoneill@gmail.com>
Co-authored-by: Gary Katsevman <git@gkatsev.com>
This commit is contained in:
Alex Barstow 2022-03-10 13:13:49 -05:00 committed by GitHub
parent a80307fe0d
commit 762e7bc751
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 408 additions and 4 deletions

View File

@ -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`

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -111,6 +111,10 @@
height: 100%;
}
.video-js.vjs-audio-only-mode .vjs-tech {
display: none;
}
// Fullscreen Styles
body.vjs-full-window {
padding: 0;

View File

@ -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;

View File

@ -25,3 +25,7 @@
font-size: 1.5em;
line-height: inherit;
}
.video-js.vjs-audio-only-mode .vjs-subs-caps-button {
display: none;
}

View File

@ -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
};

View File

@ -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'));
});