Merge pull request #1142 from sampotts/a11y-improvements

A11y improvements
This commit is contained in:
Sam Potts 2018-08-02 00:47:57 +10:00 committed by GitHub
commit 18b4d26bee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 2762 additions and 1920 deletions

2
.gitignore vendored
View File

@ -8,4 +8,4 @@ npm-debug.log
yarn-error.log
package-lock.json
*.webm
.idea/
.idea/

View File

@ -2,5 +2,6 @@
"useTabs": false,
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"printWidth": 120
}

2
demo/dist/demo.css vendored

File diff suppressed because one or more lines are too long

58
demo/dist/demo.js vendored
View File

@ -1874,7 +1874,7 @@ typeof navigator === "object" && (function () {
// webpack (using a build step causes webpack #1617). Grunt verifies that
// this value matches package.json during build.
// See: https://github.com/getsentry/raven-js/issues/465
VERSION: '3.26.3',
VERSION: '3.26.4',
debug: false,
@ -2612,34 +2612,40 @@ typeof navigator === "object" && (function () {
)
return;
options = options || {};
options = Object.assign(
{
eventId: this.lastEventId(),
dsn: this._dsn,
user: this._globalContext.user || {}
},
options
);
var lastEventId = options.eventId || this.lastEventId();
if (!lastEventId) {
if (!options.eventId) {
throw new configError('Missing eventId');
}
var dsn = options.dsn || this._dsn;
if (!dsn) {
if (!options.dsn) {
throw new configError('Missing DSN');
}
var encode = encodeURIComponent;
var qs = '';
qs += '?eventId=' + encode(lastEventId);
qs += '&dsn=' + encode(dsn);
var encodedOptions = [];
var user = options.user || this._globalContext.user;
if (user) {
if (user.name) qs += '&name=' + encode(user.name);
if (user.email) qs += '&email=' + encode(user.email);
for (var key in options) {
if (key === 'user') {
var user = options.user;
if (user.name) encodedOptions.push('name=' + encode(user.name));
if (user.email) encodedOptions.push('email=' + encode(user.email));
} else {
encodedOptions.push(encode(key) + '=' + encode(options[key]));
}
}
var globalServer = this._getGlobalServer(this._parseDSN(dsn));
var globalServer = this._getGlobalServer(this._parseDSN(options.dsn));
var script = _document.createElement('script');
script.async = true;
script.src = globalServer + '/api/embed/error-page/' + qs;
script.src = globalServer + '/api/embed/error-page/?' + encodedOptions.join('&');
(_document.head || _document.body).appendChild(script);
},
@ -4097,6 +4103,9 @@ typeof navigator === "object" && (function () {
document.addEventListener('DOMContentLoaded', function () {
singleton.context(function () {
var selector = '#player';
var container = document.getElementById('container');
if (window.shr) {
window.shr.setup({
count: {
@ -4110,6 +4119,9 @@ typeof navigator === "object" && (function () {
// Remove class on blur
document.addEventListener('focusout', function (event) {
if (container.contains(event.target)) {
return;
}
event.target.classList.remove(tabClassName);
});
@ -4122,12 +4134,18 @@ typeof navigator === "object" && (function () {
// Delay the adding of classname until the focus has changed
// This event fires before the focusin event
setTimeout(function () {
document.activeElement.classList.add(tabClassName);
}, 0);
var focused = document.activeElement;
if (!focused || container.contains(focused)) {
return;
}
focused.classList.add(tabClassName);
}, 10);
});
// Setup the player
var player = new Plyr('#player', {
var player = new Plyr(selector, {
debug: true,
title: 'View From A Blue Moon',
iconUrl: '../dist/plyr.svg',
@ -4137,7 +4155,7 @@ typeof navigator === "object" && (function () {
tooltips: {
controls: true
},
clickToPlay: false,
// clickToPlay: false,
/* controls: [
'play-large',
'restart',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
demo/dist/error.css vendored

File diff suppressed because one or more lines are too long

View File

@ -91,21 +91,22 @@
</header>
<main>
<video controls crossorigin playsinline poster="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg" id="player">
<!-- Video files -->
<source src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4" type="video/mp4" size="576">
<source src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-720p.mp4" type="video/mp4" size="720">
<source src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-1080p.mp4" type="video/mp4" size="1080">
<!-- <source src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-1440p.mp4" type="video/mp4" size="1440"> -->
<div id="container">
<video controls crossorigin playsinline poster="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg" id="player">
<!-- Video files -->
<source src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4" type="video/mp4" size="576">
<source src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-720p.mp4" type="video/mp4" size="720">
<source src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-1080p.mp4" type="video/mp4" size="1080">
<!-- Caption files -->
<track kind="captions" label="English" srclang="en" src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt"
default>
<track kind="captions" label="Français" srclang="fr" src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt">
<!-- Caption files -->
<track kind="captions" label="English" srclang="en" src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt"
default>
<track kind="captions" label="Français" srclang="fr" src="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt">
<!-- Fallback for browsers that don't support the <video> element -->
<a href="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4" download>Download</a>
</video>
<!-- Fallback for browsers that don't support the <video> element -->
<a href="https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-576p.mp4" download>Download</a>
</video>
</div>
<ul>
<li class="plyr__cite plyr__cite--video" hidden>
@ -166,7 +167,7 @@
</svg>
<p>If you think Plyr's good,
<a href="https://twitter.com/intent/tweet?text=A+simple+HTML5+media+player+with+custom+controls+and+WebVTT+captions.&amp;url=http%3A%2F%2Fplyr.io&amp;via=Sam_Potts"
target="_blank" data-shr-network="twitter">tweet it</a>
target="_blank" data-shr-network="twitter">tweet it</a> 👍
</p>
</aside>

View File

@ -7,16 +7,17 @@
import Raven from 'raven-js';
(() => {
const isLive = window.location.host === 'plyr.io';
// Raven / Sentry
// For demo site (https://plyr.io) only
if (isLive) {
Raven.config('https://d4ad9866ad834437a4754e23937071e4@sentry.io/305555').install();
}
const { host } = window.location;
const env = {
prod: host === 'plyr.io',
dev: host === 'dev.plyr.io',
};
document.addEventListener('DOMContentLoaded', () => {
Raven.context(() => {
const selector = '#player';
const container = document.getElementById('container');
if (window.shr) {
window.shr.setup({
count: {
@ -30,6 +31,9 @@ import Raven from 'raven-js';
// Remove class on blur
document.addEventListener('focusout', event => {
if (container.contains(event.target)) {
return;
}
event.target.classList.remove(tabClassName);
});
@ -42,12 +46,18 @@ import Raven from 'raven-js';
// Delay the adding of classname until the focus has changed
// This event fires before the focusin event
setTimeout(() => {
document.activeElement.classList.add(tabClassName);
}, 0);
const focused = document.activeElement;
if (!focused || container.contains(focused)) {
return;
}
focused.classList.add(tabClassName);
}, 10);
});
// Setup the player
const player = new Plyr('#player', {
const player = new Plyr(selector, {
debug: true,
title: 'View From A Blue Moon',
iconUrl: '../dist/plyr.svg',
@ -57,57 +67,6 @@ import Raven from 'raven-js';
tooltips: {
controls: true,
},
clickToPlay: false,
/* controls: [
'play-large',
'restart',
'rewind',
'play',
'fast-forward',
'progress',
'current-time',
'duration',
'mute',
'volume',
'captions',
'settings',
'pip',
'airplay',
'fullscreen',
], */
/* i18n: {
restart: '重新開始',
rewind: '快退{seektime}秒',
play: '播放',
pause: '暫停',
fastForward: '快進{seektime}秒',
seek: '尋求',
played: '發揮',
buffered: '緩衝的',
currentTime: '當前時間戳',
duration: '長短',
volume: '音量',
mute: '靜音',
unmute: '取消靜音',
enableCaptions: '開啟字幕',
disableCaptions: '關閉字幕',
enterFullscreen: '進入全螢幕',
exitFullscreen: '退出全螢幕',
frameTitle: '球員為{title}',
captions: '字幕',
settings: '設定',
speed: '速度',
normal: '正常',
quality: '質量',
loop: '循環',
start: 'Start',
end: 'End',
all: 'All',
reset: '重啟',
disabled: '殘',
enabled: '啟用',
advertisement: '廣告',
}, */
captions: {
active: true,
},
@ -115,7 +74,7 @@ import Raven from 'raven-js';
google: 'AIzaSyDrNwtN3nLH_8rjCmu5Wq3ZCm4MNAVdc0c',
},
ads: {
enabled: true,
enabled: env.prod || env.dev,
publisherId: '918848828995742',
},
});
@ -311,11 +270,17 @@ import Raven from 'raven-js';
});
});
// Raven / Sentry
// For demo site (https://plyr.io) only
if (env.prod) {
Raven.config('https://d4ad9866ad834437a4754e23937071e4@sentry.io/305555').install();
}
// Google analytics
// For demo site (https://plyr.io) only
/* eslint-disable */
if (isLive) {
(function(i, s, o, g, r, a, m) {
if (env.prod) {
((i, s, o, g, r, a, m) => {
i.GoogleAnalyticsObject = r;
i[r] =
i[r] ||

View File

@ -2,7 +2,8 @@
// Typography
// ==========================================================================
$font-sans-serif: 'Gordita', 'Avenir', 'Helvetica Neue', sans-serif;
$font-sans-serif: 'Gordita', 'Avenir', 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol';
$font-size-base: 15;
$font-size-small: 13;

2
dist/plyr.css vendored

File diff suppressed because one or more lines are too long

1385
dist/plyr.js vendored

File diff suppressed because it is too large Load Diff

2
dist/plyr.js.map vendored

File diff suppressed because one or more lines are too long

2
dist/plyr.min.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1391
dist/plyr.polyfilled.js vendored

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{
"name": "plyr",
"version": "3.3.23",
"version": "3.4.0-beta.1",
"description":
"A simple, accessible and customizable HTML5, YouTube and Vimeo media player",
"homepage": "https://plyr.io",

View File

@ -162,9 +162,9 @@ reference, the CDN hosted SVG sprite can be found at `https://cdn.plyr.io/3.3.23
## Ads
Plyr has partnered up with [vi.ai](http://vi.ai/publisher-video-monetization/?aid=plyrio) to offer monetization options for your videos. Getting setup is easy:
Plyr has partnered up with [vi.ai](https://vi.ai/publisher-video-monetization/?aid=plyrio) to offer monetization options for your videos. Getting setup is easy:
* [Sign up for a vi.ai account](http://vi.ai/publisher-video-monetization/?aid=plyrio)
* [Sign up for a vi.ai account](https://vi.ai/publisher-video-monetization/?aid=plyrio)
* Grab your publisher ID from the code snippet
* Enable ads in the [config options](#options) and enter your publisher ID

View File

@ -84,7 +84,9 @@ const captions = {
// * toggled: The real captions state
const languages = dedupe(
Array.from(navigator.languages || navigator.language || navigator.userLanguage).map(language => language.split('-')[0]),
Array.from(navigator.languages || navigator.language || navigator.userLanguage).map(
language => language.split('-')[0],
),
);
let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();

View File

@ -354,6 +354,9 @@ const defaults = {
isTouch: 'plyr--is-touch',
uiSupported: 'plyr--full-ui',
noTransition: 'plyr--no-transition',
display: {
time: 'plyr__time',
},
menu: {
value: 'plyr__menu__value',
badge: 'plyr__badge',

662
src/js/controls.js vendored
View File

@ -1,5 +1,6 @@
// ==========================================================================
// Plyr controls
// TODO: This needs to be split into smaller files and cleaned up
// ==========================================================================
import captions from './captions';
@ -9,19 +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,
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 is from './utils/is';
import loadSprite from './utils/loadSprite';
@ -243,12 +232,28 @@ const controls = {
// Setup toggle icon and labels
if (toggle) {
// Icon
button.appendChild(controls.createIcon.call(this, iconPressed, { class: 'icon--pressed' }));
button.appendChild(controls.createIcon.call(this, icon, { class: 'icon--not-pressed' }));
button.appendChild(
controls.createIcon.call(this, iconPressed, {
class: 'icon--pressed',
}),
);
button.appendChild(
controls.createIcon.call(this, icon, {
class: 'icon--not-pressed',
}),
);
// Label/Tooltip
button.appendChild(controls.createLabel.call(this, labelPressed, { class: 'label--pressed' }));
button.appendChild(controls.createLabel.call(this, label, { class: 'label--not-pressed' }));
button.appendChild(
controls.createLabel.call(this, labelPressed, {
class: 'label--pressed',
}),
);
button.appendChild(
controls.createLabel.call(this, label, {
class: 'label--not-pressed',
}),
);
} else {
button.appendChild(controls.createIcon.call(this, icon));
button.appendChild(controls.createLabel.call(this, label));
@ -360,7 +365,7 @@ const controls = {
const container = createElement(
'div',
extend(attributes, {
class: `plyr__time ${attributes.class}`,
class: `${this.config.classNames.display.time} ${attributes.class ? attributes.class : ''}`.trim(),
'aria-label': i18n.get(type, this.config),
}),
'00:00',
@ -372,37 +377,143 @@ const controls = {
return container;
},
// Bind keyboard shortcuts for a menu item
// We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus
// https://bugzilla.mozilla.org/show_bug.cgi?id=1220143
bindMenuItemShortcuts(menuItem, type) {
// Handle space or -> to open menu
on(
menuItem,
'keydown keyup',
event => {
// We only care about space and ⬆️ ⬇️️ ➡️
if (![32, 38, 39, 40].includes(event.which)) {
return;
}
// Prevent play / seek
event.preventDefault();
event.stopPropagation();
// We're just here to prevent the keydown bubbling
if (event.type === 'keydown') {
return;
}
const isRadioButton = matches(menuItem, '[role="menuitemradio"]');
// Show the respective menu
if (!isRadioButton && [32, 39].includes(event.which)) {
controls.showMenuPanel.call(this, type, true);
} else {
let target;
if (event.which !== 32) {
if (event.which === 40 || (isRadioButton && event.which === 39)) {
target = menuItem.nextElementSibling;
if (!is.element(target)) {
target = menuItem.parentNode.firstElementChild;
}
} else {
target = menuItem.previousElementSibling;
if (!is.element(target)) {
target = menuItem.parentNode.lastElementChild;
}
}
setFocus.call(this, target, true);
}
}
},
false,
);
},
// Create a settings menu item
createMenuItem({ value, list, type, title, badge = null, checked = false }) {
const item = createElement('li');
const attributes = getAttributesFromSelector(this.config.selectors.inputs[type]);
const label = createElement('label', {
class: this.config.classNames.control,
});
const radio = createElement(
'input',
extend(getAttributesFromSelector(this.config.selectors.inputs[type]), {
type: 'radio',
name: `plyr-${type}`,
const menuItem = createElement(
'button',
extend(attributes, {
type: 'button',
role: 'menuitemradio',
class: `${this.config.classNames.control} ${attributes.class ? attributes.class : ''}`.trim(),
'aria-checked': checked,
value,
checked,
class: 'plyr__sr-only',
}),
);
const faux = createElement('span', { hidden: '' });
const flex = createElement('span');
label.appendChild(radio);
label.appendChild(faux);
label.insertAdjacentHTML('beforeend', title);
// We have to set as HTML incase of special characters
flex.innerHTML = title;
if (is.element(badge)) {
label.appendChild(badge);
flex.appendChild(badge);
}
item.appendChild(label);
list.appendChild(item);
menuItem.appendChild(flex);
// Replicate radio button behaviour
Object.defineProperty(menuItem, 'checked', {
enumerable: true,
get() {
return menuItem.getAttribute('aria-checked') === 'true';
},
set(checked) {
// Ensure exclusivity
if (checked) {
Array.from(menuItem.parentNode.children)
.filter(node => matches(node, '[role="menuitemradio"]'))
.forEach(node => node.setAttribute('aria-checked', 'false'));
}
menuItem.setAttribute('aria-checked', checked ? 'true' : 'false');
},
});
this.listeners.bind(
menuItem,
'click keyup',
event => {
if (is.keyboardEvent(event) && event.which !== 32) {
return;
}
event.preventDefault();
event.stopPropagation();
menuItem.checked = true;
switch (type) {
case 'language':
this.currentTrack = Number(value);
break;
case 'quality':
this.quality = value;
break;
case 'speed':
this.speed = parseFloat(value);
break;
default:
break;
}
controls.showMenuPanel.call(this, 'home', is.keyboardEvent(event));
},
type,
false,
);
controls.bindMenuItemShortcuts.call(this, menuItem, type);
list.appendChild(menuItem);
},
// Format a time for display
@ -637,7 +748,7 @@ const controls = {
// https://github.com/video-dev/hls.js/blob/5820d29d3c4c8a46e8b75f1e3afa3e68c1a9a2db/src/controller/buffer-controller.js#L415
// https://github.com/google/shaka-player/blob/4d889054631f4e1cf0fbd80ddd2b71887c02e232/lib/media/streaming_engine.js#L1062
// https://github.com/Dash-Industry-Forum/dash.js/blob/69859f51b969645b234666800d4cb596d89c602d/src/dash/models/DashManifestModel.js#L338
if (this.duration >= 2**32) {
if (this.duration >= 2 ** 32) {
toggleHidden(this.elements.display.currentTime, true);
toggleHidden(this.elements.progress, true);
return;
@ -666,19 +777,97 @@ const controls = {
},
// Hide/show a tab
toggleTab(setting, toggle) {
toggleHidden(this.elements.settings.tabs[setting], !toggle);
toggleMenuButton(setting, toggle) {
toggleHidden(this.elements.settings.buttons[setting], !toggle);
},
// Update the selected setting
updateSetting(setting, container, input) {
const pane = this.elements.settings.panels[setting];
let value = null;
let list = container;
if (setting === 'captions') {
value = this.currentTrack;
} else {
value = !is.empty(input) ? input : this[setting];
// Get default
if (is.empty(value)) {
value = this.config[setting].default;
}
// Unsupported value
if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
return;
}
// Disabled value
if (!this.config[setting].options.includes(value)) {
this.debug.warn(`Disabled value of '${value}' for ${setting}`);
return;
}
}
// Get the list if we need to
if (!is.element(list)) {
list = pane && pane.querySelector('[role="menu"]');
}
// If there's no list it means it's not been rendered...
if (!is.element(list)) {
return;
}
// Update the label
const label = this.elements.settings.buttons[setting].querySelector(`.${this.config.classNames.menu.value}`);
label.innerHTML = controls.getLabel.call(this, setting, value);
// Find the radio option and check it
const target = list && list.querySelector(`[value="${value}"]`);
if (is.element(target)) {
target.checked = true;
}
},
// Translate a value into a nice label
getLabel(setting, value) {
switch (setting) {
case 'speed':
return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;
case 'quality':
if (is.number(value)) {
const label = i18n.get(`qualityLabel.${value}`, this.config);
if (!label.length) {
return `${value}p`;
}
return label;
}
return toTitleCase(value);
case 'captions':
return captions.getLabel.call(this);
default:
return null;
}
},
// Set the quality menu
setQualityMenu(options) {
// Menu required
if (!is.element(this.elements.settings.panes.quality)) {
if (!is.element(this.elements.settings.panels.quality)) {
return;
}
const type = 'quality';
const list = this.elements.settings.panes.quality.querySelector('ul');
const list = this.elements.settings.panels.quality.querySelector('[role="menu"]');
// Set options if passed and filter based on uniqueness and config
if (is.array(options)) {
@ -687,7 +876,10 @@ const controls = {
// Toggle the pane and tab
const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;
controls.toggleTab.call(this, type, toggle);
controls.toggleMenuButton.call(this, type, toggle);
// Empty the menu
emptyElement(list);
// Check if we need to toggle the parent
controls.checkMenu.call(this);
@ -697,9 +889,6 @@ const controls = {
return;
}
// Empty the menu
emptyElement(list);
// Get the badge HTML for HD, 4K etc
const getBadge = quality => {
const label = i18n.get(`qualityBadge.${quality}`, this.config);
@ -730,101 +919,23 @@ const controls = {
controls.updateSetting.call(this, type, list);
},
// Translate a value into a nice label
getLabel(setting, value) {
switch (setting) {
case 'speed':
return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;
case 'quality':
if (is.number(value)) {
const label = i18n.get(`qualityLabel.${value}`, this.config);
if (!label.length) {
return `${value}p`;
}
return label;
}
return toTitleCase(value);
case 'captions':
return captions.getLabel.call(this);
default:
return null;
}
},
// Update the selected setting
updateSetting(setting, container, input) {
const pane = this.elements.settings.panes[setting];
let value = null;
let list = container;
if (setting === 'captions') {
value = this.currentTrack;
} else {
value = !is.empty(input) ? input : this[setting];
// Get default
if (is.empty(value)) {
value = this.config[setting].default;
}
// Unsupported value
if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
return;
}
// Disabled value
if (!this.config[setting].options.includes(value)) {
this.debug.warn(`Disabled value of '${value}' for ${setting}`);
return;
}
}
// Get the list if we need to
if (!is.element(list)) {
list = pane && pane.querySelector('ul');
}
// If there's no list it means it's not been rendered...
if (!is.element(list)) {
return;
}
// Update the label
const label = this.elements.settings.tabs[setting].querySelector(`.${this.config.classNames.menu.value}`);
label.innerHTML = controls.getLabel.call(this, setting, value);
// Find the radio option and check it
const target = list && list.querySelector(`input[value="${value}"]`);
if (is.element(target)) {
target.checked = true;
}
},
// Set the looping options
/* setLoopMenu() {
// Menu required
if (!is.element(this.elements.settings.panes.loop)) {
if (!is.element(this.elements.settings.panels.loop)) {
return;
}
const options = ['start', 'end', 'all', 'reset'];
const list = this.elements.settings.panes.loop.querySelector('ul');
const list = this.elements.settings.panels.loop.querySelector('[role="menu"]');
// Show the pane and tab
toggleHidden(this.elements.settings.tabs.loop, false);
toggleHidden(this.elements.settings.panes.loop, false);
toggleHidden(this.elements.settings.buttons.loop, false);
toggleHidden(this.elements.settings.panels.loop, false);
// Toggle the pane and tab
const toggle = !is.empty(this.loop.options);
controls.toggleTab.call(this, 'loop', toggle);
controls.toggleMenuButton.call(this, 'loop', toggle);
// Empty the menu
emptyElement(list);
@ -857,13 +968,19 @@ const controls = {
// Set a list of available captions languages
setCaptionsMenu() {
// Menu required
if (!is.element(this.elements.settings.panels.captions)) {
return;
}
// TODO: Captions or language? Currently it's mixed
const type = 'captions';
const list = this.elements.settings.panes.captions.querySelector('ul');
const list = this.elements.settings.panels.captions.querySelector('[role="menu"]');
const tracks = captions.getTracks.call(this);
const toggle = Boolean(tracks.length);
// Toggle the pane and tab
controls.toggleTab.call(this, type, tracks.length);
controls.toggleMenuButton.call(this, type, toggle);
// Empty the menu
emptyElement(list);
@ -872,7 +989,7 @@ const controls = {
controls.checkMenu.call(this);
// If there's no captions, bail
if (!tracks.length) {
if (!toggle) {
return;
}
@ -903,17 +1020,13 @@ const controls = {
// Set a list of available captions languages
setSpeedMenu(options) {
// Do nothing if not selected
if (!this.config.controls.includes('settings') || !this.config.settings.includes('speed')) {
return;
}
// Menu required
if (!is.element(this.elements.settings.panes.speed)) {
if (!is.element(this.elements.settings.panels.speed)) {
return;
}
const type = 'speed';
const list = this.elements.settings.panels.speed.querySelector('[role="menu"]');
// Set the speed options
if (is.array(options)) {
@ -927,7 +1040,10 @@ const controls = {
// Toggle the pane and tab
const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;
controls.toggleTab.call(this, type, toggle);
controls.toggleMenuButton.call(this, type, toggle);
// Empty the menu
emptyElement(list);
// Check if we need to toggle the parent
controls.checkMenu.call(this);
@ -937,12 +1053,6 @@ const controls = {
return;
}
// Get the list to populate
const list = this.elements.settings.panes.speed.querySelector('ul');
// Empty the menu
emptyElement(list);
// Create items
this.options.speed.forEach(speed => {
controls.createMenuItem.call(this, {
@ -958,29 +1068,35 @@ const controls = {
// Check if we need to hide/show the settings menu
checkMenu() {
const { tabs } = this.elements.settings;
const visible = !is.empty(tabs) && Object.values(tabs).some(tab => !tab.hidden);
const { buttons } = this.elements.settings;
const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden);
toggleHidden(this.elements.settings.menu, !visible);
},
// Show/hide menu
toggleMenu(event) {
const { form } = this.elements.settings;
toggleMenu(input) {
const { popup } = this.elements.settings;
const button = this.elements.buttons.settings;
// Menu and button are required
if (!is.element(form) || !is.element(button)) {
if (!is.element(popup) || !is.element(button)) {
return;
}
const show = is.boolean(event) ? event : is.element(form) && form.hasAttribute('hidden');
// True toggle by default
const hidden = popup.hasAttribute('hidden');
let show = hidden;
if (is.event(event)) {
const isMenuItem = is.element(form) && form.contains(event.target);
const isButton = event.target === this.elements.buttons.settings;
if (is.boolean(input)) {
show = input;
} else if (is.keyboardEvent(input) && input.which === 27) {
show = false;
} else if (is.event(input)) {
const isMenuItem = popup.contains(input.target);
const isButton = input.target === button;
// If the click was inside the form or if the click
// If the click was inside the menu or if the click
// wasn't the button or menu item and we're trying to
// show the menu (a doc click shouldn't show the menu)
if (isMenuItem || (!isMenuItem && !isButton && show)) {
@ -989,40 +1105,38 @@ const controls = {
// Prevent the toggle being caught by the doc listener
if (isButton) {
event.stopPropagation();
input.stopPropagation();
}
}
// Set form and button attributes
if (is.element(button)) {
button.setAttribute('aria-expanded', show);
// Set button attributes
button.setAttribute('aria-expanded', show);
// Show the actual popup
toggleHidden(popup, !show);
// Add class hook
toggleClass(this.elements.container, this.config.classNames.menu.open, show);
// Focus the first item if key interaction
if (show && is.keyboardEvent(input)) {
const pane = Object.values(this.elements.settings.panels).find(pane => !pane.hidden);
const firstItem = pane.querySelector('[role^="menuitem"]');
setFocus.call(this, firstItem, true);
}
if (is.element(form)) {
toggleHidden(form, !show);
toggleClass(this.elements.container, this.config.classNames.menu.open, show);
if (show) {
form.removeAttribute('tabindex');
} else {
form.setAttribute('tabindex', -1);
}
// If closing, re-focus the button
else if (!show && !hidden) {
setFocus.call(this, button, is.keyboardEvent(input));
}
},
// Get the natural size of a tab
getTabSize(tab) {
// Get the natural size of a menu panel
getMenuSize(tab) {
const clone = tab.cloneNode(true);
clone.style.position = 'absolute';
clone.style.opacity = 0;
clone.removeAttribute('hidden');
// Prevent input's being unchecked due to the name being identical
Array.from(clone.querySelectorAll('input[name]')).forEach(input => {
const name = input.getAttribute('name');
input.setAttribute('name', `${name}-clone`);
});
// Append to parent so we get the "real" size
tab.parentNode.appendChild(clone);
@ -1039,31 +1153,18 @@ const controls = {
};
},
// Toggle Menu
showTab(target = '') {
const { menu } = this.elements.settings;
const pane = document.getElementById(target);
// Show a panel in the menu
showMenuPanel(type = '', tabFocus = false) {
const target = document.getElementById(`plyr-settings-${this.id}-${type}`);
// Nothing to show, bail
if (!is.element(pane)) {
if (!is.element(target)) {
return;
}
// Are we targeting a tab? If not, bail
const isTab = pane.getAttribute('role') === 'tabpanel';
if (!isTab) {
return;
}
// Hide all other tabs
// Get other tabs
const current = menu.querySelector('[role="tabpanel"]:not([hidden])');
const container = current.parentNode;
// Set other toggles to be expanded false
Array.from(menu.querySelectorAll(`[aria-controls="${current.getAttribute('id')}"]`)).forEach(toggle => {
toggle.setAttribute('aria-expanded', false);
});
// Hide all other panels
const container = target.parentNode;
const current = Array.from(container.children).find(node => !node.hidden);
// If we can do fancy animations, we'll animate the height/width
if (support.transitions && !support.reducedMotion) {
@ -1072,12 +1173,12 @@ const controls = {
container.style.height = `${current.scrollHeight}px`;
// Get potential sizes
const size = controls.getTabSize.call(this, pane);
const size = controls.getMenuSize.call(this, target);
// Restore auto height/width
const restore = e => {
const restore = event => {
// We're only bothered about height and width on the container
if (e.target !== container || !['width', 'height'].includes(e.propertyName)) {
if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {
return;
}
@ -1099,19 +1200,13 @@ const controls = {
// Set attributes on current tab
toggleHidden(current, true);
current.setAttribute('tabindex', -1);
// Set attributes on target
toggleHidden(pane, false);
const tabs = getElements.call(this, `[aria-controls="${target}"]`);
Array.from(tabs).forEach(tab => {
tab.setAttribute('aria-expanded', true);
});
pane.removeAttribute('tabindex');
toggleHidden(target, false);
// Focus the first item
pane.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex]')[0].focus();
const firstItem = target.querySelector('[role^="menuitem"]');
setFocus.call(this, firstItem, tabFocus);
},
// Build the default HTML
@ -1225,12 +1320,12 @@ const controls = {
// Settings button / menu
if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
const menu = createElement('div', {
const control = createElement('div', {
class: 'plyr__menu',
hidden: '',
});
menu.appendChild(
control.appendChild(
controls.createButton.call(this, 'settings', {
id: `plyr-settings-toggle-${data.id}`,
'aria-haspopup': true,
@ -1239,48 +1334,52 @@ const controls = {
}),
);
const form = createElement('form', {
const popup = createElement('div', {
class: 'plyr__menu__container',
id: `plyr-settings-${data.id}`,
hidden: '',
'aria-labelled-by': `plyr-settings-toggle-${data.id}`,
role: 'tablist',
tabindex: -1,
});
const inner = createElement('div');
const home = createElement('div', {
id: `plyr-settings-${data.id}-home`,
'aria-labelled-by': `plyr-settings-toggle-${data.id}`,
role: 'tabpanel',
});
// Create the tab list
const tabs = createElement('ul', {
role: 'tablist',
// Create the menu
const menu = createElement('div', {
role: 'menu',
});
// Build the tabs
home.appendChild(menu);
inner.appendChild(home);
this.elements.settings.panels.home = home;
// Build the menu items
this.config.settings.forEach(type => {
const tab = createElement('li', {
role: 'tab',
hidden: '',
});
const button = createElement(
// 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`,
id: `plyr-settings-${data.id}-${type}-tab`,
role: 'menuitem',
'aria-haspopup': true,
'aria-controls': `plyr-settings-${data.id}-${type}`,
'aria-expanded': false,
hidden: '',
}),
i18n.get(type, this.config),
);
// 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,
});
@ -1288,54 +1387,91 @@ const controls = {
// Speed contains HTML entities
value.innerHTML = data[type];
button.appendChild(value);
tab.appendChild(button);
tabs.appendChild(tab);
flex.appendChild(value);
menuItem.appendChild(flex);
menu.appendChild(menuItem);
this.elements.settings.tabs[type] = tab;
});
home.appendChild(tabs);
inner.appendChild(home);
// Build the panes
this.config.settings.forEach(type => {
// Build the panes
const pane = createElement('div', {
id: `plyr-settings-${data.id}-${type}`,
hidden: '',
'aria-labelled-by': `plyr-settings-${data.id}-${type}-tab`,
role: 'tabpanel',
tabindex: -1,
});
const back = createElement(
'button',
{
type: 'button',
class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
'aria-haspopup': true,
'aria-controls': `plyr-settings-${data.id}-home`,
'aria-expanded': false,
},
i18n.get(type, this.config),
// 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),
),
);
pane.appendChild(back);
// Screen reader label
backButton.appendChild(
createElement(
'span',
{
class: this.config.classNames.hidden,
},
i18n.get('menuBack', this.config),
),
);
const options = createElement('ul');
// 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',
}),
);
pane.appendChild(options);
inner.appendChild(pane);
this.elements.settings.panes[type] = pane;
this.elements.settings.buttons[type] = menuItem;
this.elements.settings.panels[type] = pane;
});
form.appendChild(inner);
menu.appendChild(form);
container.appendChild(menu);
popup.appendChild(inner);
control.appendChild(popup);
container.appendChild(control);
this.elements.settings.form = form;
this.elements.settings.menu = menu;
this.elements.settings.popup = popup;
this.elements.settings.menu = control;
}
// Picture in picture button

View File

@ -4,8 +4,9 @@
import controls from './controls';
import ui from './ui';
import { repaint } from './utils/animation';
import browser from './utils/browser';
import { getElement, getElements, getFocusElement, matches, toggleClass, toggleHidden } from './utils/elements';
import { getElement, getElements, hasClass, matches, toggleClass, toggleHidden } from './utils/elements';
import { on, once, toggleListener, triggerEvent } from './utils/events';
import is from './utils/is';
@ -13,14 +14,19 @@ class Listeners {
constructor(player) {
this.player = player;
this.lastKey = null;
this.focusTimer = null;
this.lastKeyDown = null;
this.handleKey = this.handleKey.bind(this);
this.toggleMenu = this.toggleMenu.bind(this);
this.setTabFocus = this.setTabFocus.bind(this);
this.firstTouch = this.firstTouch.bind(this);
}
// Handle key presses
handleKey(event) {
const { player } = this;
const { elements } = player;
const code = event.keyCode ? event.keyCode : event.which;
const pressed = event.type === 'keydown';
const repeat = pressed && code === this.lastKey;
@ -39,27 +45,32 @@ class Listeners {
// Seek by the number keys
const seekByKey = () => {
// Divide the max duration into 10th's and times by the number value
this.player.currentTime = this.player.duration / 10 * (code - 48);
player.currentTime = player.duration / 10 * (code - 48);
};
// Handle the key on keydown
// Reset on keyup
if (pressed) {
// Which keycodes should we prevent default
const preventDefault = [32, 37, 38, 39, 40, 48, 49, 50, 51, 52, 53, 54, 56, 57, 67, 70, 73, 75, 76, 77, 79];
// Check focused element
// and if the focused element is not editable (e.g. text input)
// and any that accept key input http://webaim.org/techniques/keyboard/
const focused = getFocusElement();
if (
is.element(focused) &&
(focused !== this.player.elements.inputs.seek &&
matches(focused, this.player.config.selectors.editable))
) {
return;
const focused = document.activeElement;
if (is.element(focused)) {
const { editable } = player.config.selectors;
const { seek } = elements.inputs;
if (focused !== seek && matches(focused, editable)) {
return;
}
if (event.which === 32 && matches(focused, 'button, [role^="menuitem"]')) {
return;
}
}
// Which keycodes should we prevent default
const preventDefault = [32, 37, 38, 39, 40, 48, 49, 50, 51, 52, 53, 54, 56, 57, 67, 70, 73, 75, 76, 77, 79];
// If the code is found prevent default (e.g. prevent scrolling for arrows)
if (preventDefault.includes(code)) {
event.preventDefault();
@ -87,52 +98,52 @@ class Listeners {
case 75:
// Space and K key
if (!repeat) {
this.player.togglePlay();
player.togglePlay();
}
break;
case 38:
// Arrow up
this.player.increaseVolume(0.1);
player.increaseVolume(0.1);
break;
case 40:
// Arrow down
this.player.decreaseVolume(0.1);
player.decreaseVolume(0.1);
break;
case 77:
// M key
if (!repeat) {
this.player.muted = !this.player.muted;
player.muted = !player.muted;
}
break;
case 39:
// Arrow forward
this.player.forward();
player.forward();
break;
case 37:
// Arrow back
this.player.rewind();
player.rewind();
break;
case 70:
// F key
this.player.fullscreen.toggle();
player.fullscreen.toggle();
break;
case 67:
// C key
if (!repeat) {
this.player.toggleCaptions();
player.toggleCaptions();
}
break;
case 76:
// L key
this.player.loop = !this.player.loop;
player.loop = !player.loop;
break;
/* case 73:
@ -153,8 +164,8 @@ class Listeners {
// Escape is handle natively when in full screen
// So we only need to worry about non native
if (!this.player.fullscreen.enabled && this.player.fullscreen.active && code === 27) {
this.player.fullscreen.toggle();
if (!player.fullscreen.enabled && player.fullscreen.active && code === 27) {
player.fullscreen.toggle();
}
// Store last code for next cycle
@ -171,58 +182,99 @@ class Listeners {
// Device is touch enabled
firstTouch() {
this.player.touch = true;
const { player } = this;
const { elements } = player;
player.touch = true;
// Add touch class
toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true);
toggleClass(elements.container, player.config.classNames.isTouch, true);
}
setTabFocus(event) {
const { player } = this;
const { elements } = player;
clearTimeout(this.focusTimer);
// Ignore any key other than tab
if (event.type === 'keydown' && event.which !== 9) {
return;
}
// Store reference to event timeStamp
if (event.type === 'keydown') {
this.lastKeyDown = event.timeStamp;
}
// Remove current classes
const removeCurrent = () => {
const className = player.config.classNames.tabFocus;
const current = getElements.call(player, `.${className}`);
toggleClass(current, className, false);
};
// Determine if a key was pressed to trigger this event
const wasKeyDown = event.timeStamp - this.lastKeyDown <= 20;
// Ignore focus events if a key was pressed prior
if (event.type === 'focus' && !wasKeyDown) {
return;
}
// Remove all current
removeCurrent();
// Delay the adding of classname until the focus has changed
// This event fires before the focusin event
this.focusTimer = setTimeout(() => {
const focused = document.activeElement;
// Ignore if current focus element isn't inside the player
if (!elements.container.contains(focused)) {
return;
}
toggleClass(document.activeElement, player.config.classNames.tabFocus, true);
}, 10);
}
// Global window & document listeners
global(toggle = true) {
const { player } = this;
// Keyboard shortcuts
if (this.player.config.keyboard.global) {
toggleListener.call(this.player, window, 'keydown keyup', this.handleKey, toggle, false);
if (player.config.keyboard.global) {
toggleListener.call(player, window, 'keydown keyup', this.handleKey, toggle, false);
}
// Click anywhere closes menu
toggleListener.call(this.player, document.body, 'click', this.toggleMenu, toggle);
toggleListener.call(player, document.body, 'click', this.toggleMenu, toggle);
// Detect touch by events
once.call(this.player, document.body, 'touchstart', this.firstTouch);
once.call(player, document.body, 'touchstart', this.firstTouch);
// Tab focus detection
toggleListener.call(player, document.body, 'keydown focus blur', this.setTabFocus, toggle, false, true);
}
// Container listeners
container() {
const { player } = this;
const { elements } = player;
// Keyboard shortcuts
if (!this.player.config.keyboard.global && this.player.config.keyboard.focused) {
on.call(this.player, this.player.elements.container, 'keydown keyup', this.handleKey, false);
if (!player.config.keyboard.global && player.config.keyboard.focused) {
on.call(player, elements.container, 'keydown keyup', this.handleKey, false);
}
// Detect tab focus
// Remove class on blur/focusout
on.call(this.player, this.player.elements.container, 'focusout', event => {
toggleClass(event.target, this.player.config.classNames.tabFocus, false);
});
// Add classname to tabbed elements
on.call(this.player, this.player.elements.container, 'keydown', event => {
if (event.keyCode !== 9) {
return;
}
// Delay the adding of classname until the focus has changed
// This event fires before the focusin event
setTimeout(() => {
toggleClass(getFocusElement(), this.player.config.classNames.tabFocus, true);
}, 0);
});
// Toggle controls on mouse events and entering fullscreen
on.call(
this.player,
this.player.elements.container,
player,
elements.container,
'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen',
event => {
const { controls } = this.player.elements;
const { controls } = elements;
// Remove button states for fullscreen
if (event.type === 'enterfullscreen') {
@ -236,85 +288,83 @@ class Listeners {
let delay = 0;
if (show) {
ui.toggleControls.call(this.player, true);
ui.toggleControls.call(player, true);
// Use longer timeout for touch devices
delay = this.player.touch ? 3000 : 2000;
delay = player.touch ? 3000 : 2000;
}
// Clear timer
clearTimeout(this.player.timers.controls);
// Timer to prevent flicker when seeking
this.player.timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay);
clearTimeout(player.timers.controls);
// Set new timer to prevent flicker when seeking
player.timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);
},
);
}
// Listen for media events
media() {
const { player } = this;
const { elements } = player;
// Time change on media
on.call(this.player, this.player.media, 'timeupdate seeking seeked', event =>
controls.timeUpdate.call(this.player, event),
);
on.call(player, player.media, 'timeupdate seeking seeked', event => controls.timeUpdate.call(player, event));
// Display duration
on.call(this.player, this.player.media, 'durationchange loadeddata loadedmetadata', event =>
controls.durationUpdate.call(this.player, event),
on.call(player, player.media, 'durationchange loadeddata loadedmetadata', event =>
controls.durationUpdate.call(player, event),
);
// Check for audio tracks on load
// We can't use `loadedmetadata` as it doesn't seem to have audio tracks at that point
on.call(this.player, this.player.media, 'canplay', () => {
toggleHidden(this.player.elements.volume, !this.player.hasAudio);
toggleHidden(this.player.elements.buttons.mute, !this.player.hasAudio);
on.call(player, player.media, 'canplay', () => {
toggleHidden(elements.volume, !player.hasAudio);
toggleHidden(elements.buttons.mute, !player.hasAudio);
});
// Handle the media finishing
on.call(this.player, this.player.media, 'ended', () => {
on.call(player, player.media, 'ended', () => {
// Show poster on end
if (this.player.isHTML5 && this.player.isVideo && this.player.config.resetOnEnd) {
if (player.isHTML5 && player.isVideo && player.config.resetOnEnd) {
// Restart
this.player.restart();
player.restart();
}
});
// Check for buffer progress
on.call(this.player, this.player.media, 'progress playing seeking seeked', event =>
controls.updateProgress.call(this.player, event),
on.call(player, player.media, 'progress playing seeking seeked', event =>
controls.updateProgress.call(player, event),
);
// Handle volume changes
on.call(this.player, this.player.media, 'volumechange', event =>
controls.updateVolume.call(this.player, event),
);
on.call(player, player.media, 'volumechange', event => controls.updateVolume.call(player, event));
// Handle play/pause
on.call(this.player, this.player.media, 'playing play pause ended emptied timeupdate', event =>
ui.checkPlaying.call(this.player, event),
on.call(player, player.media, 'playing play pause ended emptied timeupdate', event =>
ui.checkPlaying.call(player, event),
);
// Loading state
on.call(this.player, this.player.media, 'waiting canplay seeked playing', event =>
ui.checkLoading.call(this.player, event),
);
on.call(player, player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(player, event));
// If autoplay, then load advertisement if required
// TODO: Show some sort of loading state while the ad manager loads else there's a delay before ad shows
on.call(this.player, this.player.media, 'playing', () => {
if (!this.player.ads) {
on.call(player, player.media, 'playing', () => {
if (!player.ads) {
return;
}
// If ads are enabled, wait for them first
if (this.player.ads.enabled && !this.player.ads.initialized) {
if (player.ads.enabled && !player.ads.initialized) {
// Wait for manager response
this.player.ads.managerPromise.then(() => this.player.ads.play()).catch(() => this.player.play());
player.ads.managerPromise.then(() => player.ads.play()).catch(() => player.play());
}
});
// Click video
if (this.player.supported.ui && this.player.config.clickToPlay && !this.player.isAudio) {
if (player.supported.ui && player.config.clickToPlay && !player.isAudio) {
// Re-fetch the wrapper
const wrapper = getElement.call(this.player, `.${this.player.config.classNames.video}`);
const wrapper = getElement.call(player, `.${player.config.classNames.video}`);
// Bail if there's no wrapper (this should never happen)
if (!is.element(wrapper)) {
@ -322,28 +372,38 @@ class Listeners {
}
// On click play, pause ore restart
on.call(this.player, wrapper, 'click', () => {
// Touch devices will just show controls (if we're hiding controls)
if (this.player.config.hideControls && this.player.touch && !this.player.paused) {
on.call(player, elements.container, 'click touchstart', event => {
const targets = [elements.container, wrapper];
// Ignore if click if not container or in video wrapper
if (!targets.includes(event.target) && !wrapper.contains(event.target)) {
return;
}
if (this.player.paused) {
this.player.play();
} else if (this.player.ended) {
this.player.restart();
this.player.play();
// First touch on touch devices will just show controls (if we're hiding controls)
// If controls are shown then it'll toggle like a pointer device
if (
player.config.hideControls &&
player.touch &&
hasClass(elements.container, player.config.classNames.hideControls)
) {
return;
}
if (player.ended) {
player.restart();
player.play();
} else {
this.player.pause();
player.togglePlay();
}
});
}
// Disable right click
if (this.player.supported.ui && this.player.config.disableContextMenu) {
if (player.supported.ui && player.config.disableContextMenu) {
on.call(
this.player,
this.player.elements.wrapper,
player,
elements.wrapper,
'contextmenu',
event => {
event.preventDefault();
@ -353,220 +413,227 @@ class Listeners {
}
// Volume change
on.call(this.player, this.player.media, 'volumechange', () => {
on.call(player, player.media, 'volumechange', () => {
// Save to storage
this.player.storage.set({ volume: this.player.volume, muted: this.player.muted });
player.storage.set({
volume: player.volume,
muted: player.muted,
});
});
// Speed change
on.call(this.player, this.player.media, 'ratechange', () => {
on.call(player, player.media, 'ratechange', () => {
// Update UI
controls.updateSetting.call(this.player, 'speed');
controls.updateSetting.call(player, 'speed');
// Save to storage
this.player.storage.set({ speed: this.player.speed });
player.storage.set({ speed: player.speed });
});
// Quality request
on.call(this.player, this.player.media, 'qualityrequested', event => {
on.call(player, player.media, 'qualityrequested', event => {
// Save to storage
this.player.storage.set({ quality: event.detail.quality });
player.storage.set({ quality: event.detail.quality });
});
// Quality change
on.call(this.player, this.player.media, 'qualitychange', event => {
on.call(player, player.media, 'qualitychange', event => {
// Update UI
controls.updateSetting.call(this.player, 'quality', null, event.detail.quality);
controls.updateSetting.call(player, 'quality', null, event.detail.quality);
});
// Proxy events to container
// Bubble up key events for Edge
const proxyEvents = this.player.config.events.concat(['keyup', 'keydown']).join(' ');
on.call(this.player, this.player.media, proxyEvents, event => {
const proxyEvents = player.config.events.concat(['keyup', 'keydown']).join(' ');
on.call(player, player.media, proxyEvents, event => {
let { detail = {} } = event;
// Get error details from media
if (event.type === 'error') {
detail = this.player.media.error;
detail = player.media.error;
}
triggerEvent.call(this.player, this.player.elements.container, event.type, true, detail);
triggerEvent.call(player, elements.container, event.type, true, detail);
});
}
// Run default and custom handlers
proxy(event, defaultHandler, customHandlerKey) {
const { player } = this;
const customHandler = player.config.listeners[customHandlerKey];
const hasCustomHandler = is.function(customHandler);
let returned = true;
// Execute custom handler
if (hasCustomHandler) {
returned = customHandler.call(player, event);
}
// Only call default handler if not prevented in custom handler
if (returned && is.function(defaultHandler)) {
defaultHandler.call(player, event);
}
}
// Trigger custom and default handlers
bind(element, type, defaultHandler, customHandlerKey, passive = true) {
const { player } = this;
const customHandler = player.config.listeners[customHandlerKey];
const hasCustomHandler = is.function(customHandler);
on.call(
player,
element,
type,
event => this.proxy(event, defaultHandler, customHandlerKey),
passive && !hasCustomHandler,
);
}
// Listen for control events
controls() {
const { player } = this;
const { elements } = player;
// IE doesn't support input event, so we fallback to change
const inputEvent = browser.isIE ? 'change' : 'input';
// Run default and custom handlers
const proxy = (event, defaultHandler, customHandlerKey) => {
const customHandler = this.player.config.listeners[customHandlerKey];
const hasCustomHandler = is.function(customHandler);
let returned = true;
// Execute custom handler
if (hasCustomHandler) {
returned = customHandler.call(this.player, event);
}
// Only call default handler if not prevented in custom handler
if (returned && is.function(defaultHandler)) {
defaultHandler.call(this.player, event);
}
};
// Trigger custom and default handlers
const bind = (element, type, defaultHandler, customHandlerKey, passive = true) => {
const customHandler = this.player.config.listeners[customHandlerKey];
const hasCustomHandler = is.function(customHandler);
on.call(
this.player,
element,
type,
event => proxy(event, defaultHandler, customHandlerKey),
passive && !hasCustomHandler,
);
};
// Play/pause toggle
if (this.player.elements.buttons.play) {
Array.from(this.player.elements.buttons.play).forEach(button => {
bind(button, 'click', this.player.togglePlay, 'play');
if (elements.buttons.play) {
Array.from(elements.buttons.play).forEach(button => {
this.bind(button, 'click', player.togglePlay, 'play');
});
}
// Pause
bind(this.player.elements.buttons.restart, 'click', this.player.restart, 'restart');
this.bind(elements.buttons.restart, 'click', player.restart, 'restart');
// Rewind
bind(this.player.elements.buttons.rewind, 'click', this.player.rewind, 'rewind');
this.bind(elements.buttons.rewind, 'click', player.rewind, 'rewind');
// Rewind
bind(this.player.elements.buttons.fastForward, 'click', this.player.forward, 'fastForward');
this.bind(elements.buttons.fastForward, 'click', player.forward, 'fastForward');
// Mute toggle
bind(
this.player.elements.buttons.mute,
this.bind(
elements.buttons.mute,
'click',
() => {
this.player.muted = !this.player.muted;
player.muted = !player.muted;
},
'mute',
);
// Captions toggle
bind(this.player.elements.buttons.captions, 'click', () => this.player.toggleCaptions());
this.bind(elements.buttons.captions, 'click', () => player.toggleCaptions());
// Fullscreen toggle
bind(
this.player.elements.buttons.fullscreen,
this.bind(
elements.buttons.fullscreen,
'click',
() => {
this.player.fullscreen.toggle();
player.fullscreen.toggle();
},
'fullscreen',
);
// Picture-in-Picture
bind(
this.player.elements.buttons.pip,
this.bind(
elements.buttons.pip,
'click',
() => {
this.player.pip = 'toggle';
player.pip = 'toggle';
},
'pip',
);
// Airplay
bind(this.player.elements.buttons.airplay, 'click', this.player.airplay, 'airplay');
this.bind(elements.buttons.airplay, 'click', player.airplay, 'airplay');
// Settings menu
bind(this.player.elements.buttons.settings, 'click', event => {
controls.toggleMenu.call(this.player, event);
// Settings menu - click toggle
this.bind(elements.buttons.settings, 'click', event => {
controls.toggleMenu.call(player, event);
});
// Settings menu
bind(this.player.elements.settings.form, 'click', event => {
event.stopPropagation();
// Settings menu - keyboard toggle
// We have to bind to keyup otherwise Firefox triggers a click when a keydown event handler shifts focus
// https://bugzilla.mozilla.org/show_bug.cgi?id=1220143
this.bind(
elements.buttons.settings,
'keyup',
event => {
// We only care about space and return
if (event.which !== 32 && event.which !== 13) {
return;
}
// Go back to home tab on click
const showHomeTab = () => {
const id = `plyr-settings-${this.player.id}-home`;
controls.showTab.call(this.player, id);
};
// Prevent scroll
event.preventDefault();
// Settings menu items - use event delegation as items are added/removed
if (matches(event.target, this.player.config.selectors.inputs.language)) {
proxy(
event,
() => {
this.player.currentTrack = Number(event.target.value);
showHomeTab();
},
'language',
);
} else if (matches(event.target, this.player.config.selectors.inputs.quality)) {
proxy(
event,
() => {
this.player.quality = event.target.value;
showHomeTab();
},
'quality',
);
} else if (matches(event.target, this.player.config.selectors.inputs.speed)) {
proxy(
event,
() => {
this.player.speed = parseFloat(event.target.value);
showHomeTab();
},
'speed',
);
} else {
const tab = event.target;
controls.showTab.call(this.player, tab.getAttribute('aria-controls'));
// Prevent playing video (Firefox)
if (event.which === 32) {
event.stopPropagation();
}
// Toggle menu
controls.toggleMenu.call(player, event);
},
null,
false,
);
// Escape closes menu
this.bind(elements.settings.menu, 'keydown', event => {
if (event.which === 27) {
controls.toggleMenu.call(player, event);
}
});
// Set range input alternative "value", which matches the tooltip time (#954)
bind(this.player.elements.inputs.seek, 'mousedown mousemove', event => {
const clientRect = this.player.elements.progress.getBoundingClientRect();
const percent = 100 / clientRect.width * (event.pageX - clientRect.left);
this.bind(elements.inputs.seek, 'mousedown mousemove', event => {
const rect = elements.progress.getBoundingClientRect();
const percent = 100 / rect.width * (event.pageX - rect.left);
event.currentTarget.setAttribute('seek-value', percent);
});
// Pause while seeking
bind(this.player.elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => {
this.bind(elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => {
const seek = event.currentTarget;
const code = event.keyCode ? event.keyCode : event.which;
const eventType = event.type;
const attribute = 'play-on-seeked';
if ((eventType === 'keydown' || eventType === 'keyup') && (code !== 39 && code !== 37)) {
if (is.keyboardEvent(event) && (code !== 39 && code !== 37)) {
return;
}
// Was playing before?
const play = seek.hasAttribute('play-on-seeked');
const play = seek.hasAttribute(attribute);
// Done seeking
const done = ['mouseup', 'touchend', 'keyup'].includes(event.type);
// If we're done seeking and it was playing, resume playback
if (play && done) {
seek.removeAttribute('play-on-seeked');
this.player.play();
} else if (!done && this.player.playing) {
seek.setAttribute('play-on-seeked', '');
this.player.pause();
seek.removeAttribute(attribute);
player.play();
} else if (!done && player.playing) {
seek.setAttribute(attribute, '');
player.pause();
}
});
// Fix range inputs on iOS
// Super weird iOS bug where after you interact with an <input type="range">,
// it takes over further interactions on the page. This is a hack
if (browser.isIos) {
const inputs = getElements.call(player, 'input[type="range"]');
Array.from(inputs).forEach(input => this.bind(input, inputEvent, event => repaint(event.target)));
}
// Seek
bind(
this.player.elements.inputs.seek,
this.bind(
elements.inputs.seek,
inputEvent,
event => {
const seek = event.currentTarget;
@ -580,70 +647,71 @@ class Listeners {
seek.removeAttribute('seek-value');
this.player.currentTime = seekTo / seek.max * this.player.duration;
player.currentTime = seekTo / seek.max * player.duration;
},
'seek',
);
// Current time invert
// Only if one time element is used for both currentTime and duration
if (this.player.config.toggleInvert && !is.element(this.player.elements.display.duration)) {
bind(this.player.elements.display.currentTime, 'click', () => {
// Do nothing if we're at the start
if (this.player.currentTime === 0) {
return;
}
this.player.config.invertTime = !this.player.config.invertTime;
controls.timeUpdate.call(this.player);
});
}
// Volume
bind(
this.player.elements.inputs.volume,
inputEvent,
event => {
this.player.volume = event.target.value;
},
'volume',
// Seek tooltip
this.bind(elements.progress, 'mouseenter mouseleave mousemove', event =>
controls.updateSeekTooltip.call(player, event),
);
// Polyfill for lower fill in <input type="range"> for webkit
if (browser.isWebkit) {
Array.from(getElements.call(this.player, 'input[type="range"]')).forEach(element => {
bind(element, 'input', event => controls.updateRangeFill.call(this.player, event.target));
Array.from(getElements.call(player, 'input[type="range"]')).forEach(element => {
this.bind(element, 'input', event => controls.updateRangeFill.call(player, event.target));
});
}
// Seek tooltip
bind(this.player.elements.progress, 'mouseenter mouseleave mousemove', event =>
controls.updateSeekTooltip.call(this.player, event),
// Current time invert
// Only if one time element is used for both currentTime and duration
if (player.config.toggleInvert && !is.element(elements.display.duration)) {
this.bind(elements.display.currentTime, 'click', () => {
// Do nothing if we're at the start
if (player.currentTime === 0) {
return;
}
player.config.invertTime = !player.config.invertTime;
controls.timeUpdate.call(player);
});
}
// Volume
this.bind(
elements.inputs.volume,
inputEvent,
event => {
player.volume = event.target.value;
},
'volume',
);
// Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)
bind(this.player.elements.controls, 'mouseenter mouseleave', event => {
this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter';
this.bind(elements.controls, 'mouseenter mouseleave', event => {
elements.controls.hover = !player.touch && event.type === 'mouseenter';
});
// Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)
bind(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
this.player.elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);
this.bind(elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);
});
// Focus in/out on controls
bind(this.player.elements.controls, 'focusin focusout', event => {
const { config, elements, timers } = this.player;
this.bind(elements.controls, 'focusin focusout', event => {
const { config, elements, timers } = player;
const isFocusIn = event.type === 'focusin';
// Skip transition to prevent focus from scrolling the parent element
toggleClass(elements.controls, config.classNames.noTransition, event.type === 'focusin');
toggleClass(elements.controls, config.classNames.noTransition, isFocusIn);
// Toggle
ui.toggleControls.call(this.player, event.type === 'focusin');
ui.toggleControls.call(player, isFocusIn);
// If focusin, hide again after delay
if (event.type === 'focusin') {
if (isFocusIn) {
// Restore transition
setTimeout(() => {
toggleClass(elements.controls, config.classNames.noTransition, false);
@ -654,14 +722,15 @@ class Listeners {
// Clear timer
clearTimeout(timers.controls);
// Hide
timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay);
timers.controls = setTimeout(() => ui.toggleControls.call(player, false), delay);
}
});
// Mouse wheel for volume
bind(
this.player.elements.inputs.volume,
this.bind(
elements.inputs.volume,
'wheel',
event => {
// Detect "natural" scroll - suppored on OS X Safari only
@ -675,10 +744,10 @@ class Listeners {
const direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y);
// Change the volume by 2%
this.player.increaseVolume(direction / 50);
player.increaseVolume(direction / 50);
// Don't break page scrolling at max and min
const { volume } = this.player.media;
const { volume } = player.media;
if ((direction === 1 && volume < 1) || (direction === -1 && volume > 0)) {
event.preventDefault();
}

View File

@ -207,6 +207,11 @@ class Ads {
* @param {Event} adsManagerLoadedEvent
*/
onAdsManagerLoaded(event) {
// Load could occur after a source change (race condition)
if (!this.enabled) {
return;
}
// Get the ads manager
const settings = new google.ima.AdsRenderingSettings();
@ -240,10 +245,6 @@ class Ads {
});
}
// Get skippable state
// TODO: Skip button
// this.player.debug.warn(this.manager.getAdSkippableState());
// Set volume to match player
this.manager.setVolume(this.player.volume);

View File

@ -75,16 +75,17 @@ class Plyr {
// Elements cache
this.elements = {
container: null,
captions: null,
buttons: {},
display: {},
progress: {},
inputs: {},
settings: {
popup: null,
menu: null,
panes: {},
tabs: {},
panels: {},
buttons: {},
},
captions: null,
};
// Captions
@ -185,7 +186,7 @@ class Plyr {
// YouTube requires the playsinline in the URL
if (this.isYouTube) {
this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));
this.config.hl = url.searchParams.get('hl');
this.config.hl = url.searchParams.get('hl'); // TODO: Should this be setting language?
} else {
this.config.playsinline = true;
}
@ -221,7 +222,7 @@ class Plyr {
if (this.media.hasAttribute('autoplay')) {
this.config.autoplay = true;
}
if (this.media.hasAttribute('playsinline')) {
if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) {
this.config.playsinline = true;
}
if (this.media.hasAttribute('muted')) {
@ -293,7 +294,9 @@ class Plyr {
this.fullscreen = new Fullscreen(this);
// Setup ads if provided
this.ads = new Ads(this);
if (this.config.ads.enabled) {
this.ads = new Ads(this);
}
// Autoplay if required
if (this.config.autoplay) {
@ -696,7 +699,9 @@ class Plyr {
}
// Trigger request event
triggerEvent.call(this, this.media, 'qualityrequested', false, { quality });
triggerEvent.call(this, this.media, 'qualityrequested', false, {
quality,
});
// Update config
config.selected = quality;
@ -933,13 +938,16 @@ class Plyr {
if (hiding && this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
controls.toggleMenu.call(this, false);
}
// Trigger event on change
if (hiding !== isHidden) {
const eventName = hiding ? 'controlshidden' : 'controlsshown';
triggerEvent.call(this, this.media, eventName);
}
return !hiding;
}
return false;
}

View File

@ -15,7 +15,9 @@ export const transitionEndEvent = (() => {
transition: 'transitionend',
};
const type = Object.keys(events).find(event => element.style[event] !== undefined);
const type = Object.keys(events).find(
event => element.style[event] !== undefined,
);
return is.string(type) ? events[type] : false;
})();
@ -23,8 +25,12 @@ export const transitionEndEvent = (() => {
// Force repaint of element
export function repaint(element) {
setTimeout(() => {
toggleHidden(element, true);
element.offsetHeight; // eslint-disable-line
toggleHidden(element, false);
try {
toggleHidden(element, true);
element.offsetHeight; // eslint-disable-line
toggleHidden(element, false);
} catch (e) {
// Do nothing
}
}, 0);
}

View File

@ -70,12 +70,19 @@ export function createElement(type, attributes, text) {
// Inaert an element after another
export function insertAfter(element, target) {
if (!is.element(element) || !is.element(target)) {
return;
}
target.parentNode.insertBefore(element, target.nextSibling);
}
// Insert a DocumentFragment
export function insertElement(type, parent, attributes, text) {
// Inject the new <element>
if (!is.element(parent)) {
return;
}
parent.appendChild(createElement(type, attributes, text));
}
@ -95,6 +102,10 @@ export function removeElement(element) {
// Remove all child elements
export function emptyElement(element) {
if (!is.element(element)) {
return;
}
let { length } = element.childNodes;
while (length > 0) {
@ -105,7 +116,11 @@ export function emptyElement(element) {
// Replace element
export function replaceElement(newChild, oldChild) {
if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) {
if (
!is.element(oldChild) ||
!is.element(oldChild.parentNode) ||
!is.element(newChild)
) {
return null;
}
@ -192,6 +207,10 @@ export function toggleHidden(element, hidden) {
// Mirror Element.classList.toggle, with IE compatibility for "force" argument
export function toggleClass(element, className, force) {
if (is.nodeList(element)) {
return Array.from(element).map(e => toggleClass(e, className, force));
}
if (is.element(element)) {
let method = 'toggle';
if (typeof force !== 'undefined') {
@ -202,7 +221,7 @@ export function toggleClass(element, className, force) {
return element.classList.contains(className);
}
return null;
return false;
}
// Has class name
@ -238,26 +257,16 @@ export function getElement(selector) {
return this.elements.container.querySelector(selector);
}
// Get the focused element
export function getFocusElement() {
let focused = document.activeElement;
if (!focused || focused === document.body) {
focused = null;
} else {
focused = document.querySelector(':focus');
}
return focused;
}
// Trap focus inside container
export function trapFocus(element = null, toggle = false) {
if (!is.element(element)) {
return;
}
const focusable = getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
const focusable = getElements.call(
this,
'button:not(:disabled), input:not(:disabled), [tabindex]',
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
@ -268,7 +277,7 @@ export function trapFocus(element = null, toggle = false) {
}
// Get the current focused element
const focused = getFocusElement();
const focused = document.activeElement;
if (focused === last && !event.shiftKey) {
// Move focus to first element that can be tabbed if Shift isn't used
@ -281,5 +290,27 @@ export function trapFocus(element = null, toggle = false) {
}
};
toggleListener.call(this, this.elements.container, 'keydown', trap, toggle, false);
toggleListener.call(
this,
this.elements.container,
'keydown',
trap,
toggle,
false,
);
}
// Set focus and tab focus class
export function setFocus(element = null, tabFocus = false) {
if (!is.element(element)) {
return;
}
// Set regular focus
element.focus();
// If we want to mimic keyboard focus via tab
if (tabFocus) {
toggleClass(element, this.config.classNames.tabFocus);
}
}

View File

@ -16,6 +16,7 @@ const isNodeList = input => instanceOf(input, NodeList);
const isElement = input => instanceOf(input, Element);
const isTextNode = input => getConstructor(input) === Text;
const isEvent = input => instanceOf(input, Event);
const isKeyboardEvent = input => instanceOf(input, KeyboardEvent);
const isCue = input => instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue);
const isTrack = input => instanceOf(input, TextTrack) || (!isNullOrUndefined(input) && isString(input.kind));
@ -56,6 +57,7 @@ export default {
element: isElement,
textNode: isTextNode,
event: isEvent,
keyboardEvent: isKeyboardEvent,
cue: isCue,
track: isTrack,
url: isUrl,

View File

@ -41,7 +41,7 @@
display: none;
}
// Audio styles
// Audio control
.plyr--audio .plyr__control {
&.plyr__tab-focus,
&:hover,
@ -51,6 +51,21 @@
}
}
// Video control
.plyr--video .plyr__control {
svg {
filter: drop-shadow(0 1px 1px rgba(#000, 0.15));
}
// Hover and tab focus
&.plyr__tab-focus,
&:hover,
&[aria-expanded='true'] {
background: $plyr-video-control-bg-hover;
color: $plyr-video-control-color-hover;
}
}
// Large play button (video only)
.plyr__control--overlaid {
background: rgba($plyr-video-control-bg-hover, 0.8);

View File

@ -2,11 +2,6 @@
// Controls
// --------------------------------------------------------------
// Hide empty controls
.plyr__controls:empty {
display: none;
}
// Hide native controls
.plyr--full-ui ::-webkit-media-controls {
display: none;
@ -37,6 +32,11 @@
margin-left: ($plyr-control-spacing / 2);
}
// Hide empty controls
&:empty {
display: none;
}
@media (min-width: $plyr-bp-sm) {
> .plyr__control,
.plyr__progress,
@ -53,6 +53,14 @@
}
}
// Audio controls
.plyr--audio .plyr__controls {
background: $plyr-audio-controls-bg;
border-radius: inherit;
color: $plyr-audio-control-color;
padding: $plyr-control-spacing;
}
// Video controls
.plyr--video .plyr__controls {
background: linear-gradient(
@ -69,32 +77,10 @@
position: absolute;
right: 0;
transition: opacity 0.4s ease-in-out, transform 0.4s ease-in-out;
z-index: 2;
.plyr__control {
svg {
filter: drop-shadow(0 1px 1px rgba(#000, 0.15));
}
// Hover and tab focus
&.plyr__tab-focus,
&:hover,
&[aria-expanded='true'] {
background: $plyr-video-control-bg-hover;
color: $plyr-video-control-color-hover;
}
}
z-index: 3;
}
// Audio controls
.plyr--audio .plyr__controls {
background: $plyr-audio-controls-bg;
border-radius: inherit;
color: $plyr-audio-control-color;
padding: $plyr-control-spacing;
}
// Hide controls
// Hide video controls
.plyr--video.plyr--hide-controls .plyr__controls {
opacity: 0;
pointer-events: none;

View File

@ -39,7 +39,8 @@
> div {
overflow: hidden;
transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1),
width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
// Arrow
@ -54,18 +55,16 @@
width: 0;
}
ul {
list-style: none;
margin: 0;
overflow: hidden;
[role='menu'] {
padding: $plyr-control-padding;
}
li {
margin-top: 2px;
[role='menuitem'],
[role='menuitemradio'] {
margin-top: 2px;
&:first-child {
margin-top: 0;
}
&:first-child {
margin-top: 0;
}
}
@ -75,10 +74,17 @@
color: $plyr-menu-color;
display: flex;
font-size: $plyr-font-size-menu;
padding: ceil($plyr-control-padding / 2) ($plyr-control-padding * 2);
padding: ceil($plyr-control-padding / 2)
ceil($plyr-control-padding * 1.5);
user-select: none;
width: 100%;
> span {
align-items: inherit;
display: flex;
width: 100%;
}
&::after {
border: 4px solid transparent;
content: '';
@ -135,50 +141,49 @@
}
}
label.plyr__control {
.plyr__control[role='menuitemradio'] {
padding-left: $plyr-control-padding;
input[type='radio'] + span {
background: rgba(#000, 0.1);
&::before,
&::after {
border-radius: 100%;
}
&::before {
background: rgba(#000, 0.1);
content: '';
display: block;
flex-shrink: 0;
height: 16px;
margin-right: $plyr-control-spacing;
position: relative;
transition: all 0.3s ease;
width: 16px;
&::after {
background: #fff;
border-radius: 100%;
content: '';
height: 6px;
left: 5px;
opacity: 0;
position: absolute;
top: 5px;
transform: scale(0);
transition: transform 0.3s ease, opacity 0.3s ease;
width: 6px;
}
}
input[type='radio']:checked + span {
background: $plyr-color-main;
&::after {
background: #fff;
border: 0;
height: 6px;
left: 12px;
opacity: 0;
top: 50%;
transform: translateY(-50%) scale(0);
transition: transform 0.3s ease, opacity 0.3s ease;
width: 6px;
}
&[aria-checked='true'] {
&::before {
background: $plyr-color-main;
}
&::after {
opacity: 1;
transform: scale(1);
transform: translateY(-50%) scale(1);
}
}
input[type='radio']:focus + span {
@include plyr-tab-focus();
}
&.plyr__tab-focus input[type='radio'] + span,
&:hover input[type='radio'] + span {
&.plyr__tab-focus::before,
&:hover::before {
background: rgba(#000, 0.1);
}
}
@ -188,7 +193,7 @@
align-items: center;
display: flex;
margin-left: auto;
margin-right: -$plyr-control-padding;
margin-right: -($plyr-control-padding - 2);
overflow: hidden;
padding-left: ceil($plyr-control-padding * 3.5);
pointer-events: none;

View File

@ -12,12 +12,11 @@
opacity: 0;
position: absolute;
top: 0;
transition: opacity 0.3s ease;
transition: opacity 0.2s ease;
width: 100%;
z-index: 1;
}
.plyr--stopped.plyr__poster-enabled .plyr__poster {
opacity: 1;
pointer-events: none;
}

View File

@ -3,7 +3,6 @@
// --------------------------------------------------------------
.plyr__progress {
display: flex;
flex: 1;
left: $plyr-range-thumb-height / 2;
margin-right: $plyr-range-thumb-height;

View File

@ -19,7 +19,11 @@
&::-webkit-slider-runnable-track {
@include plyr-range-track();
background-image: linear-gradient(to right, currentColor var(--value, 0%), transparent var(--value, 0%));
background-image: linear-gradient(
to right,
currentColor var(--value, 0%),
transparent var(--value, 0%)
);
}
&::-webkit-slider-thumb {
@ -140,15 +144,21 @@
// Pressed styles
&:active {
&::-webkit-slider-thumb {
@include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color);
@include plyr-range-thumb-active(
$plyr-audio-range-thumb-shadow-color
);
}
&::-moz-range-thumb {
@include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color);
@include plyr-range-thumb-active(
$plyr-audio-range-thumb-shadow-color
);
}
&::-ms-thumb {
@include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color);
@include plyr-range-thumb-active(
$plyr-audio-range-thumb-shadow-color
);
}
}
}

View File

@ -5,7 +5,7 @@
// Nicer focus styles
// ---------------------------------------
@mixin plyr-tab-focus($color: $plyr-tab-focus-default-color) {
box-shadow: 0 0 0 3px rgba($color, 0.35);
box-shadow: 0 0 0 5px rgba($color, 0.5);
outline: 0;
}
@ -28,6 +28,7 @@
border: 0;
border-radius: ($plyr-range-track-height / 2);
height: $plyr-range-track-height;
transition: box-shadow 0.3s ease;
user-select: none;
}
@ -36,7 +37,6 @@
border: 0;
border-radius: 100%;
box-shadow: $plyr-range-thumb-shadow;
box-sizing: border-box;
height: $plyr-range-thumb-height;
position: relative;
transition: all 0.2s ease;

View File

@ -22,3 +22,7 @@
width: 1px;
}
}
.plyr [hidden] {
display: none !important;
}

146
yarn.lock
View File

@ -407,6 +407,17 @@ autoprefixer@^8.0.0:
postcss "^6.0.19"
postcss-value-parser "^3.2.3"
autoprefixer@^9.0.0:
version "9.0.1"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.0.1.tgz#b5b74aba3fa60b4f1403729e46a6a1246f16818f"
dependencies:
browserslist "^4.0.1"
caniuse-lite "^1.0.30000865"
normalize-range "^0.1.2"
num2fraction "^1.2.2"
postcss "^7.0.1"
postcss-value-parser "^3.2.3"
aws-sign2@~0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.6.0.tgz#14342dd38dbcc94d0e5b87d763cd63612c0e794f"
@ -1061,6 +1072,14 @@ browserslist@^3.2.6:
caniuse-lite "^1.0.30000844"
electron-to-chromium "^1.3.47"
browserslist@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.0.1.tgz#61c05ce2a5843c7d96166408bc23d58b5416e818"
dependencies:
caniuse-lite "^1.0.30000865"
electron-to-chromium "^1.3.52"
node-releases "^1.0.0-alpha.10"
buffer-from@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.0.tgz#87fcaa3a298358e0ade6e442cfce840740d1ad04"
@ -1136,6 +1155,10 @@ caniuse-lite@^1.0.30000844:
version "1.0.30000847"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000847.tgz#be77f439be29bbc57ae08004b1e470b653b1ec1d"
caniuse-lite@^1.0.30000865:
version "1.0.30000865"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000865.tgz#70026616e8afe6e1442f8bb4e1092987d81a2f25"
capture-stack-trace@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.0.tgz#4a6fa07399c26bba47f0b2496b4d0fb408c5550d"
@ -1597,9 +1620,9 @@ currently-unhandled@^0.4.1:
dependencies:
array-find-index "^1.0.1"
custom-event-polyfill@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-0.3.0.tgz#99807839be62edb446b645832e0d80ead6fa1888"
custom-event-polyfill@^1.0.6:
version "1.0.6"
resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-1.0.6.tgz#6b026e81cd9f7bc896bd6b016a427407bb068db1"
d@1:
version "1.0.0"
@ -1859,6 +1882,10 @@ electron-to-chromium@^1.3.47:
version "1.3.48"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.48.tgz#d3b0d8593814044e092ece2108fc3ac9aea4b900"
electron-to-chromium@^1.3.52:
version "1.3.52"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.52.tgz#d2d9f1270ba4a3b967b831c40ef71fb4d9ab5ce0"
"emoji-regex@>=6.0.0 <=6.1.1":
version "6.1.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e"
@ -2044,9 +2071,9 @@ eslint@^4.0.0:
table "4.0.2"
text-table "~0.2.0"
eslint@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.1.0.tgz#2ed611f1ce163c0fb99e1e0cda5af8f662dff645"
eslint@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.2.0.tgz#3901ae249195d473e633c4acbc370068b1c964dc"
dependencies:
ajv "^6.5.0"
babel-code-frame "^6.26.0"
@ -2064,7 +2091,7 @@ eslint@^5.1.0:
functional-red-black-tree "^1.0.1"
glob "^7.1.2"
globals "^11.7.0"
ignore "^3.3.3"
ignore "^4.0.2"
imurmurhash "^0.1.4"
inquirer "^5.2.0"
is-resolvable "^1.1.0"
@ -2889,9 +2916,9 @@ gulp-postcss@^7.0.1:
postcss-load-config "^1.2.0"
vinyl-sourcemaps-apply "^0.2.1"
gulp-rename@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-1.3.0.tgz#2e789d8f563ab0c924eeb62967576f37ff4cb826"
gulp-rename@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/gulp-rename/-/gulp-rename-1.4.0.tgz#de1c718e7c4095ae861f7296ef4f3248648240bd"
gulp-replace@^1.0.0:
version "1.0.0"
@ -3238,6 +3265,10 @@ ignore@^3.3.3, ignore@^3.3.5:
version "3.3.7"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021"
ignore@^4.0.0, ignore@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.2.tgz#0a8dd228947ec78c2d7f736b1642a9f7317c1905"
import-lazy@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
@ -4634,6 +4665,12 @@ node-pre-gyp@^0.10.0:
semver "^5.3.0"
tar "^4"
node-releases@^1.0.0-alpha.10:
version "1.0.0-alpha.10"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.0.0-alpha.10.tgz#61c8d5f9b5b2e05d84eba941d05b6f5202f68a2a"
dependencies:
semver "^5.3.0"
node-sass@^4.8.3:
version "4.8.3"
resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.8.3.tgz#d077cc20a08ac06f661ca44fb6f19cd2ed41debb"
@ -5144,9 +5181,9 @@ postcss-html@^0.15.0:
remark "^9.0.0"
unist-util-find-all-after "^1.0.1"
postcss-html@^0.28.0:
version "0.28.0"
resolved "https://registry.yarnpkg.com/postcss-html/-/postcss-html-0.28.0.tgz#3dd0f5b5d7f886b8181bf844396d43a7898162cb"
postcss-html@^0.31.0:
version "0.31.0"
resolved "https://registry.yarnpkg.com/postcss-html/-/postcss-html-0.31.0.tgz#ea6ae2e95df60a03032e9ab5aba72143d8ca0325"
dependencies:
htmlparser2 "^3.9.2"
@ -5185,9 +5222,9 @@ postcss-load-plugins@^2.3.0:
cosmiconfig "^2.1.1"
object-assign "^4.1.0"
postcss-markdown@^0.28.0:
version "0.28.0"
resolved "https://registry.yarnpkg.com/postcss-markdown/-/postcss-markdown-0.28.0.tgz#99d1c4e74967af9e9c98acb2e2b66df4b3c6ed86"
postcss-markdown@^0.31.0:
version "0.31.0"
resolved "https://registry.yarnpkg.com/postcss-markdown/-/postcss-markdown-0.31.0.tgz#e4c699ad34b14a29ad5d47132bb1b3100b60ef75"
dependencies:
remark "^9.0.0"
unist-util-find-all-after "^1.0.2"
@ -5215,6 +5252,12 @@ postcss-safe-parser@^3.0.1:
dependencies:
postcss "^6.0.6"
postcss-safe-parser@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-4.0.1.tgz#8756d9e4c36fdce2c72b091bbc8ca176ab1fcdea"
dependencies:
postcss "^7.0.0"
postcss-sass@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/postcss-sass/-/postcss-sass-0.2.0.tgz#e55516441e9526ba4b380a730d3a02e9eaa78c7a"
@ -5235,6 +5278,12 @@ postcss-scss@^1.0.2:
dependencies:
postcss "^6.0.19"
postcss-scss@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-2.0.0.tgz#248b0a28af77ea7b32b1011aba0f738bda27dea1"
dependencies:
postcss "^7.0.0"
postcss-selector-parser@^3.1.0, postcss-selector-parser@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-3.1.1.tgz#4f875f4afb0c96573d5cf4d74011aee250a7e865"
@ -5258,9 +5307,13 @@ postcss-sorting@^3.1.0:
lodash "^4.17.4"
postcss "^6.0.13"
postcss-syntax@^0.28.0:
version "0.28.0"
resolved "https://registry.yarnpkg.com/postcss-syntax/-/postcss-syntax-0.28.0.tgz#e17572a7dcf5388f0c9b68232d2dad48fa7f0b12"
postcss-styled@^0.31.0:
version "0.31.0"
resolved "https://registry.yarnpkg.com/postcss-styled/-/postcss-styled-0.31.0.tgz#ab532a2b3c469dfcca306a7623c4d4a98bb077d5"
postcss-syntax@^0.31.0:
version "0.31.0"
resolved "https://registry.yarnpkg.com/postcss-syntax/-/postcss-syntax-0.31.0.tgz#13d955c705d339595d10a19efa4a1bee82dfb78f"
postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0:
version "3.3.0"
@ -5299,6 +5352,14 @@ postcss@^6.0.17:
source-map "^0.6.1"
supports-color "^5.3.0"
postcss@^7.0.0, postcss@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.1.tgz#db20ca4fc90aa56809674eea75864148c66b67fa"
dependencies:
chalk "^2.4.1"
source-map "^0.6.1"
supports-color "^5.4.0"
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@ -5420,9 +5481,9 @@ randomatic@^1.1.3:
is-number "^3.0.0"
kind-of "^4.0.0"
raven-js@^3.26.3:
version "3.26.3"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.26.3.tgz#0efb49969b5b11ab965f7b0d6da4ca102b763cb0"
raven-js@^3.26.4:
version "3.26.4"
resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.26.4.tgz#32aae3a63a9314467a453c94c89a364ea43707be"
rc@^1.0.1, rc@^1.1.6:
version "1.2.6"
@ -5948,9 +6009,9 @@ rollup-plugin-babel@^3.0.7:
dependencies:
rollup-pluginutils "^1.5.0"
rollup-plugin-commonjs@^9.1.3:
version "9.1.3"
resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.3.tgz#37bfbf341292ea14f512438a56df8f9ca3ba4d67"
rollup-plugin-commonjs@^9.1.4:
version "9.1.4"
resolved "https://registry.yarnpkg.com/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.4.tgz#525b701adfd40e314b5bb6888d88edc28e10442f"
dependencies:
estree-walker "^0.5.1"
magic-string "^0.22.4"
@ -6256,6 +6317,10 @@ specificity@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.3.2.tgz#99e6511eceef0f8d9b57924937aac2cb13d13c42"
specificity@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.0.tgz#301b1ab5455987c37d6d94f8c956ef9d9fb48c1d"
split-string@^3.0.1, split-string@^3.0.2:
version "3.1.0"
resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@ -6467,9 +6532,9 @@ stylelint-scss@^2.0.0:
postcss-selector-parser "^3.1.1"
postcss-value-parser "^3.3.0"
stylelint-scss@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-3.1.3.tgz#28f881ae298c3f5db667b10b6cf94a1a219001d6"
stylelint-scss@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-3.2.0.tgz#13545a1be5ab5435ea94e761b2d4824eb32033b3"
dependencies:
lodash "^4.17.10"
postcss-media-query-parser "^0.2.3"
@ -6575,11 +6640,11 @@ stylelint@^8.1.1:
svg-tags "^1.0.0"
table "^4.0.1"
stylelint@^9.3.0:
version "9.3.0"
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-9.3.0.tgz#fe176e4e421ac10eac1a6b6d9f28e908eb58c5db"
stylelint@^9.4.0:
version "9.4.0"
resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-9.4.0.tgz#2f2b82ae9db53a06735ae0724f41b134fdb84a10"
dependencies:
autoprefixer "^8.0.0"
autoprefixer "^9.0.0"
balanced-match "^1.0.0"
chalk "^2.4.1"
cosmiconfig "^5.0.0"
@ -6590,7 +6655,7 @@ stylelint@^9.3.0:
globby "^8.0.0"
globjoin "^0.1.4"
html-tags "^2.0.0"
ignore "^3.3.3"
ignore "^4.0.0"
import-lazy "^3.1.0"
imurmurhash "^0.1.4"
known-css-properties "^0.6.0"
@ -6601,22 +6666,23 @@ stylelint@^9.3.0:
micromatch "^2.3.11"
normalize-selector "^0.2.0"
pify "^3.0.0"
postcss "^6.0.16"
postcss-html "^0.28.0"
postcss "^7.0.0"
postcss-html "^0.31.0"
postcss-less "^2.0.0"
postcss-markdown "^0.28.0"
postcss-markdown "^0.31.0"
postcss-media-query-parser "^0.2.3"
postcss-reporter "^5.0.0"
postcss-resolve-nested-selector "^0.1.1"
postcss-safe-parser "^3.0.1"
postcss-safe-parser "^4.0.0"
postcss-sass "^0.3.0"
postcss-scss "^1.0.2"
postcss-scss "^2.0.0"
postcss-selector-parser "^3.1.0"
postcss-syntax "^0.28.0"
postcss-styled "^0.31.0"
postcss-syntax "^0.31.0"
postcss-value-parser "^3.3.0"
resolve-from "^4.0.0"
signal-exit "^3.0.2"
specificity "^0.3.1"
specificity "^0.4.0"
string-width "^2.1.0"
style-search "^0.1.0"
sugarss "^1.0.0"