aboutsummaryrefslogtreecommitdiffstats
path: root/src/js/controls.js
diff options
context:
space:
mode:
authorSam Potts <sam@potts.es>2019-01-29 21:34:40 +1100
committerSam Potts <sam@potts.es>2019-01-29 21:34:40 +1100
commitb1da599b5d5891dc1dca44bd6aa9d8d03872fdcb (patch)
treec799fb2b444482f6d99dcdf3f16a957b290888c0 /src/js/controls.js
parentafc969bac322f9b17dc0554a65fa848eb998c8e6 (diff)
parentb798368ba68853558819d79a995aa0deec27f95e (diff)
downloadplyr-b1da599b5d5891dc1dca44bd6aa9d8d03872fdcb.tar.lz
plyr-b1da599b5d5891dc1dca44bd6aa9d8d03872fdcb.tar.xz
plyr-b1da599b5d5891dc1dca44bd6aa9d8d03872fdcb.zip
Merge branch 'develop' into beta
Diffstat (limited to 'src/js/controls.js')
-rw-r--r--src/js/controls.js1536
1 files changed, 946 insertions, 590 deletions
diff --git a/src/js/controls.js b/src/js/controls.js
index ec64977e..953ca293 100644
--- a/src/js/controls.js
+++ b/src/js/controls.js
@@ -1,40 +1,38 @@
// ==========================================================================
// Plyr controls
+// TODO: This needs to be split into smaller files and cleaned up
// ==========================================================================
import captions from './captions';
import html5 from './html5';
-import i18n from './i18n';
import support from './support';
-import ui from './ui';
-import utils from './utils';
-
-// Sniff out the browser
-const browser = utils.getBrowser();
-
+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 { off, on } from './utils/events';
+import i18n from './utils/i18n';
+import is from './utils/is';
+import loadSprite from './utils/loadSprite';
+import { extend } from './utils/objects';
+import { getPercentage, replaceAll, toCamelCase, toTitleCase } from './utils/strings';
+import { formatTime, getHours } from './utils/time';
+
+// TODO: Don't export a massive object - break down and create class
const controls = {
- // Webkit polyfill for lower fill range
- updateRangeFill(target) {
- // Get range from event if event passed
- const range = utils.is.event(target) ? target.target : target;
-
- // Needs to be a valid <input type='range'>
- if (!utils.is.element(range) || range.getAttribute('type') !== 'range') {
- return;
- }
-
- // Set aria value for https://github.com/sampotts/plyr/issues/905
- range.setAttribute('aria-valuenow', range.value);
-
- // WebKit only
- if (!browser.isWebkit) {
- return;
- }
-
- // Set CSS custom property
- range.style.setProperty('--value', `${range.value / range.max * 100}%`);
- },
-
// Get icon URL
getIconUrl() {
const url = new URL(this.config.iconUrl, window.location);
@@ -46,46 +44,47 @@ const controls = {
};
},
- // Find the UI controls and store references in custom controls
- // TODO: Allow settings menus with custom controls
+ // Find the UI controls
findElements() {
try {
- this.elements.controls = utils.getElement.call(this, this.config.selectors.controls.wrapper);
+ this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);
// Buttons
this.elements.buttons = {
- play: utils.getElements.call(this, this.config.selectors.buttons.play),
- pause: utils.getElement.call(this, this.config.selectors.buttons.pause),
- restart: utils.getElement.call(this, this.config.selectors.buttons.restart),
- rewind: utils.getElement.call(this, this.config.selectors.buttons.rewind),
- fastForward: utils.getElement.call(this, this.config.selectors.buttons.fastForward),
- mute: utils.getElement.call(this, this.config.selectors.buttons.mute),
- pip: utils.getElement.call(this, this.config.selectors.buttons.pip),
- airplay: utils.getElement.call(this, this.config.selectors.buttons.airplay),
- settings: utils.getElement.call(this, this.config.selectors.buttons.settings),
- captions: utils.getElement.call(this, this.config.selectors.buttons.captions),
- fullscreen: utils.getElement.call(this, this.config.selectors.buttons.fullscreen),
+ play: getElements.call(this, this.config.selectors.buttons.play),
+ pause: getElement.call(this, this.config.selectors.buttons.pause),
+ restart: getElement.call(this, this.config.selectors.buttons.restart),
+ rewind: getElement.call(this, this.config.selectors.buttons.rewind),
+ fastForward: getElement.call(this, this.config.selectors.buttons.fastForward),
+ mute: getElement.call(this, this.config.selectors.buttons.mute),
+ pip: getElement.call(this, this.config.selectors.buttons.pip),
+ airplay: getElement.call(this, this.config.selectors.buttons.airplay),
+ settings: getElement.call(this, this.config.selectors.buttons.settings),
+ captions: getElement.call(this, this.config.selectors.buttons.captions),
+ fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen),
};
// Progress
- this.elements.progress = utils.getElement.call(this, this.config.selectors.progress);
+ this.elements.progress = getElement.call(this, this.config.selectors.progress);
// Inputs
this.elements.inputs = {
- seek: utils.getElement.call(this, this.config.selectors.inputs.seek),
- volume: utils.getElement.call(this, this.config.selectors.inputs.volume),
+ seek: getElement.call(this, this.config.selectors.inputs.seek),
+ volume: getElement.call(this, this.config.selectors.inputs.volume),
};
// Display
this.elements.display = {
- buffer: utils.getElement.call(this, this.config.selectors.display.buffer),
- currentTime: utils.getElement.call(this, this.config.selectors.display.currentTime),
- duration: utils.getElement.call(this, this.config.selectors.display.duration),
+ buffer: getElement.call(this, this.config.selectors.display.buffer),
+ currentTime: getElement.call(this, this.config.selectors.display.currentTime),
+ duration: getElement.call(this, this.config.selectors.display.duration),
};
// Seek tooltip
- if (utils.is.element(this.elements.progress)) {
- this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`);
+ if (is.element(this.elements.progress)) {
+ this.elements.display.seekTooltip = this.elements.progress.querySelector(
+ `.${this.config.classNames.tooltip}`,
+ );
}
return true;
@@ -108,9 +107,9 @@ const controls = {
// Create <svg>
const icon = document.createElementNS(namespace, 'svg');
- utils.setAttributes(
+ setAttributes(
icon,
- utils.extend(attributes, {
+ extend(attributes, {
role: 'presentation',
focusable: 'false',
}),
@@ -125,10 +124,11 @@ const controls = {
// https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href
if ('href' in use) {
use.setAttributeNS('http://www.w3.org/1999/xlink', 'href', path);
- } else {
- use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path);
}
+ // Always set the older attribute even though it's "deprecated" (it'll be around for ages)
+ use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', path);
+
// Add <use> to <svg>
icon.appendChild(use);
@@ -136,44 +136,28 @@ const controls = {
},
// Create hidden text label
- createLabel(type, attr) {
- let text = i18n.get(type, this.config);
- const attributes = Object.assign({}, attr);
-
- switch (type) {
- case 'pip':
- text = 'PIP';
- break;
-
- case 'airplay':
- text = 'AirPlay';
- break;
+ createLabel(key, attr = {}) {
+ const text = i18n.get(key, this.config);
- default:
- break;
- }
-
- if ('class' in attributes) {
- attributes.class += ` ${this.config.classNames.hidden}`;
- } else {
- attributes.class = this.config.classNames.hidden;
- }
+ const attributes = Object.assign({}, attr, {
+ class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' '),
+ });
- return utils.createElement('span', attributes, text);
+ return createElement('span', attributes, text);
},
// Create a badge
createBadge(text) {
- if (utils.is.empty(text)) {
+ if (is.empty(text)) {
return null;
}
- const badge = utils.createElement('span', {
+ const badge = createElement('span', {
class: this.config.classNames.menu.value,
});
badge.appendChild(
- utils.createElement(
+ createElement(
'span',
{
class: this.config.classNames.menu.badge,
@@ -187,22 +171,33 @@ const controls = {
// Create a <button>
createButton(buttonType, attr) {
- const button = utils.createElement('button');
const attributes = Object.assign({}, attr);
- let type = utils.toCamelCase(buttonType);
+ let type = toCamelCase(buttonType);
- let toggle = false;
- let label;
- let icon;
- let labelPressed;
- let iconPressed;
+ const props = {
+ element: 'button',
+ toggle: false,
+ label: null,
+ icon: null,
+ labelPressed: null,
+ iconPressed: null,
+ };
- if (!('type' in attributes)) {
+ ['element', 'icon', 'label'].forEach(key => {
+ if (Object.keys(attributes).includes(key)) {
+ props[key] = attributes[key];
+ delete attributes[key];
+ }
+ });
+
+ // Default to 'button' type to prevent form submission
+ if (props.element === 'button' && !Object.keys(attributes).includes('type')) {
attributes.type = 'button';
}
- if ('class' in attributes) {
- if (attributes.class.includes(this.config.classNames.control)) {
+ // Set class name
+ if (Object.keys(attributes).includes('class')) {
+ if (!attributes.class.includes(this.config.classNames.control)) {
attributes.class += ` ${this.config.classNames.control}`;
}
} else {
@@ -212,74 +207,92 @@ const controls = {
// Large play button
switch (buttonType) {
case 'play':
- toggle = true;
- label = 'play';
- labelPressed = 'pause';
- icon = 'play';
- iconPressed = 'pause';
+ props.toggle = true;
+ props.label = 'play';
+ props.labelPressed = 'pause';
+ props.icon = 'play';
+ props.iconPressed = 'pause';
break;
case 'mute':
- toggle = true;
- label = 'mute';
- labelPressed = 'unmute';
- icon = 'volume';
- iconPressed = 'muted';
+ props.toggle = true;
+ props.label = 'mute';
+ props.labelPressed = 'unmute';
+ props.icon = 'volume';
+ props.iconPressed = 'muted';
break;
case 'captions':
- toggle = true;
- label = 'enableCaptions';
- labelPressed = 'disableCaptions';
- icon = 'captions-off';
- iconPressed = 'captions-on';
+ props.toggle = true;
+ props.label = 'enableCaptions';
+ props.labelPressed = 'disableCaptions';
+ props.icon = 'captions-off';
+ props.iconPressed = 'captions-on';
break;
case 'fullscreen':
- toggle = true;
- label = 'enterFullscreen';
- labelPressed = 'exitFullscreen';
- icon = 'enter-fullscreen';
- iconPressed = 'exit-fullscreen';
+ props.toggle = true;
+ props.label = 'enterFullscreen';
+ props.labelPressed = 'exitFullscreen';
+ props.icon = 'enter-fullscreen';
+ props.iconPressed = 'exit-fullscreen';
break;
case 'play-large':
attributes.class += ` ${this.config.classNames.control}--overlaid`;
type = 'play';
- label = 'play';
- icon = 'play';
+ props.label = 'play';
+ props.icon = 'play';
break;
default:
- label = type;
- icon = buttonType;
+ if (is.empty(props.label)) {
+ props.label = type;
+ }
+ if (is.empty(props.icon)) {
+ props.icon = buttonType;
+ }
}
+ const button = createElement(props.element);
+
// Setup toggle icon and labels
- if (toggle) {
+ if (props.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, props.iconPressed, {
+ class: 'icon--pressed',
+ }),
+ );
+ button.appendChild(
+ controls.createIcon.call(this, props.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' }));
-
- // Add aria attributes
- attributes['aria-pressed'] = false;
+ button.appendChild(
+ controls.createLabel.call(this, props.labelPressed, {
+ class: 'label--pressed',
+ }),
+ );
+ button.appendChild(
+ controls.createLabel.call(this, props.label, {
+ class: 'label--not-pressed',
+ }),
+ );
} else {
- button.appendChild(controls.createIcon.call(this, icon));
- button.appendChild(controls.createLabel.call(this, label));
+ button.appendChild(controls.createIcon.call(this, props.icon));
+ button.appendChild(controls.createLabel.call(this, props.label));
}
- // Merge attributes
- utils.extend(attributes, utils.getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
-
- utils.setAttributes(button, attributes);
+ // Merge and set attributes
+ extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
+ setAttributes(button, attributes);
// We have multiple play buttons
if (type === 'play') {
- if (!utils.is.array(this.elements.buttons[type])) {
+ if (!is.array(this.elements.buttons[type])) {
this.elements.buttons[type] = [];
}
@@ -293,22 +306,11 @@ const controls = {
// Create an <input type='range'>
createRange(type, attributes) {
- // Seek label
- const label = utils.createElement(
- 'label',
- {
- for: attributes.id,
- id: `${attributes.id}-label`,
- class: this.config.classNames.hidden,
- },
- i18n.get(type, this.config),
- );
-
// Seek input
- const input = utils.createElement(
+ const input = createElement(
'input',
- utils.extend(
- utils.getAttributesFromSelector(this.config.selectors.inputs[type]),
+ extend(
+ getAttributesFromSelector(this.config.selectors.inputs[type]),
{
type: 'range',
min: 0,
@@ -318,7 +320,7 @@ const controls = {
autocomplete: 'off',
// A11y fixes for https://github.com/sampotts/plyr/issues/905
role: 'slider',
- 'aria-labelledby': `${attributes.id}-label`,
+ 'aria-label': i18n.get(type, this.config),
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': 0,
@@ -332,18 +334,15 @@ const controls = {
// Set the fill for webkit now
controls.updateRangeFill.call(this, input);
- return {
- label,
- input,
- };
+ return input;
},
// Create a <progress>
createProgress(type, attributes) {
- const progress = utils.createElement(
+ const progress = createElement(
'progress',
- utils.extend(
- utils.getAttributesFromSelector(this.config.selectors.display[type]),
+ extend(
+ getAttributesFromSelector(this.config.selectors.display[type]),
{
min: 0,
max: 100,
@@ -357,23 +356,15 @@ const controls = {
// Create the label inside
if (type !== 'volume') {
- progress.appendChild(utils.createElement('span', null, '0'));
-
- let suffix = '';
- switch (type) {
- case 'played':
- suffix = i18n.get('played', this.config);
- break;
-
- case 'buffer':
- suffix = i18n.get('buffered', this.config);
- break;
+ progress.appendChild(createElement('span', null, '0'));
- default:
- break;
- }
+ const suffixKey = {
+ played: 'played',
+ buffer: 'buffered',
+ }[type];
+ const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';
- progress.textContent = `% ${suffix.toLowerCase()}`;
+ progress.innerText = `% ${suffix.toLowerCase()}`;
}
this.elements.display[type] = progress;
@@ -383,12 +374,16 @@ const controls = {
// Create time display
createTime(type) {
- const attributes = utils.getAttributesFromSelector(this.config.selectors.display[type]);
+ const attributes = getAttributesFromSelector(this.config.selectors.display[type]);
- const container = utils.createElement('div', utils.extend(attributes, {
- class: `plyr__time ${attributes.class}`,
- 'aria-label': i18n.get(type, this.config),
- }), '00:00');
+ const container = createElement(
+ 'div',
+ extend(attributes, {
+ class: `${this.config.classNames.display.time} ${attributes.class ? attributes.class : ''}`.trim(),
+ 'aria-label': i18n.get(type, this.config),
+ }),
+ '00:00',
+ );
// Reference for updates
this.elements.display[type] = container;
@@ -396,37 +391,296 @@ const controls = {
return container;
},
- // Create a settings menu item
- createMenuItem(value, list, type, title, badge = null, checked = false) {
- const item = utils.createElement('li');
+ // 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) {
+ // Navigate through menus via arrow keys and space
+ 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"]');
- const label = utils.createElement('label', {
- class: this.config.classNames.control,
+ // 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,
+ );
+
+ // Enter will fire a `click` event but we still need to manage focus
+ // So we bind to keyup which fires after and set focus here
+ on(menuItem, 'keyup', event => {
+ if (event.which !== 13) {
+ return;
+ }
+
+ controls.focusFirstMenuItem.call(this, null, true);
});
+ },
- const radio = utils.createElement(
- 'input',
- utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs[type]), {
- type: 'radio',
- name: `plyr-${type}`,
+ // Create a settings menu item
+ createMenuItem({ value, list, type, title, badge = null, checked = false }) {
+ const attributes = getAttributesFromSelector(this.config.selectors.inputs[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 = utils.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 (utils.is.element(badge)) {
- label.appendChild(badge);
+ if (is.element(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
+ formatTime(time = 0, inverted = false) {
+ // Bail if the value isn't a number
+ if (!is.number(time)) {
+ return time;
+ }
+
+ // Always display hours if duration is over an hour
+ const forceHours = getHours(this.duration) > 0;
+
+ return formatTime(time, forceHours, inverted);
+ },
+
+ // Update the displayed time
+ updateTimeDisplay(target = null, time = 0, inverted = false) {
+ // Bail if there's no element to display or the value isn't a number
+ if (!is.element(target) || !is.number(time)) {
+ return;
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ target.innerText = controls.formatTime(time, inverted);
+ },
+
+ // Update volume UI and storage
+ updateVolume() {
+ if (!this.supported.ui) {
+ return;
+ }
+
+ // Update range
+ if (is.element(this.elements.inputs.volume)) {
+ controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
+ }
+
+ // Update mute state
+ if (is.element(this.elements.buttons.mute)) {
+ this.elements.buttons.mute.pressed = this.muted || this.volume === 0;
+ }
+ },
+
+ // Update seek value and lower fill
+ setRange(target, value = 0) {
+ if (!is.element(target)) {
+ return;
+ }
+
+ // eslint-disable-next-line
+ target.value = value;
+
+ // Webkit range fill
+ controls.updateRangeFill.call(this, target);
+ },
+
+ // Update <progress> elements
+ updateProgress(event) {
+ if (!this.supported.ui || !is.event(event)) {
+ return;
+ }
+
+ let value = 0;
+
+ const setProgress = (target, input) => {
+ const value = is.number(input) ? input : 0;
+ const progress = is.element(target) ? target : this.elements.display.buffer;
+
+ // Update value and label
+ if (is.element(progress)) {
+ progress.value = value;
+
+ // Update text label inside
+ const label = progress.getElementsByTagName('span')[0];
+ if (is.element(label)) {
+ label.childNodes[0].nodeValue = value;
+ }
+ }
+ };
+
+ if (event) {
+ switch (event.type) {
+ // Video playing
+ case 'timeupdate':
+ case 'seeking':
+ case 'seeked':
+ value = getPercentage(this.currentTime, this.duration);
+
+ // Set seek range value only if it's a 'natural' time event
+ if (event.type === 'timeupdate') {
+ controls.setRange.call(this, this.elements.inputs.seek, value);
+ }
+
+ break;
+
+ // Check buffer status
+ case 'playing':
+ case 'progress':
+ setProgress(this.elements.display.buffer, this.buffered * 100);
+
+ break;
+
+ default:
+ break;
+ }
+ }
+ },
+
+ // Webkit polyfill for lower fill range
+ updateRangeFill(target) {
+ // Get range from event if event passed
+ const range = is.event(target) ? target.target : target;
+
+ // Needs to be a valid <input type='range'>
+ if (!is.element(range) || range.getAttribute('type') !== 'range') {
+ return;
+ }
+
+ // Set aria values for https://github.com/sampotts/plyr/issues/905
+ if (matches(range, this.config.selectors.inputs.seek)) {
+ range.setAttribute('aria-valuenow', this.currentTime);
+ const currentTime = controls.formatTime(this.currentTime);
+ const duration = controls.formatTime(this.duration);
+ const format = i18n.get('seekLabel', this.config);
+ range.setAttribute(
+ 'aria-valuetext',
+ format.replace('{currentTime}', currentTime).replace('{duration}', duration),
+ );
+ } else if (matches(range, this.config.selectors.inputs.volume)) {
+ const percent = range.value * 100;
+ range.setAttribute('aria-valuenow', percent);
+ range.setAttribute('aria-valuetext', `${percent.toFixed(1)}%`);
+ } else {
+ range.setAttribute('aria-valuenow', range.value);
+ }
+
+ // WebKit only
+ if (!browser.isWebkit) {
+ return;
+ }
+
+ // Set CSS custom property
+ range.style.setProperty('--value', `${(range.value / range.max) * 100}%`);
},
// Update hover tooltip for seeking
@@ -434,8 +688,8 @@ const controls = {
// Bail if setting not true
if (
!this.config.tooltips.seek ||
- !utils.is.element(this.elements.inputs.seek) ||
- !utils.is.element(this.elements.display.seekTooltip) ||
+ !is.element(this.elements.inputs.seek) ||
+ !is.element(this.elements.display.seekTooltip) ||
this.duration === 0
) {
return;
@@ -443,11 +697,11 @@ const controls = {
// Calculate percentage
let percent = 0;
- const clientRect = this.elements.inputs.seek.getBoundingClientRect();
+ const clientRect = this.elements.progress.getBoundingClientRect();
const visible = `${this.config.classNames.tooltip}--visible`;
const toggle = toggle => {
- utils.toggleClass(this.elements.display.seekTooltip, visible, toggle);
+ toggleClass(this.elements.display.seekTooltip, visible, toggle);
};
// Hide on touch
@@ -457,9 +711,9 @@ const controls = {
}
// Determine percentage, if already visible
- if (utils.is.event(event)) {
- percent = 100 / clientRect.width * (event.pageX - clientRect.left);
- } else if (utils.hasClass(this.elements.display.seekTooltip, visible)) {
+ if (is.event(event)) {
+ percent = (100 / clientRect.width) * (event.pageX - clientRect.left);
+ } else if (hasClass(this.elements.display.seekTooltip, visible)) {
percent = parseFloat(this.elements.display.seekTooltip.style.left, 10);
} else {
return;
@@ -473,115 +727,153 @@ const controls = {
}
// Display the time a click would seek to
- ui.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent);
+ controls.updateTimeDisplay.call(this, this.elements.display.seekTooltip, (this.duration / 100) * percent);
// Set position
this.elements.display.seekTooltip.style.left = `${percent}%`;
// Show/hide the tooltip
// If the event is a moues in/out and percentage is inside bounds
- if (utils.is.event(event) && [
- 'mouseenter',
- 'mouseleave',
- ].includes(event.type)) {
+ if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
toggle(event.type === 'mouseenter');
}
},
- // Hide/show a tab
- toggleTab(setting, toggle) {
- utils.toggleHidden(this.elements.settings.tabs[setting], !toggle);
+ // Handle time change event
+ timeUpdate(event) {
+ // Only invert if only one time element is displayed and used for both duration and currentTime
+ const invert = !is.element(this.elements.display.duration) && this.config.invertTime;
+
+ // Duration
+ controls.updateTimeDisplay.call(
+ this,
+ this.elements.display.currentTime,
+ invert ? this.duration - this.currentTime : this.currentTime,
+ invert,
+ );
+
+ // Ignore updates while seeking
+ if (event && event.type === 'timeupdate' && this.media.seeking) {
+ return;
+ }
+
+ // Playing progress
+ controls.updateProgress.call(this, event);
},
- // Set the quality menu
- // TODO: Vimeo support
- setQualityMenu(options) {
- // Menu required
- if (!utils.is.element(this.elements.settings.panes.quality)) {
+ // Show the duration on metadataloaded or durationchange events
+ durationUpdate() {
+ // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false
+ if (!this.supported.ui || (!this.config.invertTime && this.currentTime)) {
return;
}
- const type = 'quality';
- const list = this.elements.settings.panes.quality.querySelector('ul');
+ // If duration is the 2**32 (shaka), Infinity (HLS), DASH-IF (Number.MAX_SAFE_INTEGER || Number.MAX_VALUE) indicating live we hide the currentTime and progressbar.
+ // 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) {
+ toggleHidden(this.elements.display.currentTime, true);
+ toggleHidden(this.elements.progress, true);
+ return;
+ }
- // Set options if passed and filter based on config
- if (utils.is.array(options)) {
- this.options.quality = options.filter(quality => this.config.quality.options.includes(quality));
+ // Update ARIA values
+ if (is.element(this.elements.inputs.seek)) {
+ this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);
}
- // Toggle the pane and tab
- const toggle = !utils.is.empty(this.options.quality) && this.options.quality.length > 1;
- controls.toggleTab.call(this, type, toggle);
+ // If there's a spot to display duration
+ const hasDuration = is.element(this.elements.display.duration);
- // Check if we need to toggle the parent
- controls.checkMenu.call(this);
+ // If there's only one time display, display duration there
+ if (!hasDuration && this.config.displayDuration && this.paused) {
+ controls.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);
+ }
- // If we're hiding, nothing more to do
- if (!toggle) {
- return;
+ // If there's a duration element, update content
+ if (hasDuration) {
+ controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
}
- // Empty the menu
- utils.emptyElement(list);
+ // Update the tooltip (if visible)
+ controls.updateSeekTooltip.call(this);
+ },
- // Get the badge HTML for HD, 4K etc
- const getBadge = quality => {
- let label = '';
+ // Hide/show a tab
+ toggleMenuButton(setting, toggle) {
+ toggleHidden(this.elements.settings.buttons[setting], !toggle);
+ },
- switch (quality) {
- case 2160:
- label = '4K';
- break;
+ // Update the selected setting
+ updateSetting(setting, container, input) {
+ const pane = this.elements.settings.panels[setting];
+ let value = null;
+ let list = container;
- case 1440:
- case 1080:
- case 720:
- label = 'HD';
- break;
+ if (setting === 'captions') {
+ value = this.currentTrack;
+ } else {
+ value = !is.empty(input) ? input : this[setting];
- case 576:
- case 480:
- label = 'SD';
- break;
+ // Get default
+ if (is.empty(value)) {
+ value = this.config[setting].default;
+ }
- default:
- break;
+ // Unsupported value
+ if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
+ this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
+ return;
}
- if (!label.length) {
- return null;
+ // Disabled value
+ if (!this.config[setting].options.includes(value)) {
+ this.debug.warn(`Disabled value of '${value}' for ${setting}`);
+ return;
}
+ }
- return controls.createBadge.call(this, label);
- };
+ // Get the list if we need to
+ if (!is.element(list)) {
+ list = pane && pane.querySelector('[role="menu"]');
+ }
- // Sort options by the config and then render options
- this.options.quality
- .sort((a, b) => {
- const sorting = this.config.quality.options;
- return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
- })
- .forEach(quality => {
- const label = controls.getLabel.call(this, 'quality', quality);
- controls.createMenuItem.call(this, quality, list, type, label, getBadge(quality));
- });
+ // If there's no list it means it's not been rendered...
+ if (!is.element(list)) {
+ return;
+ }
- controls.updateSetting.call(this, type, list);
+ // 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
- // TODO: Localisation
getLabel(setting, value) {
switch (setting) {
case 'speed':
return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;
case 'quality':
- if (utils.is.number(value)) {
- return `${value}p`;
+ if (is.number(value)) {
+ const label = i18n.get(`qualityLabel.${value}`, this.config);
+
+ if (!label.length) {
+ return `${value}p`;
+ }
+
+ return label;
}
- return utils.toTitleCase(value);
+ return toTitleCase(value);
case 'captions':
return captions.getLabel.call(this);
@@ -591,98 +883,93 @@ const controls = {
}
},
- // Update the selected setting
- updateSetting(setting, container, input) {
- const pane = this.elements.settings.panes[setting];
- let value = null;
- let list = container;
-
- switch (setting) {
- case 'captions':
- if (this.captions.active) {
- if (this.options.captions.length > 2 || !this.options.captions.some(lang => lang === 'enabled')) {
- value = this.captions.language;
- } else {
- value = 'enabled';
- }
- } else {
- value = '';
- }
+ // Set the quality menu
+ setQualityMenu(options) {
+ // Menu required
+ if (!is.element(this.elements.settings.panels.quality)) {
+ return;
+ }
- break;
+ const type = 'quality';
+ const list = this.elements.settings.panels.quality.querySelector('[role="menu"]');
- default:
- value = !utils.is.empty(input) ? input : this[setting];
+ // Set options if passed and filter based on uniqueness and config
+ if (is.array(options)) {
+ this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality));
+ }
- // Get default
- if (utils.is.empty(value)) {
- value = this.config[setting].default;
- }
+ // Toggle the pane and tab
+ const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;
+ controls.toggleMenuButton.call(this, type, toggle);
- // Unsupported value
- if (!utils.is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
- this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
- return;
- }
+ // Empty the menu
+ emptyElement(list);
- // Disabled value
- if (!this.config[setting].options.includes(value)) {
- this.debug.warn(`Disabled value of '${value}' for ${setting}`);
- return;
- }
+ // Check if we need to toggle the parent
+ controls.checkMenu.call(this);
- break;
+ // If we're hiding, nothing more to do
+ if (!toggle) {
+ return;
}
- // Get the list if we need to
- if (!utils.is.element(list)) {
- list = pane && pane.querySelector('ul');
- }
+ // Get the badge HTML for HD, 4K etc
+ const getBadge = quality => {
+ const label = i18n.get(`qualityBadge.${quality}`, this.config);
- // If there's no list it means it's not been rendered...
- if (!utils.is.element(list)) {
- return;
- }
+ if (!label.length) {
+ return null;
+ }
- // Update the label
- const label = this.elements.settings.tabs[setting].querySelector(`.${this.config.classNames.menu.value}`);
- label.innerHTML = controls.getLabel.call(this, setting, value);
+ return controls.createBadge.call(this, label);
+ };
- // Find the radio option and check it
- const target = list && list.querySelector(`input[value="${value}"]`);
+ // Sort options by the config and then render options
+ this.options.quality
+ .sort((a, b) => {
+ const sorting = this.config.quality.options;
+ return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
+ })
+ .forEach(quality => {
+ controls.createMenuItem.call(this, {
+ value: quality,
+ list,
+ type,
+ title: controls.getLabel.call(this, 'quality', quality),
+ badge: getBadge(quality),
+ });
+ });
- if (utils.is.element(target)) {
- target.checked = true;
- }
+ controls.updateSetting.call(this, type, list);
},
// Set the looping options
/* setLoopMenu() {
// Menu required
- if (!utils.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
- utils.toggleHidden(this.elements.settings.tabs.loop, false);
- utils.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 = !utils.is.empty(this.loop.options);
- controls.toggleTab.call(this, 'loop', toggle);
+ const toggle = !is.empty(this.loop.options);
+ controls.toggleMenuButton.call(this, 'loop', toggle);
// Empty the menu
- utils.emptyElement(list);
+ emptyElement(list);
options.forEach(option => {
- const item = utils.createElement('li');
+ const item = createElement('li');
- const button = utils.createElement(
+ const button = createElement(
'button',
- utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.loop), {
+ extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {
type: 'button',
class: this.config.classNames.control,
'data-plyr-loop-action': option,
@@ -705,16 +992,22 @@ 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
- const toggle = captions.getTracks.call(this).length;
- controls.toggleTab.call(this, type, toggle);
+ controls.toggleMenuButton.call(this, type, toggle);
// Empty the menu
- utils.emptyElement(list);
+ emptyElement(list);
// Check if we need to toggle the parent
controls.checkMenu.call(this);
@@ -724,72 +1017,57 @@ const controls = {
return;
}
- // Re-map the tracks into just the data we need
- const tracks = captions.getTracks.call(this).map(track => ({
- language: !utils.is.empty(track.language) ? track.language : 'enabled',
- label: captions.getLabel.call(this, track),
+ // Generate options data
+ const options = tracks.map((track, value) => ({
+ value,
+ checked: this.captions.toggled && this.currentTrack === value,
+ title: captions.getLabel.call(this, track),
+ badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()),
+ list,
+ type: 'language',
}));
// Add the "Disabled" option to turn off captions
- tracks.unshift({
- language: '',
- label: i18n.get('disabled', this.config),
+ options.unshift({
+ value: -1,
+ checked: !this.captions.toggled,
+ title: i18n.get('disabled', this.config),
+ list,
+ type: 'language',
});
// Generate options
- tracks.forEach(track => {
- controls.createMenuItem.call(
- this,
- track.language,
- list,
- 'language',
- track.label,
- track.language !== 'enabled' ? controls.createBadge.call(this, track.language.toUpperCase()) : null,
- track.language.toLowerCase() === this.captions.language.toLowerCase(),
- );
- });
-
- // Store reference
- this.options.captions = tracks.map(track => track.language);
+ options.forEach(controls.createMenuItem.bind(this));
controls.updateSetting.call(this, type, list);
},
// 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 (!utils.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 (utils.is.array(options)) {
+ if (is.array(options)) {
this.options.speed = options;
} else if (this.isHTML5 || this.isVimeo) {
- this.options.speed = [
- 0.5,
- 0.75,
- 1,
- 1.25,
- 1.5,
- 1.75,
- 2,
- ];
+ this.options.speed = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
}
// Set options if passed and filter based on config
this.options.speed = this.options.speed.filter(speed => this.config.speed.options.includes(speed));
// Toggle the pane and tab
- const toggle = !utils.is.empty(this.options.speed) && this.options.speed.length > 1;
- controls.toggleTab.call(this, type, toggle);
+ const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;
+ controls.toggleMenuButton.call(this, type, toggle);
+
+ // Empty the menu
+ emptyElement(list);
// Check if we need to toggle the parent
controls.checkMenu.call(this);
@@ -799,16 +1077,14 @@ const controls = {
return;
}
- // Get the list to populate
- const list = this.elements.settings.panes.speed.querySelector('ul');
-
- // Empty the menu
- utils.emptyElement(list);
-
// Create items
this.options.speed.forEach(speed => {
- const label = controls.getLabel.call(this, 'speed', speed);
- controls.createMenuItem.call(this, speed, list, type, label);
+ controls.createMenuItem.call(this, {
+ value: speed,
+ list,
+ type,
+ title: controls.getLabel.call(this, 'speed', speed),
+ });
});
controls.updateSetting.call(this, type, list);
@@ -816,71 +1092,83 @@ const controls = {
// Check if we need to hide/show the settings menu
checkMenu() {
- const { tabs } = this.elements.settings;
- const visible = !utils.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);
- utils.toggleHidden(this.elements.settings.menu, !visible);
+ toggleHidden(this.elements.settings.menu, !visible);
+ },
+
+ // Focus the first menu item in a given (or visible) menu
+ focusFirstMenuItem(pane, tabFocus = false) {
+ if (this.elements.settings.popup.hidden) {
+ return;
+ }
+
+ let target = pane;
+
+ if (!is.element(target)) {
+ target = Object.values(this.elements.settings.panels).find(pane => !pane.hidden);
+ }
+
+ const firstItem = target.querySelector('[role^="menuitem"]');
+
+ setFocus.call(this, firstItem, tabFocus);
},
// 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 (!utils.is.element(form) || !utils.is.element(button)) {
+ if (!is.element(popup) || !is.element(button)) {
return;
}
- const show = utils.is.boolean(event) ? event : utils.is.element(form) && form.hasAttribute('hidden');
+ // True toggle by default
+ const { hidden } = popup;
+ let show = hidden;
- if (utils.is.event(event)) {
- const isMenuItem = utils.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);
- // 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)) {
+ if (isMenuItem || (!isMenuItem && input.target !== button && show)) {
return;
}
-
- // Prevent the toggle being caught by the doc listener
- if (isButton) {
- event.stopPropagation();
- }
}
- // Set form and button attributes
- if (utils.is.element(button)) {
- button.setAttribute('aria-expanded', show);
- }
+ // Set button attributes
+ button.setAttribute('aria-expanded', show);
- if (utils.is.element(form)) {
- utils.toggleHidden(form, !show);
- utils.toggleClass(this.elements.container, this.config.classNames.menu.open, show);
+ // Show the actual popup
+ toggleHidden(popup, !show);
- if (show) {
- form.removeAttribute('tabindex');
- } else {
- form.setAttribute('tabindex', -1);
- }
+ // 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)) {
+ controls.focusFirstMenuItem.call(this, null, true);
+ } else if (!show && !hidden) {
+ // If closing, re-focus the button
+ 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);
@@ -889,7 +1177,7 @@ const controls = {
const height = clone.scrollHeight;
// Remove from the DOM
- utils.removeElement(clone);
+ removeElement(clone);
return {
width,
@@ -897,31 +1185,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 (!utils.is.element(pane)) {
- return;
- }
-
- // Are we targetting a tab? If not, bail
- const isTab = pane.getAttribute('role') === 'tabpanel';
- if (!isTab) {
+ if (!is.element(target)) {
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) {
@@ -930,15 +1205,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;
}
@@ -947,11 +1219,11 @@ const controls = {
container.style.height = '';
// Only listen once
- utils.off(container, utils.transitionEndEvent, restore);
+ off.call(this, container, transitionEndEvent, restore);
};
// Listen for the transition finishing and restore auto height/width
- utils.on(container, utils.transitionEndEvent, restore);
+ on.call(this, container, transitionEndEvent, restore);
// Set dimensions to target
container.style.width = `${size.width}px`;
@@ -959,32 +1231,33 @@ const controls = {
}
// Set attributes on current tab
- utils.toggleHidden(current, true);
- current.setAttribute('tabindex', -1);
+ toggleHidden(current, true);
// Set attributes on target
- utils.toggleHidden(pane, false);
-
- const tabs = utils.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();
+ controls.focusFirstMenuItem.call(this, target, tabFocus);
+ },
+
+ // Set the download link
+ setDownloadLink() {
+ const button = this.elements.buttons.download;
+
+ // Bail if no button
+ if (!is.element(button)) {
+ return;
+ }
+
+ // Set download link
+ button.setAttribute('href', this.download);
},
// Build the default HTML
// TODO: Set order based on order in the config.controls array?
create(data) {
- // Do nothing if we want no controls
- if (utils.is.empty(this.config.controls)) {
- return null;
- }
-
// Create the container
- const container = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.controls.wrapper));
+ const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
// Restart button
if (this.config.controls.includes('restart')) {
@@ -1008,14 +1281,14 @@ const controls = {
// Progress
if (this.config.controls.includes('progress')) {
- const progress = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.progress));
+ const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
// Seek range slider
- const seek = controls.createRange.call(this, 'seek', {
- id: `plyr-seek-${data.id}`,
- });
- progress.appendChild(seek.label);
- progress.appendChild(seek.input);
+ progress.appendChild(
+ controls.createRange.call(this, 'seek', {
+ id: `plyr-seek-${data.id}`,
+ }),
+ );
// Buffer progress
progress.appendChild(controls.createProgress.call(this, 'buffer'));
@@ -1024,10 +1297,9 @@ const controls = {
// Seek tooltip
if (this.config.tooltips.seek) {
- const tooltip = utils.createElement(
+ const tooltip = createElement(
'span',
{
- role: 'tooltip',
class: this.config.classNames.tooltip,
},
'00:00',
@@ -1051,36 +1323,39 @@ const controls = {
container.appendChild(controls.createTime.call(this, 'duration'));
}
- // Toggle mute button
- if (this.config.controls.includes('mute')) {
- container.appendChild(controls.createButton.call(this, 'mute'));
- }
-
- // Volume range control
- if (this.config.controls.includes('volume')) {
- const volume = utils.createElement('div', {
+ // Volume controls
+ if (this.config.controls.includes('mute') || this.config.controls.includes('volume')) {
+ const volume = createElement('div', {
class: 'plyr__volume',
});
- // Set the attributes
- const attributes = {
- max: 1,
- step: 0.05,
- value: this.config.volume,
- };
+ // Toggle mute button
+ if (this.config.controls.includes('mute')) {
+ volume.appendChild(controls.createButton.call(this, 'mute'));
+ }
- // Create the volume range slider
- const range = controls.createRange.call(
- this,
- 'volume',
- utils.extend(attributes, {
- id: `plyr-volume-${data.id}`,
- }),
- );
- volume.appendChild(range.label);
- volume.appendChild(range.input);
+ // Volume range control
+ if (this.config.controls.includes('volume')) {
+ // Set the attributes
+ const attributes = {
+ max: 1,
+ step: 0.05,
+ value: this.config.volume,
+ };
+
+ // Create the volume range slider
+ volume.appendChild(
+ controls.createRange.call(
+ this,
+ 'volume',
+ extend(attributes, {
+ id: `plyr-volume-${data.id}`,
+ }),
+ ),
+ );
- this.elements.volume = volume;
+ this.elements.volume = volume;
+ }
container.appendChild(volume);
}
@@ -1091,118 +1366,157 @@ const controls = {
}
// Settings button / menu
- if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) {
- const menu = utils.createElement('div', {
+ if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
+ 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,
'aria-controls': `plyr-settings-${data.id}`,
'aria-expanded': false,
}),
);
- const form = utils.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 = utils.createElement('div');
+ const inner = createElement('div');
- const home = utils.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 = utils.createElement('ul', {
- role: 'tablist',
+ // Create the menu
+ const menu = createElement('div', {
+ role: 'menu',
});
- // Build the tabs
- this.config.settings.forEach(type => {
- const tab = utils.createElement('li', {
- role: 'tab',
- hidden: '',
- });
+ home.appendChild(menu);
+ inner.appendChild(home);
+ this.elements.settings.panels.home = home;
- const button = utils.createElement(
+ // Build the menu items
+ this.config.settings.forEach(type => {
+ // TODO: bundle this with the createMenuItem helper and bindings
+ const menuItem = createElement(
'button',
- utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.settings), {
+ 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),
);
- const value = utils.createElement('span', {
+ // 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];
- 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 => {
- const pane = utils.createElement('div', {
+ // 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 = utils.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,
+ // 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);
},
- i18n.get(type, this.config),
+ false,
);
- pane.appendChild(back);
+ // Go back via button click
+ on(backButton, 'click', () => {
+ controls.showMenuPanel.call(this, 'home', false);
+ });
+
+ // Add to pane
+ pane.appendChild(backButton);
- const options = utils.createElement('ul');
+ // 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
@@ -1215,6 +1529,26 @@ const controls = {
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,
+ });
+ }
+
+ container.appendChild(controls.createButton.call(this, 'download', attributes));
+ }
+
// Toggle fullscreen button
if (this.config.controls.includes('fullscreen')) {
container.appendChild(controls.createButton.call(this, 'fullscreen'));
@@ -1227,6 +1561,7 @@ const controls = {
this.elements.controls = container;
+ // Set available quality levels
if (this.isHTML5) {
controls.setQualityMenu.call(this, html5.getQualityOptions.call(this));
}
@@ -1244,7 +1579,7 @@ const controls = {
// Only load external sprite using AJAX
if (icon.cors) {
- utils.loadSprite(icon.url, 'sprite-plyr');
+ loadSprite(icon.url, 'sprite-plyr');
}
}
@@ -1263,13 +1598,19 @@ const controls = {
};
let update = true;
- if (utils.is.string(this.config.controls) || utils.is.element(this.config.controls)) {
- // String or HTMLElement passed as the option
+ // If function, run it and use output
+ if (is.function(this.config.controls)) {
+ this.config.controls = this.config.controls.call(this, props);
+ }
+
+ // Convert falsy controls to empty array (primarily for empty strings)
+ if (!this.config.controls) {
+ this.config.controls = [];
+ }
+
+ if (is.element(this.config.controls) || is.string(this.config.controls)) {
+ // HTMLElement or Non-empty string passed as the option
container = this.config.controls;
- } else if (utils.is.function(this.config.controls)) {
- // A custom function to build controls
- // The function can return a HTMLElement or String
- container = this.config.controls.call(this, props);
} else {
// Create controls
container = controls.create.call(this, {
@@ -1288,11 +1629,8 @@ const controls = {
const replace = input => {
let result = input;
- Object.entries(props).forEach(([
- key,
- value,
- ]) => {
- result = utils.replaceAll(result, `{${key}}`, value);
+ Object.entries(props).forEach(([key, value]) => {
+ result = replaceAll(result, `{${key}}`, value);
});
return result;
@@ -1300,9 +1638,9 @@ const controls = {
// Update markup
if (update) {
- if (utils.is.string(this.config.controls)) {
+ if (is.string(this.config.controls)) {
container = replace(container);
- } else if (utils.is.element(container)) {
+ } else if (is.element(container)) {
container.innerHTML = replace(container.innerHTML);
}
}
@@ -1311,49 +1649,67 @@ const controls = {
let target;
// Inject to custom location
- if (utils.is.string(this.config.selectors.controls.container)) {
+ if (is.string(this.config.selectors.controls.container)) {
target = document.querySelector(this.config.selectors.controls.container);
}
// Inject into the container by default
- if (!utils.is.element(target)) {
+ if (!is.element(target)) {
target = this.elements.container;
}
- // Inject controls HTML
- if (utils.is.element(container)) {
- target.appendChild(container);
- } else if (container) {
- target.insertAdjacentHTML('beforeend', container);
- }
+ // Inject controls HTML (needs to be before captions, hence "afterbegin")
+ const insertMethod = is.element(container) ? 'insertAdjacentElement' : 'insertAdjacentHTML';
+ target[insertMethod]('afterbegin', container);
// Find the elements if need be
- if (!utils.is.element(this.elements.controls)) {
+ if (!is.element(this.elements.controls)) {
controls.findElements.call(this);
}
- // Edge sometimes doesn't finish the paint so force a redraw
- if (window.navigator.userAgent.includes('Edge')) {
- utils.repaint(target);
+ // Add pressed property to buttons
+ if (!is.empty(this.elements.buttons)) {
+ const addProperty = button => {
+ const className = this.config.classNames.controlPressed;
+ Object.defineProperty(button, 'pressed', {
+ enumerable: true,
+ get() {
+ return hasClass(button, className);
+ },
+ set(pressed = false) {
+ toggleClass(button, className, pressed);
+ },
+ });
+ };
+
+ // Toggle classname when pressed property is set
+ Object.values(this.elements.buttons)
+ .filter(Boolean)
+ .forEach(button => {
+ if (is.array(button) || is.nodeList(button)) {
+ Array.from(button)
+ .filter(Boolean)
+ .forEach(addProperty);
+ } else {
+ addProperty(button);
+ }
+ });
+ }
+
+ // Edge sometimes doesn't finish the paint so force a repaint
+ if (browser.isEdge) {
+ repaint(target);
}
// Setup tooltips
if (this.config.tooltips.controls) {
- const labels = utils.getElements.call(
- this,
- [
- this.config.selectors.controls.wrapper,
- ' ',
- this.config.selectors.labels,
- ' .',
- this.config.classNames.hidden,
- ].join(''),
- );
+ const { classNames, selectors } = this.config;
+ const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;
+ const labels = getElements.call(this, selector);
Array.from(labels).forEach(label => {
- utils.toggleClass(label, this.config.classNames.hidden, false);
- utils.toggleClass(label, this.config.classNames.tooltip, true);
- label.setAttribute('role', 'tooltip');
+ toggleClass(label, this.config.classNames.hidden, false);
+ toggleClass(label, this.config.classNames.tooltip, true);
});
}
},