commit
e644eeb5b6
13
changelog.md
13
changelog.md
@ -1,3 +1,16 @@
|
||||
## v3.5.4
|
||||
|
||||
- Added: Set download URL via new setter
|
||||
- Improvement: The order of the `controls` option now effects the order in the DOM - i.e. you can re-order the controls - Note: this may break any custom CSS you have setup. Please see the changes in the PR to the default SASS
|
||||
- Fixed issue with empty controls and preview thumbs
|
||||
- Fixed issue with setGutter call (from Sentry)
|
||||
- Fixed issue with initial selected speed not working
|
||||
- Added notes on `autoplay` config option and browser compatibility
|
||||
- Fixed issue with ads volume not matching current content volume
|
||||
- Fixed race condition where ads were loading during source change
|
||||
- Improvement: Automatic aspect ratio for YouTube is now supported, meaning all aspect ratios are set based on media content - Note: we're now using a different API to get YouTube video metadata so you may need to adjust any CSPs you have setup
|
||||
- Fix for menu in the Shadow DOM (thanks @emielbeinema)
|
||||
|
||||
## v3.5.3
|
||||
|
||||
- Improved the usage of the `ratio` config option; it now works as expected and for all video types. The default has not changed, it is to dynamically, where possible (except YouTube where 16:9 is used) determine the ratio from the media source so this is not a breaking change.
|
||||
|
@ -265,7 +265,7 @@
|
||||
|
||||
<!-- Polyfills -->
|
||||
<script
|
||||
src="https://cdn.polyfill.io/v2/polyfill.min.js?features=es6,Array.prototype.includes,CustomEvent,Object.entries,Object.values,URL,Math.trunc"
|
||||
src="https://cdn.polyfill.io/v3/polyfill.min.js?features=es6,Array.prototype.includes,CustomEvent,Object.entries,Object.values,URL,Math.trunc&flags=gated"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plyr",
|
||||
"version": "3.5.3",
|
||||
"version": "3.5.4",
|
||||
"description": "A simple, accessible and customizable HTML5, YouTube and Vimeo media player",
|
||||
"homepage": "https://plyr.io",
|
||||
"author": "Sam Potts <sam@potts.es>",
|
||||
@ -78,7 +78,7 @@
|
||||
"stylelint": "^9.10.1",
|
||||
"stylelint-config-prettier": "^5.0.0",
|
||||
"stylelint-config-recommended": "^2.1.0",
|
||||
"stylelint-config-sass-guidelines": "^5.3.0",
|
||||
"stylelint-config-sass-guidelines": "^5.4.0",
|
||||
"stylelint-order": "^2.2.1",
|
||||
"stylelint-scss": "^3.5.4",
|
||||
"stylelint-selector-bem-pattern": "^2.1.0",
|
||||
|
@ -275,7 +275,7 @@ Note the single quotes encapsulating the JSON and double quotes on the object ke
|
||||
| `iconUrl` | String | `null` | Specify a URL or path to the SVG sprite. See the [SVG section](#svg) for more info. |
|
||||
| `iconPrefix` | String | `plyr` | Specify the id prefix for the icons used in the default controls (e.g. "plyr-play" would be "plyr"). This is to prevent clashes if you're using your own SVG sprite but with the default controls. Most people can ignore this option. |
|
||||
| `blankVideo` | String | `https://cdn.plyr.io/static/blank.mp4` | Specify a URL or path to a blank video file used to properly cancel network requests. |
|
||||
| `autoplay` | Boolean | `false` | Autoplay the media on load. This is generally advised against on UX grounds. It is also disabled by default in some browsers. If the `autoplay` attribute is present on a `<video>` or `<audio>` element, this will be automatically set to true. |
|
||||
| `autoplay`² | Boolean | `false` | Autoplay the media on load. If the `autoplay` attribute is present on a `<video>` or `<audio>` element, this will be automatically set to true. |
|
||||
| `autopause`¹ | Boolean | `true` | Only allow one player playing at once. |
|
||||
| `seekTime` | Number | `10` | The time, in seconds, to seek when a user hits fast forward or rewind. |
|
||||
| `volume` | Number | `1` | A number, between 0 and 1, representing the initial volume of the player. |
|
||||
@ -305,6 +305,11 @@ Note the single quotes encapsulating the JSON and double quotes on the object ke
|
||||
| `previewThumbnails` | Object | `{ enabled: false, src: '' }` | `enabled`: Whether to enable the preview thumbnails (they must be generated by you). `src` must be either a string or an array of strings representing URLs for the VTT files containing the image URL(s). Learn more about [preview thumbnails](#preview-thumbnails) below. |
|
||||
|
||||
1. Vimeo only
|
||||
2. Autoplay is generally not recommended as it is seen as a negative user experience. It is also disabled in many browsers. Before raising issues, do your homework. More info can be found here:
|
||||
|
||||
- https://webkit.org/blog/6784/new-video-policies-for-ios/
|
||||
- https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
|
||||
- https://hacks.mozilla.org/2019/02/firefox-66-to-block-automatically-playing-audible-video-and-audio/
|
||||
|
||||
# API
|
||||
|
||||
@ -406,6 +411,7 @@ player.fullscreen.active; // false;
|
||||
| `fullscreen.enabled` | ✓ | - | Returns a boolean indicating if the current player has fullscreen enabled. |
|
||||
| `pip`¹ | ✓ | ✓ | Gets or sets the picture-in-picture state of the player. The setter accepts a boolean. This currently only supported on Safari 10+ (on MacOS Sierra+ and iOS 10+) and Chrome 70+. |
|
||||
| `ratio` | ✓ | ✓ | Gets or sets the video aspect ratio. The setter accepts a string in the same format as the `ratio` option. |
|
||||
| `download` | ✓ | ✓ | Gets or sets the URL for the download button. The setter accepts a string containing a valid absolute URL. |
|
||||
|
||||
1. HTML5 only
|
||||
|
||||
|
@ -124,19 +124,21 @@ const captions = {
|
||||
|
||||
// Handle tracks (add event listener and "pseudo"-default)
|
||||
if (this.isHTML5 && this.isVideo) {
|
||||
tracks.filter(track => !meta.get(track)).forEach(track => {
|
||||
this.debug.log('Track added', track);
|
||||
// Attempt to store if the original dom element was "default"
|
||||
meta.set(track, {
|
||||
default: track.mode === 'showing',
|
||||
tracks
|
||||
.filter(track => !meta.get(track))
|
||||
.forEach(track => {
|
||||
this.debug.log('Track added', track);
|
||||
// Attempt to store if the original dom element was "default"
|
||||
meta.set(track, {
|
||||
default: track.mode === 'showing',
|
||||
});
|
||||
|
||||
// Turn off native caption rendering to avoid double captions
|
||||
track.mode = 'hidden';
|
||||
|
||||
// Add event listener for cue changes
|
||||
on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
|
||||
});
|
||||
|
||||
// Turn off native caption rendering to avoid double captions
|
||||
track.mode = 'hidden';
|
||||
|
||||
// Add event listener for cue changes
|
||||
on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
|
||||
});
|
||||
}
|
||||
|
||||
// Update language first time it matches, or if the previous matching track was removed
|
||||
@ -300,10 +302,12 @@ const captions = {
|
||||
const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default);
|
||||
const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));
|
||||
let track;
|
||||
|
||||
languages.every(language => {
|
||||
track = sorted.find(track => track.language === language);
|
||||
return !track; // Break iteration if there is a match
|
||||
});
|
||||
|
||||
// If no match is found but is required, get first
|
||||
return track || (force ? sorted[0] : undefined);
|
||||
},
|
||||
@ -360,6 +364,7 @@ const captions = {
|
||||
// Get cues from track
|
||||
if (!cues) {
|
||||
const track = captions.getCurrentTrack.call(this);
|
||||
|
||||
cues = Array.from((track || {}).activeCues || [])
|
||||
.map(cue => cue.getCueAsHTML())
|
||||
.map(getHTML);
|
||||
|
@ -61,7 +61,7 @@ const defaults = {
|
||||
// Sprite (for icons)
|
||||
loadSprite: true,
|
||||
iconPrefix: 'plyr',
|
||||
iconUrl: 'https://cdn.plyr.io/3.5.3/plyr.svg',
|
||||
iconUrl: 'https://cdn.plyr.io/3.5.2/plyr.svg',
|
||||
|
||||
// Blank video (used to prevent errors on source change)
|
||||
blankVideo: 'https://cdn.plyr.io/static/blank.mp4',
|
||||
@ -195,8 +195,7 @@ const defaults = {
|
||||
},
|
||||
youtube: {
|
||||
sdk: 'https://www.youtube.com/iframe_api',
|
||||
api:
|
||||
'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title))&part=snippet',
|
||||
api: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v={0}', // 'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title),fileDetails)&part=snippet',
|
||||
},
|
||||
googleIMA: {
|
||||
sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js',
|
||||
@ -320,9 +319,6 @@ const defaults = {
|
||||
progress: '.plyr__progress',
|
||||
captions: '.plyr__captions',
|
||||
caption: '.plyr__caption',
|
||||
menu: {
|
||||
quality: '.js-plyr__menu__list--quality',
|
||||
},
|
||||
},
|
||||
|
||||
// Class hooks added to the player in different states
|
||||
@ -396,11 +392,6 @@ const defaults = {
|
||||
},
|
||||
},
|
||||
|
||||
// API keys
|
||||
keys: {
|
||||
google: null,
|
||||
},
|
||||
|
||||
// Advertisements plugin
|
||||
// Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio
|
||||
ads: {
|
||||
|
618
src/js/controls.js
vendored
618
src/js/controls.js
vendored
@ -10,20 +10,7 @@ import support from './support';
|
||||
import { repaint, transitionEndEvent } from './utils/animation';
|
||||
import { dedupe } from './utils/arrays';
|
||||
import browser from './utils/browser';
|
||||
import {
|
||||
createElement,
|
||||
emptyElement,
|
||||
getAttributesFromSelector,
|
||||
getElement,
|
||||
getElements,
|
||||
hasClass,
|
||||
matches,
|
||||
removeElement,
|
||||
setAttributes,
|
||||
setFocus,
|
||||
toggleClass,
|
||||
toggleHidden,
|
||||
} from './utils/elements';
|
||||
import { createElement, emptyElement, getAttributesFromSelector, getElement, getElements, hasClass, matches, removeElement, setAttributes, setFocus, toggleClass, toggleHidden } from './utils/elements';
|
||||
import { off, on } from './utils/events';
|
||||
import i18n from './utils/i18n';
|
||||
import is from './utils/is';
|
||||
@ -172,7 +159,7 @@ const controls = {
|
||||
|
||||
// Create a <button>
|
||||
createButton(buttonType, attr) {
|
||||
const attributes = Object.assign({}, attr);
|
||||
const attributes = extend({}, attr);
|
||||
let type = toCamelCase(buttonType);
|
||||
|
||||
const props = {
|
||||
@ -198,8 +185,10 @@ const controls = {
|
||||
|
||||
// Set class name
|
||||
if (Object.keys(attributes).includes('class')) {
|
||||
if (!attributes.class.includes(this.config.classNames.control)) {
|
||||
attributes.class += ` ${this.config.classNames.control}`;
|
||||
if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) {
|
||||
extend(attributes, {
|
||||
class: `${attributes.class} ${this.config.classNames.control}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
attributes.class = this.config.classNames.control;
|
||||
@ -377,13 +366,13 @@ const controls = {
|
||||
},
|
||||
|
||||
// Create time display
|
||||
createTime(type) {
|
||||
const attributes = getAttributesFromSelector(this.config.selectors.display[type]);
|
||||
createTime(type, attrs) {
|
||||
const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);
|
||||
|
||||
const container = createElement(
|
||||
'div',
|
||||
extend(attributes, {
|
||||
class: `${this.config.classNames.display.time} ${attributes.class ? attributes.class : ''}`.trim(),
|
||||
class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(),
|
||||
'aria-label': i18n.get(type, this.config),
|
||||
}),
|
||||
'00:00',
|
||||
@ -1138,7 +1127,10 @@ const controls = {
|
||||
} else if (is.keyboardEvent(input) && input.which === 27) {
|
||||
show = false;
|
||||
} else if (is.event(input)) {
|
||||
const isMenuItem = popup.contains(input.target);
|
||||
// If Plyr is in a shadowDOM, the event target is set to the component, instead of the
|
||||
// Element in the shadowDOM. The path, if available, is complete.
|
||||
const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target;
|
||||
const isMenuItem = popup.contains(target);
|
||||
|
||||
// If the click was inside the menu or if the click
|
||||
// wasn't the button or menu item and we're trying to
|
||||
@ -1191,7 +1183,7 @@ const controls = {
|
||||
|
||||
// Show a panel in the menu
|
||||
showMenuPanel(type = '', tabFocus = false) {
|
||||
const target = document.getElementById(`plyr-settings-${this.id}-${type}`);
|
||||
const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`);
|
||||
|
||||
// Nothing to show, bail
|
||||
if (!is.element(target)) {
|
||||
@ -1244,8 +1236,8 @@ const controls = {
|
||||
controls.focusFirstMenuItem.call(this, target, tabFocus);
|
||||
},
|
||||
|
||||
// Set the download link
|
||||
setDownloadLink() {
|
||||
// Set the download URL
|
||||
setDownloadUrl() {
|
||||
const button = this.elements.buttons.download;
|
||||
|
||||
// Bail if no button
|
||||
@ -1253,324 +1245,356 @@ const controls = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set download link
|
||||
// Set attribute
|
||||
button.setAttribute('href', this.download);
|
||||
},
|
||||
|
||||
// Build the default HTML
|
||||
// TODO: Set order based on order in the config.controls array?
|
||||
create(data) {
|
||||
const {
|
||||
bindMenuItemShortcuts,
|
||||
createButton,
|
||||
createProgress,
|
||||
createRange,
|
||||
createTime,
|
||||
setQualityMenu,
|
||||
setSpeedMenu,
|
||||
showMenuPanel,
|
||||
} = controls;
|
||||
this.elements.controls = null;
|
||||
|
||||
// Larger overlaid play button
|
||||
if (this.config.controls.includes('play-large')) {
|
||||
this.elements.container.appendChild(createButton.call(this, 'play-large'));
|
||||
}
|
||||
|
||||
// Create the container
|
||||
const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
|
||||
this.elements.controls = container;
|
||||
|
||||
// Restart button
|
||||
if (this.config.controls.includes('restart')) {
|
||||
container.appendChild(controls.createButton.call(this, 'restart'));
|
||||
}
|
||||
// Default item attributes
|
||||
const defaultAttributes = { class: 'plyr__controls__item' };
|
||||
|
||||
// Rewind button
|
||||
if (this.config.controls.includes('rewind')) {
|
||||
container.appendChild(controls.createButton.call(this, 'rewind'));
|
||||
}
|
||||
// Loop through controls in order
|
||||
dedupe(this.config.controls).forEach(control => {
|
||||
// Restart button
|
||||
if (control === 'restart') {
|
||||
container.appendChild(createButton.call(this, 'restart', defaultAttributes));
|
||||
}
|
||||
|
||||
// Play/Pause button
|
||||
if (this.config.controls.includes('play')) {
|
||||
container.appendChild(controls.createButton.call(this, 'play'));
|
||||
}
|
||||
// Rewind button
|
||||
if (control === 'rewind') {
|
||||
container.appendChild(createButton.call(this, 'rewind', defaultAttributes));
|
||||
}
|
||||
|
||||
// Fast forward button
|
||||
if (this.config.controls.includes('fast-forward')) {
|
||||
container.appendChild(controls.createButton.call(this, 'fast-forward'));
|
||||
}
|
||||
// Play/Pause button
|
||||
if (control === 'play') {
|
||||
container.appendChild(createButton.call(this, 'play', defaultAttributes));
|
||||
}
|
||||
|
||||
// Progress
|
||||
if (this.config.controls.includes('progress')) {
|
||||
const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
|
||||
// Fast forward button
|
||||
if (control === 'fast-forward') {
|
||||
container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes));
|
||||
}
|
||||
|
||||
// Seek range slider
|
||||
progress.appendChild(
|
||||
controls.createRange.call(this, 'seek', {
|
||||
id: `plyr-seek-${data.id}`,
|
||||
}),
|
||||
);
|
||||
// Progress
|
||||
if (control === 'progress') {
|
||||
const progressContainer = createElement('div', {
|
||||
class: `${defaultAttributes.class} plyr__progress__container`,
|
||||
});
|
||||
|
||||
// Buffer progress
|
||||
progress.appendChild(controls.createProgress.call(this, 'buffer'));
|
||||
const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
|
||||
|
||||
// TODO: Add loop display indicator
|
||||
|
||||
// Seek tooltip
|
||||
if (this.config.tooltips.seek) {
|
||||
const tooltip = createElement(
|
||||
'span',
|
||||
{
|
||||
class: this.config.classNames.tooltip,
|
||||
},
|
||||
'00:00',
|
||||
// Seek range slider
|
||||
progress.appendChild(
|
||||
createRange.call(this, 'seek', {
|
||||
id: `plyr-seek-${data.id}`,
|
||||
}),
|
||||
);
|
||||
|
||||
progress.appendChild(tooltip);
|
||||
this.elements.display.seekTooltip = tooltip;
|
||||
// Buffer progress
|
||||
progress.appendChild(createProgress.call(this, 'buffer'));
|
||||
|
||||
// TODO: Add loop display indicator
|
||||
|
||||
// Seek tooltip
|
||||
if (this.config.tooltips.seek) {
|
||||
const tooltip = createElement(
|
||||
'span',
|
||||
{
|
||||
class: this.config.classNames.tooltip,
|
||||
},
|
||||
'00:00',
|
||||
);
|
||||
|
||||
progress.appendChild(tooltip);
|
||||
this.elements.display.seekTooltip = tooltip;
|
||||
}
|
||||
|
||||
this.elements.progress = progress;
|
||||
progressContainer.appendChild(this.elements.progress);
|
||||
container.appendChild(progressContainer);
|
||||
}
|
||||
|
||||
this.elements.progress = progress;
|
||||
container.appendChild(this.elements.progress);
|
||||
}
|
||||
|
||||
// Media current time display
|
||||
if (this.config.controls.includes('current-time')) {
|
||||
container.appendChild(controls.createTime.call(this, 'currentTime'));
|
||||
}
|
||||
|
||||
// Media duration display
|
||||
if (this.config.controls.includes('duration')) {
|
||||
container.appendChild(controls.createTime.call(this, 'duration'));
|
||||
}
|
||||
|
||||
// Volume controls
|
||||
if (this.config.controls.includes('mute') || this.config.controls.includes('volume')) {
|
||||
const volume = createElement('div', {
|
||||
class: 'plyr__volume',
|
||||
});
|
||||
|
||||
// Toggle mute button
|
||||
if (this.config.controls.includes('mute')) {
|
||||
volume.appendChild(controls.createButton.call(this, 'mute'));
|
||||
// Media current time display
|
||||
if (control === 'current-time') {
|
||||
container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));
|
||||
}
|
||||
|
||||
// Volume range control
|
||||
if (this.config.controls.includes('volume')) {
|
||||
// Set the attributes
|
||||
const attributes = {
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
value: this.config.volume,
|
||||
};
|
||||
// Media duration display
|
||||
if (control === 'duration') {
|
||||
container.appendChild(createTime.call(this, 'duration', defaultAttributes));
|
||||
}
|
||||
|
||||
// Create the volume range slider
|
||||
volume.appendChild(
|
||||
controls.createRange.call(
|
||||
this,
|
||||
'volume',
|
||||
extend(attributes, {
|
||||
id: `plyr-volume-${data.id}`,
|
||||
// Volume controls
|
||||
if (control === 'mute' || control === 'volume') {
|
||||
let { volume } = this.elements;
|
||||
|
||||
// Create the volume container if needed
|
||||
if (!is.element(volume) || !container.contains(volume)) {
|
||||
volume = createElement(
|
||||
'div',
|
||||
extend({}, defaultAttributes, {
|
||||
class: `${defaultAttributes.class} plyr__volume`.trim(),
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
this.elements.volume = volume;
|
||||
this.elements.volume = volume;
|
||||
|
||||
container.appendChild(volume);
|
||||
}
|
||||
|
||||
// Toggle mute button
|
||||
if (control === 'mute') {
|
||||
volume.appendChild(createButton.call(this, 'mute'));
|
||||
}
|
||||
|
||||
// Volume range control
|
||||
if (control === 'volume') {
|
||||
// Set the attributes
|
||||
const attributes = {
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
value: this.config.volume,
|
||||
};
|
||||
|
||||
// Create the volume range slider
|
||||
volume.appendChild(
|
||||
createRange.call(
|
||||
this,
|
||||
'volume',
|
||||
extend(attributes, {
|
||||
id: `plyr-volume-${data.id}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
container.appendChild(volume);
|
||||
}
|
||||
// Toggle captions button
|
||||
if (control === 'captions') {
|
||||
container.appendChild(createButton.call(this, 'captions', defaultAttributes));
|
||||
}
|
||||
|
||||
// Toggle captions button
|
||||
if (this.config.controls.includes('captions')) {
|
||||
container.appendChild(controls.createButton.call(this, 'captions'));
|
||||
}
|
||||
|
||||
// Settings button / menu
|
||||
if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
|
||||
const control = createElement('div', {
|
||||
class: 'plyr__menu',
|
||||
hidden: '',
|
||||
});
|
||||
|
||||
control.appendChild(
|
||||
controls.createButton.call(this, 'settings', {
|
||||
'aria-haspopup': true,
|
||||
'aria-controls': `plyr-settings-${data.id}`,
|
||||
'aria-expanded': false,
|
||||
}),
|
||||
);
|
||||
|
||||
const popup = createElement('div', {
|
||||
class: 'plyr__menu__container',
|
||||
id: `plyr-settings-${data.id}`,
|
||||
hidden: '',
|
||||
});
|
||||
|
||||
const inner = createElement('div');
|
||||
|
||||
const home = createElement('div', {
|
||||
id: `plyr-settings-${data.id}-home`,
|
||||
});
|
||||
|
||||
// Create the menu
|
||||
const menu = createElement('div', {
|
||||
role: 'menu',
|
||||
});
|
||||
|
||||
home.appendChild(menu);
|
||||
inner.appendChild(home);
|
||||
this.elements.settings.panels.home = home;
|
||||
|
||||
// Build the menu items
|
||||
this.config.settings.forEach(type => {
|
||||
// TODO: bundle this with the createMenuItem helper and bindings
|
||||
const menuItem = createElement(
|
||||
'button',
|
||||
extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {
|
||||
type: 'button',
|
||||
class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
|
||||
role: 'menuitem',
|
||||
'aria-haspopup': true,
|
||||
// Settings button / menu
|
||||
if (control === 'settings' && !is.empty(this.config.settings)) {
|
||||
const control = createElement(
|
||||
'div',
|
||||
extend({}, defaultAttributes, {
|
||||
class: `${defaultAttributes.class} plyr__menu`.trim(),
|
||||
hidden: '',
|
||||
}),
|
||||
);
|
||||
|
||||
// Bind menu shortcuts for keyboard users
|
||||
controls.bindMenuItemShortcuts.call(this, menuItem, type);
|
||||
|
||||
// Show menu on click
|
||||
on(menuItem, 'click', () => {
|
||||
controls.showMenuPanel.call(this, type, false);
|
||||
});
|
||||
|
||||
const flex = createElement('span', null, i18n.get(type, this.config));
|
||||
|
||||
const value = createElement('span', {
|
||||
class: this.config.classNames.menu.value,
|
||||
});
|
||||
|
||||
// Speed contains HTML entities
|
||||
value.innerHTML = data[type];
|
||||
|
||||
flex.appendChild(value);
|
||||
menuItem.appendChild(flex);
|
||||
menu.appendChild(menuItem);
|
||||
|
||||
// Build the panes
|
||||
const pane = createElement('div', {
|
||||
id: `plyr-settings-${data.id}-${type}`,
|
||||
hidden: '',
|
||||
});
|
||||
|
||||
// Back button
|
||||
const backButton = createElement('button', {
|
||||
type: 'button',
|
||||
class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
|
||||
});
|
||||
|
||||
// Visible label
|
||||
backButton.appendChild(
|
||||
createElement(
|
||||
'span',
|
||||
{
|
||||
'aria-hidden': true,
|
||||
},
|
||||
i18n.get(type, this.config),
|
||||
),
|
||||
);
|
||||
|
||||
// Screen reader label
|
||||
backButton.appendChild(
|
||||
createElement(
|
||||
'span',
|
||||
{
|
||||
class: this.config.classNames.hidden,
|
||||
},
|
||||
i18n.get('menuBack', this.config),
|
||||
),
|
||||
);
|
||||
|
||||
// Go back via keyboard
|
||||
on(
|
||||
pane,
|
||||
'keydown',
|
||||
event => {
|
||||
// We only care about <-
|
||||
if (event.which !== 37) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent seek
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Show the respective menu
|
||||
controls.showMenuPanel.call(this, 'home', true);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// Go back via button click
|
||||
on(backButton, 'click', () => {
|
||||
controls.showMenuPanel.call(this, 'home', false);
|
||||
});
|
||||
|
||||
// Add to pane
|
||||
pane.appendChild(backButton);
|
||||
|
||||
// Menu
|
||||
pane.appendChild(
|
||||
createElement('div', {
|
||||
role: 'menu',
|
||||
control.appendChild(
|
||||
createButton.call(this, 'settings', {
|
||||
'aria-haspopup': true,
|
||||
'aria-controls': `plyr-settings-${data.id}`,
|
||||
'aria-expanded': false,
|
||||
}),
|
||||
);
|
||||
|
||||
inner.appendChild(pane);
|
||||
|
||||
this.elements.settings.buttons[type] = menuItem;
|
||||
this.elements.settings.panels[type] = pane;
|
||||
});
|
||||
|
||||
popup.appendChild(inner);
|
||||
control.appendChild(popup);
|
||||
container.appendChild(control);
|
||||
|
||||
this.elements.settings.popup = popup;
|
||||
this.elements.settings.menu = control;
|
||||
}
|
||||
|
||||
// Picture in picture button
|
||||
if (this.config.controls.includes('pip') && support.pip) {
|
||||
container.appendChild(controls.createButton.call(this, 'pip'));
|
||||
}
|
||||
|
||||
// Airplay button
|
||||
if (this.config.controls.includes('airplay') && support.airplay) {
|
||||
container.appendChild(controls.createButton.call(this, 'airplay'));
|
||||
}
|
||||
|
||||
// Download button
|
||||
if (this.config.controls.includes('download')) {
|
||||
const attributes = {
|
||||
element: 'a',
|
||||
href: this.download,
|
||||
target: '_blank',
|
||||
};
|
||||
|
||||
const { download } = this.config.urls;
|
||||
|
||||
if (!is.url(download) && this.isEmbed) {
|
||||
extend(attributes, {
|
||||
icon: `logo-${this.provider}`,
|
||||
label: this.provider,
|
||||
const popup = createElement('div', {
|
||||
class: 'plyr__menu__container',
|
||||
id: `plyr-settings-${data.id}`,
|
||||
hidden: '',
|
||||
});
|
||||
|
||||
const inner = createElement('div');
|
||||
|
||||
const home = createElement('div', {
|
||||
id: `plyr-settings-${data.id}-home`,
|
||||
});
|
||||
|
||||
// Create the menu
|
||||
const menu = createElement('div', {
|
||||
role: 'menu',
|
||||
});
|
||||
|
||||
home.appendChild(menu);
|
||||
inner.appendChild(home);
|
||||
this.elements.settings.panels.home = home;
|
||||
|
||||
// Build the menu items
|
||||
this.config.settings.forEach(type => {
|
||||
// TODO: bundle this with the createMenuItem helper and bindings
|
||||
const menuItem = createElement(
|
||||
'button',
|
||||
extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {
|
||||
type: 'button',
|
||||
class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
|
||||
role: 'menuitem',
|
||||
'aria-haspopup': true,
|
||||
hidden: '',
|
||||
}),
|
||||
);
|
||||
|
||||
// Bind menu shortcuts for keyboard users
|
||||
bindMenuItemShortcuts.call(this, menuItem, type);
|
||||
|
||||
// Show menu on click
|
||||
on(menuItem, 'click', () => {
|
||||
showMenuPanel.call(this, type, false);
|
||||
});
|
||||
|
||||
const flex = createElement('span', null, i18n.get(type, this.config));
|
||||
|
||||
const value = createElement('span', {
|
||||
class: this.config.classNames.menu.value,
|
||||
});
|
||||
|
||||
// Speed contains HTML entities
|
||||
value.innerHTML = data[type];
|
||||
|
||||
flex.appendChild(value);
|
||||
menuItem.appendChild(flex);
|
||||
menu.appendChild(menuItem);
|
||||
|
||||
// Build the panes
|
||||
const pane = createElement('div', {
|
||||
id: `plyr-settings-${data.id}-${type}`,
|
||||
hidden: '',
|
||||
});
|
||||
|
||||
// Back button
|
||||
const backButton = createElement('button', {
|
||||
type: 'button',
|
||||
class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
|
||||
});
|
||||
|
||||
// Visible label
|
||||
backButton.appendChild(
|
||||
createElement(
|
||||
'span',
|
||||
{
|
||||
'aria-hidden': true,
|
||||
},
|
||||
i18n.get(type, this.config),
|
||||
),
|
||||
);
|
||||
|
||||
// Screen reader label
|
||||
backButton.appendChild(
|
||||
createElement(
|
||||
'span',
|
||||
{
|
||||
class: this.config.classNames.hidden,
|
||||
},
|
||||
i18n.get('menuBack', this.config),
|
||||
),
|
||||
);
|
||||
|
||||
// Go back via keyboard
|
||||
on(
|
||||
pane,
|
||||
'keydown',
|
||||
event => {
|
||||
// We only care about <-
|
||||
if (event.which !== 37) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent seek
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Show the respective menu
|
||||
showMenuPanel.call(this, 'home', true);
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
// Go back via button click
|
||||
on(backButton, 'click', () => {
|
||||
showMenuPanel.call(this, 'home', false);
|
||||
});
|
||||
|
||||
// Add to pane
|
||||
pane.appendChild(backButton);
|
||||
|
||||
// Menu
|
||||
pane.appendChild(
|
||||
createElement('div', {
|
||||
role: 'menu',
|
||||
}),
|
||||
);
|
||||
|
||||
inner.appendChild(pane);
|
||||
|
||||
this.elements.settings.buttons[type] = menuItem;
|
||||
this.elements.settings.panels[type] = pane;
|
||||
});
|
||||
|
||||
popup.appendChild(inner);
|
||||
control.appendChild(popup);
|
||||
container.appendChild(control);
|
||||
|
||||
this.elements.settings.popup = popup;
|
||||
this.elements.settings.menu = control;
|
||||
}
|
||||
|
||||
container.appendChild(controls.createButton.call(this, 'download', attributes));
|
||||
}
|
||||
// Picture in picture button
|
||||
if (control === 'pip' && support.pip) {
|
||||
container.appendChild(createButton.call(this, 'pip', defaultAttributes));
|
||||
}
|
||||
|
||||
// Toggle fullscreen button
|
||||
if (this.config.controls.includes('fullscreen')) {
|
||||
container.appendChild(controls.createButton.call(this, 'fullscreen'));
|
||||
}
|
||||
// Airplay button
|
||||
if (control === 'airplay' && support.airplay) {
|
||||
container.appendChild(createButton.call(this, 'airplay', defaultAttributes));
|
||||
}
|
||||
|
||||
// Larger overlaid play button
|
||||
if (this.config.controls.includes('play-large')) {
|
||||
this.elements.container.appendChild(controls.createButton.call(this, 'play-large'));
|
||||
}
|
||||
// Download button
|
||||
if (control === 'download') {
|
||||
const attributes = extend({}, defaultAttributes, {
|
||||
element: 'a',
|
||||
href: this.download,
|
||||
target: '_blank',
|
||||
});
|
||||
|
||||
this.elements.controls = container;
|
||||
const { download } = this.config.urls;
|
||||
|
||||
if (!is.url(download) && this.isEmbed) {
|
||||
extend(attributes, {
|
||||
icon: `logo-${this.provider}`,
|
||||
label: this.provider,
|
||||
});
|
||||
}
|
||||
|
||||
container.appendChild(createButton.call(this, 'download', attributes));
|
||||
}
|
||||
|
||||
// Toggle fullscreen button
|
||||
if (control === 'fullscreen') {
|
||||
container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes));
|
||||
}
|
||||
});
|
||||
|
||||
// Set available quality levels
|
||||
if (this.isHTML5) {
|
||||
controls.setQualityMenu.call(this, html5.getQualityOptions.call(this));
|
||||
setQualityMenu.call(this, html5.getQualityOptions.call(this));
|
||||
}
|
||||
|
||||
controls.setSpeedMenu.call(this);
|
||||
setSpeedMenu.call(this);
|
||||
|
||||
return container;
|
||||
},
|
||||
|
@ -318,7 +318,7 @@ class Listeners {
|
||||
|
||||
const target = player.elements.wrapper.firstChild;
|
||||
const [, y] = ratio;
|
||||
const [videoX, videoY] = getAspectRatio.call(this);
|
||||
const [videoX, videoY] = getAspectRatio.call(player);
|
||||
|
||||
target.style.maxWidth = toggle ? `${(y / videoY) * videoX}px` : null;
|
||||
target.style.margin = toggle ? '0 auto' : null;
|
||||
@ -486,7 +486,7 @@ class Listeners {
|
||||
|
||||
// Update download link when ready and if quality changes
|
||||
on.call(player, player.media, 'ready qualitychange', () => {
|
||||
controls.setDownloadLink.call(player);
|
||||
controls.setDownloadUrl.call(player);
|
||||
});
|
||||
|
||||
// Proxy events to container
|
||||
|
@ -14,6 +14,20 @@ import loadScript from '../utils/loadScript';
|
||||
import { formatTime } from '../utils/time';
|
||||
import { buildUrlParams } from '../utils/urls';
|
||||
|
||||
const destroy = instance => {
|
||||
// Destroy our adsManager
|
||||
if (instance.manager) {
|
||||
instance.manager.destroy();
|
||||
}
|
||||
|
||||
// Destroy our adsManager
|
||||
if (instance.elements.displayContainer) {
|
||||
instance.elements.displayContainer.destroy();
|
||||
}
|
||||
|
||||
instance.elements.container.remove();
|
||||
};
|
||||
|
||||
class Ads {
|
||||
/**
|
||||
* Ads constructor.
|
||||
@ -63,20 +77,22 @@ class Ads {
|
||||
* Load the IMA SDK
|
||||
*/
|
||||
load() {
|
||||
if (this.enabled) {
|
||||
// Check if the Google IMA3 SDK is loaded or load it ourselves
|
||||
if (!is.object(window.google) || !is.object(window.google.ima)) {
|
||||
loadScript(this.player.config.urls.googleIMA.sdk)
|
||||
.then(() => {
|
||||
this.ready();
|
||||
})
|
||||
.catch(() => {
|
||||
// Script failed to load or is blocked
|
||||
this.trigger('error', new Error('Google IMA SDK failed to load'));
|
||||
});
|
||||
} else {
|
||||
this.ready();
|
||||
}
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the Google IMA3 SDK is loaded or load it ourselves
|
||||
if (!is.object(window.google) || !is.object(window.google.ima)) {
|
||||
loadScript(this.player.config.urls.googleIMA.sdk)
|
||||
.then(() => {
|
||||
this.ready();
|
||||
})
|
||||
.catch(() => {
|
||||
// Script failed to load or is blocked
|
||||
this.trigger('error', new Error('Google IMA SDK failed to load'));
|
||||
});
|
||||
} else {
|
||||
this.ready();
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,6 +100,11 @@ class Ads {
|
||||
* Get the ads instance ready
|
||||
*/
|
||||
ready() {
|
||||
// Double check we're enabled
|
||||
if (!this.enabled) {
|
||||
destroy(this);
|
||||
}
|
||||
|
||||
// Start ticking our safety timer. If the whole advertisement
|
||||
// thing doesn't resolve within our set time; we bail
|
||||
this.startSafetyTimer(12000, 'ready()');
|
||||
@ -240,9 +261,6 @@ class Ads {
|
||||
// Get the cue points for any mid-rolls by filtering out the pre- and post-roll
|
||||
this.cuePoints = this.manager.getCuePoints();
|
||||
|
||||
// Set volume to match player
|
||||
this.manager.setVolume(this.player.volume);
|
||||
|
||||
// Add listeners to the required events
|
||||
// Advertisement error events
|
||||
this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error));
|
||||
@ -297,15 +315,15 @@ class Ads {
|
||||
triggerEvent.call(this.player, this.player.media, event);
|
||||
};
|
||||
|
||||
// Bubble the event
|
||||
dispatchEvent(event.type);
|
||||
|
||||
switch (event.type) {
|
||||
case google.ima.AdEvent.Type.LOADED:
|
||||
// This is the first event sent for an ad - it is possible to determine whether the
|
||||
// ad is a video ad or an overlay
|
||||
this.trigger('loaded');
|
||||
|
||||
// Bubble event
|
||||
dispatchEvent(event.type);
|
||||
|
||||
// Start countdown
|
||||
this.pollCountdown(true);
|
||||
|
||||
@ -317,15 +335,19 @@ class Ads {
|
||||
|
||||
// console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex());
|
||||
// console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset());
|
||||
|
||||
break;
|
||||
|
||||
case google.ima.AdEvent.Type.STARTED:
|
||||
// Set volume to match player
|
||||
this.manager.setVolume(this.player.volume);
|
||||
|
||||
break;
|
||||
|
||||
case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:
|
||||
// All ads for the current videos are done. We can now request new advertisements
|
||||
// in case the video is re-played
|
||||
|
||||
// Fire event
|
||||
dispatchEvent(event.type);
|
||||
|
||||
// TODO: Example for what happens when a next video in a playlist would be loaded.
|
||||
// So here we load a new video when all ads are done.
|
||||
// Then we load new ads within a new adsManager. When the video
|
||||
@ -350,6 +372,7 @@ class Ads {
|
||||
// playing when the IMA SDK is ready or has failed
|
||||
|
||||
this.loadAds();
|
||||
|
||||
break;
|
||||
|
||||
case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:
|
||||
@ -357,8 +380,6 @@ class Ads {
|
||||
// for example display a pause button and remaining time. Fired when content should
|
||||
// be paused. This usually happens right before an ad is about to cover the content
|
||||
|
||||
dispatchEvent(event.type);
|
||||
|
||||
this.pauseContent();
|
||||
|
||||
break;
|
||||
@ -369,26 +390,17 @@ class Ads {
|
||||
// Fired when content should be resumed. This usually happens when an ad finishes
|
||||
// or collapses
|
||||
|
||||
dispatchEvent(event.type);
|
||||
|
||||
this.pollCountdown();
|
||||
|
||||
this.resumeContent();
|
||||
|
||||
break;
|
||||
|
||||
case google.ima.AdEvent.Type.STARTED:
|
||||
case google.ima.AdEvent.Type.MIDPOINT:
|
||||
case google.ima.AdEvent.Type.COMPLETE:
|
||||
case google.ima.AdEvent.Type.IMPRESSION:
|
||||
case google.ima.AdEvent.Type.CLICK:
|
||||
dispatchEvent(event.type);
|
||||
break;
|
||||
|
||||
case google.ima.AdEvent.Type.LOG:
|
||||
if (adData.adError) {
|
||||
this.player.debug.warn(`Non-fatal ad error: ${adData.adError.getMessage()}`);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
@ -463,6 +475,9 @@ class Ads {
|
||||
// Play the requested advertisement whenever the adsManager is ready
|
||||
this.managerPromise
|
||||
.then(() => {
|
||||
// Set volume to match player
|
||||
this.manager.setVolume(this.player.volume);
|
||||
|
||||
// Initialize the container. Must be done via a user action on mobile devices
|
||||
this.elements.displayContainer.initialize();
|
||||
|
||||
|
@ -149,9 +149,11 @@ class PreviewThumbnails {
|
||||
// If the URLs don't start with '/', then we need to set their relative path to be the location of the VTT file
|
||||
// If the URLs do start with '/', then they obviously don't need a prefix, so it will remain blank
|
||||
// If the thumbnail URLs start with with none of '/', 'http://' or 'https://', then we need to set their relative path to be the location of the VTT file
|
||||
if (!thumbnail.frames[0].text.startsWith('/') &&
|
||||
if (
|
||||
!thumbnail.frames[0].text.startsWith('/') &&
|
||||
!thumbnail.frames[0].text.startsWith('http://') &&
|
||||
!thumbnail.frames[0].text.startsWith('https://')) {
|
||||
!thumbnail.frames[0].text.startsWith('https://')
|
||||
) {
|
||||
thumbnail.urlPrefix = url.substring(0, url.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
@ -297,7 +299,9 @@ class PreviewThumbnails {
|
||||
this.elements.thumb.container.appendChild(timeContainer);
|
||||
|
||||
// Inject the whole thumb
|
||||
this.player.elements.progress.appendChild(this.elements.thumb.container);
|
||||
if (is.element(this.player.elements.progress)) {
|
||||
this.player.elements.progress.appendChild(this.elements.thumb.container);
|
||||
}
|
||||
|
||||
// Create HTML element: plyr__preview-scrubbing-container
|
||||
this.elements.scrubbing.container = createElement('div', {
|
||||
|
@ -48,14 +48,14 @@ const vimeo = {
|
||||
// Set intial ratio
|
||||
setAspectRatio.call(this);
|
||||
|
||||
// Load the API if not already
|
||||
// Load the SDK if not already
|
||||
if (!is.object(window.Vimeo)) {
|
||||
loadScript(this.config.urls.vimeo.sdk)
|
||||
.then(() => {
|
||||
vimeo.ready.call(this);
|
||||
})
|
||||
.catch(error => {
|
||||
this.debug.warn('Vimeo API failed to load', error);
|
||||
this.debug.warn('Vimeo SDK (player.js) failed to load', error);
|
||||
});
|
||||
} else {
|
||||
vimeo.ready.call(this);
|
||||
@ -259,7 +259,7 @@ const vimeo = {
|
||||
.getVideoUrl()
|
||||
.then(value => {
|
||||
currentSrc = value;
|
||||
controls.setDownloadLink.call(player);
|
||||
controls.setDownloadUrl.call(player);
|
||||
})
|
||||
.catch(error => {
|
||||
this.debug.warn(error);
|
||||
@ -281,7 +281,7 @@ const vimeo = {
|
||||
// Set aspect ratio based on video size
|
||||
Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => {
|
||||
const [width, height] = dimensions;
|
||||
player.embed.ratio = `${width}:${height}`;
|
||||
player.embed.ratio = [width, height];
|
||||
setAspectRatio.call(this);
|
||||
});
|
||||
|
||||
|
@ -52,9 +52,6 @@ const youtube = {
|
||||
// Add embed class for responsive
|
||||
toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
|
||||
|
||||
// Set aspect ratio
|
||||
setAspectRatio.call(this);
|
||||
|
||||
// Setup API
|
||||
if (is.object(window.YT) && is.function(window.YT.Player)) {
|
||||
youtube.ready.call(this);
|
||||
@ -84,33 +81,27 @@ const youtube = {
|
||||
|
||||
// Get the media title
|
||||
getTitle(videoId) {
|
||||
// Try via undocumented API method first
|
||||
// This method disappears now and then though...
|
||||
// https://github.com/sampotts/plyr/issues/709
|
||||
if (is.function(this.embed.getVideoData)) {
|
||||
const { title } = this.embed.getVideoData();
|
||||
const url = format(this.config.urls.youtube.api, videoId);
|
||||
|
||||
if (is.empty(title)) {
|
||||
this.config.title = title;
|
||||
ui.setTitle.call(this);
|
||||
return;
|
||||
}
|
||||
}
|
||||
fetch(url)
|
||||
.then(data => {
|
||||
if (is.object(data)) {
|
||||
const { title, height, width } = data;
|
||||
|
||||
// Or via Google API
|
||||
const key = this.config.keys.google;
|
||||
if (is.string(key) && !is.empty(key)) {
|
||||
const url = format(this.config.urls.youtube.api, videoId, key);
|
||||
// Set title
|
||||
this.config.title = title;
|
||||
ui.setTitle.call(this);
|
||||
|
||||
fetch(url)
|
||||
.then(result => {
|
||||
if (is.object(result)) {
|
||||
this.config.title = result.items[0].snippet.title;
|
||||
ui.setTitle.call(this);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
// Set aspect ratio
|
||||
this.embed.ratio = [width, height];
|
||||
}
|
||||
|
||||
setAspectRatio.call(this);
|
||||
})
|
||||
.catch(() => {
|
||||
// Set aspect ratio
|
||||
setAspectRatio.call(this);
|
||||
});
|
||||
},
|
||||
|
||||
// API ready
|
||||
|
@ -25,6 +25,7 @@ import { createElement, hasClass, removeElement, replaceElement, toggleClass, wr
|
||||
import { off, on, once, triggerEvent, unbindListeners } from './utils/events';
|
||||
import is from './utils/is';
|
||||
import loadSprite from './utils/loadSprite';
|
||||
import { clamp } from './utils/numbers';
|
||||
import { cloneDeep, extend } from './utils/objects';
|
||||
import { getAspectRatio, reduceAspectRatio, setAspectRatio, validateRatio } from './utils/style';
|
||||
import { parseUrl } from './utils/urls';
|
||||
@ -661,24 +662,17 @@ class Plyr {
|
||||
speed = this.config.speed.selected;
|
||||
}
|
||||
|
||||
// Set min/max
|
||||
if (speed < 0.1) {
|
||||
speed = 0.1;
|
||||
}
|
||||
if (speed > 2.0) {
|
||||
speed = 2.0;
|
||||
}
|
||||
|
||||
if (!this.config.speed.options.includes(speed)) {
|
||||
this.debug.warn(`Unsupported speed (${speed})`);
|
||||
return;
|
||||
}
|
||||
// Clamp to min/max
|
||||
const { minimumSpeed: min, maximumSpeed: max } = this;
|
||||
speed = clamp(speed, min, max);
|
||||
|
||||
// Update config
|
||||
this.config.speed.selected = speed;
|
||||
|
||||
// Set media speed
|
||||
this.media.playbackRate = speed;
|
||||
setTimeout(() => {
|
||||
this.media.playbackRate = speed;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -688,6 +682,42 @@ class Plyr {
|
||||
return Number(this.media.playbackRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimum allowed speed
|
||||
*/
|
||||
get minimumSpeed() {
|
||||
if (this.isYouTube) {
|
||||
// https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
|
||||
return Math.min(...this.options.speed);
|
||||
}
|
||||
|
||||
if (this.isVimeo) {
|
||||
// https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/32320020/1191319
|
||||
return 0.0625;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum allowed speed
|
||||
*/
|
||||
get maximumSpeed() {
|
||||
if (this.isYouTube) {
|
||||
// https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
|
||||
return Math.max(...this.options.speed);
|
||||
}
|
||||
|
||||
if (this.isVimeo) {
|
||||
// https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
|
||||
return 2;
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/32320020/1191319
|
||||
return 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set playback quality
|
||||
* Currently HTML5 & YouTube only
|
||||
@ -823,6 +853,19 @@ class Plyr {
|
||||
return is.url(download) ? download : this.source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the download URL
|
||||
*/
|
||||
set download(input) {
|
||||
if (!is.url(input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.urls.download = input;
|
||||
|
||||
controls.setDownloadUrl.call(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the poster image for a video
|
||||
* @param {String} input - the URL for the new poster image
|
||||
@ -851,6 +894,10 @@ class Plyr {
|
||||
* Get the current aspect ratio in use
|
||||
*/
|
||||
get ratio() {
|
||||
if (!this.isVideo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ratio = reduceAspectRatio(getAspectRatio.call(this));
|
||||
|
||||
return is.array(ratio) ? ratio.join(':') : ratio;
|
||||
|
27
src/js/ui.js
27
src/js/ui.js
@ -67,15 +67,15 @@ const ui = {
|
||||
// Reset mute state
|
||||
this.muted = null;
|
||||
|
||||
// Reset speed
|
||||
this.speed = null;
|
||||
|
||||
// Reset loop state
|
||||
this.loop = null;
|
||||
|
||||
// Reset quality setting
|
||||
this.quality = null;
|
||||
|
||||
// Reset speed
|
||||
this.speed = null;
|
||||
|
||||
// Reset volume display
|
||||
controls.updateVolume.call(this);
|
||||
|
||||
@ -233,13 +233,16 @@ const ui = {
|
||||
clearTimeout(this.timers.loading);
|
||||
|
||||
// Timer to prevent flicker when seeking
|
||||
this.timers.loading = setTimeout(() => {
|
||||
// Update progress bar loading class state
|
||||
toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
|
||||
this.timers.loading = setTimeout(
|
||||
() => {
|
||||
// Update progress bar loading class state
|
||||
toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
|
||||
|
||||
// Update controls visibility
|
||||
ui.toggleControls.call(this);
|
||||
}, this.loading ? 250 : 0);
|
||||
// Update controls visibility
|
||||
ui.toggleControls.call(this);
|
||||
},
|
||||
this.loading ? 250 : 0,
|
||||
);
|
||||
},
|
||||
|
||||
// Toggle controls based on state and `force` argument
|
||||
@ -248,10 +251,12 @@ const ui = {
|
||||
|
||||
if (controls && this.config.hideControls) {
|
||||
// Don't hide controls if a touch-device user recently seeked. (Must be limited to touch devices, or it occasionally prevents desktop controls from hiding.)
|
||||
const recentTouchSeek = (this.touch && this.lastSeekTime + 2000 > Date.now());
|
||||
const recentTouchSeek = this.touch && this.lastSeekTime + 2000 > Date.now();
|
||||
|
||||
// Show controls if force, loading, paused, button interaction, or recent seek, otherwise hide
|
||||
this.toggleControls(Boolean(force || this.loading || this.paused || controls.pressed || controls.hover || recentTouchSeek));
|
||||
this.toggleControls(
|
||||
Boolean(force || this.loading || this.paused || controls.pressed || controls.hover || recentTouchSeek),
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
@ -4,6 +4,7 @@
|
||||
|
||||
import { toggleListener } from './events';
|
||||
import is from './is';
|
||||
import { extend } from './objects';
|
||||
|
||||
// Wrap an element
|
||||
export function wrap(elements, wrapper) {
|
||||
@ -137,7 +138,7 @@ export function getAttributesFromSelector(sel, existingAttributes) {
|
||||
}
|
||||
|
||||
const attributes = {};
|
||||
const existing = existingAttributes;
|
||||
const existing = extend({}, existingAttributes);
|
||||
|
||||
sel.split(',').forEach(s => {
|
||||
// Remove whitespace
|
||||
@ -147,7 +148,7 @@ export function getAttributesFromSelector(sel, existingAttributes) {
|
||||
|
||||
// Get the parts and value
|
||||
const parts = stripped.split('=');
|
||||
const key = parts[0];
|
||||
const [key] = parts;
|
||||
const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : '';
|
||||
|
||||
// Get the first character
|
||||
@ -156,11 +157,11 @@ export function getAttributesFromSelector(sel, existingAttributes) {
|
||||
switch (start) {
|
||||
case '.':
|
||||
// Add to existing classname
|
||||
if (is.object(existing) && is.string(existing.class)) {
|
||||
existing.class += ` ${className}`;
|
||||
if (is.string(existing.class)) {
|
||||
attributes.class = `${existing.class} ${className}`;
|
||||
} else {
|
||||
attributes.class = className;
|
||||
}
|
||||
|
||||
attributes.class = className;
|
||||
break;
|
||||
|
||||
case '#':
|
||||
@ -179,7 +180,7 @@ export function getAttributesFromSelector(sel, existingAttributes) {
|
||||
}
|
||||
});
|
||||
|
||||
return attributes;
|
||||
return extend(existing, attributes);
|
||||
}
|
||||
|
||||
// Toggle hidden
|
||||
|
17
src/js/utils/numbers.js
Normal file
17
src/js/utils/numbers.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Returns a number whose value is limited to the given range.
|
||||
*
|
||||
* Example: limit the output of this computation to between 0 and 255
|
||||
* (x * 255).clamp(0, 255)
|
||||
*
|
||||
* @param {Number} input
|
||||
* @param {Number} min The lower boundary of the output range
|
||||
* @param {Number} max The upper boundary of the output range
|
||||
* @returns A number in the range [min, max]
|
||||
* @type Number
|
||||
*/
|
||||
export function clamp(input = 0, min = 0, max = 255) {
|
||||
return Math.min(Math.max(input, min), max);
|
||||
}
|
||||
|
||||
export default { clamp };
|
@ -44,8 +44,14 @@ export function getAspectRatio(input) {
|
||||
}
|
||||
|
||||
// Get from embed
|
||||
if (ratio === null && !is.empty(this.embed) && is.string(this.embed.ratio)) {
|
||||
ratio = parse(this.embed.ratio);
|
||||
if (ratio === null && !is.empty(this.embed) && is.array(this.embed.ratio)) {
|
||||
({ ratio } = this.embed);
|
||||
}
|
||||
|
||||
// Get from HTML5 video
|
||||
if (ratio === null && this.isHTML5) {
|
||||
const { videoWidth, videoHeight } = this.media;
|
||||
ratio = reduceAspectRatio([videoWidth, videoHeight]);
|
||||
}
|
||||
|
||||
return ratio;
|
||||
|
@ -14,42 +14,46 @@
|
||||
justify-content: flex-end;
|
||||
text-align: center;
|
||||
|
||||
.plyr__progress__container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// Spacing
|
||||
> .plyr__control,
|
||||
.plyr__progress,
|
||||
.plyr__time,
|
||||
.plyr__menu,
|
||||
.plyr__volume {
|
||||
margin-left: ($plyr-control-spacing / 2);
|
||||
}
|
||||
.plyr__controls__item {
|
||||
margin-left: ($plyr-control-spacing / 4);
|
||||
|
||||
.plyr__menu + .plyr__control,
|
||||
> .plyr__control + .plyr__menu,
|
||||
> .plyr__control + .plyr__control,
|
||||
.plyr__progress + .plyr__control {
|
||||
margin-left: floor($plyr-control-spacing / 4);
|
||||
}
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
> .plyr__control:first-child,
|
||||
> .plyr__control:first-child + [data-plyr='pause'] {
|
||||
margin-left: 0;
|
||||
margin-right: auto;
|
||||
&.plyr__progress__container {
|
||||
padding-left: ($plyr-control-spacing / 4);
|
||||
}
|
||||
|
||||
&.plyr__time {
|
||||
padding: 0 ($plyr-control-spacing / 2);
|
||||
}
|
||||
|
||||
&.plyr__progress__container:first-child,
|
||||
&.plyr__time:first-child,
|
||||
&.plyr__time + .plyr__time {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&.plyr__volume {
|
||||
padding-right: ($plyr-control-spacing / 2);
|
||||
}
|
||||
|
||||
&.plyr__volume:first-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide empty controls
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: $plyr-bp-sm) {
|
||||
> .plyr__control,
|
||||
.plyr__menu,
|
||||
.plyr__progress,
|
||||
.plyr__time,
|
||||
.plyr__volume {
|
||||
margin-left: $plyr-control-spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Audio controls
|
||||
@ -62,10 +66,7 @@
|
||||
|
||||
// Video controls
|
||||
.plyr--video .plyr__controls {
|
||||
background: linear-gradient(
|
||||
rgba($plyr-video-controls-bg, 0),
|
||||
rgba($plyr-video-controls-bg, 0.7)
|
||||
);
|
||||
background: linear-gradient(rgba($plyr-video-controls-bg, 0), rgba($plyr-video-controls-bg, 0.7));
|
||||
border-bottom-left-radius: inherit;
|
||||
border-bottom-right-radius: inherit;
|
||||
bottom: 0;
|
||||
|
@ -2,18 +2,19 @@
|
||||
// Playback progress
|
||||
// --------------------------------------------------------------
|
||||
|
||||
// Offset the range thumb in order to be able to calculate the relative progress (#954)
|
||||
$plyr-progress-offset: $plyr-range-thumb-height;
|
||||
|
||||
.plyr__progress {
|
||||
flex: 1;
|
||||
left: $plyr-range-thumb-height / 2;
|
||||
margin-right: $plyr-range-thumb-height;
|
||||
left: $plyr-progress-offset / 2;
|
||||
margin-right: $plyr-progress-offset;
|
||||
position: relative;
|
||||
|
||||
input[type='range'],
|
||||
&__buffer {
|
||||
margin-left: -($plyr-range-thumb-height / 2);
|
||||
margin-right: -($plyr-range-thumb-height / 2);
|
||||
// Offset the range thumb in order to be able to calculate the relative progress (#954)
|
||||
width: calc(100% + #{$plyr-range-thumb-height});
|
||||
margin-left: -($plyr-progress-offset / 2);
|
||||
margin-right: -($plyr-progress-offset / 2);
|
||||
width: calc(100% + #{$plyr-progress-offset});
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
|
Loading…
x
Reference in New Issue
Block a user