aboutsummaryrefslogtreecommitdiffstats
path: root/src/js/controls.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/controls.js')
-rw-r--r--src/js/controls.js3242
1 files changed, 1619 insertions, 1623 deletions
diff --git a/src/js/controls.js b/src/js/controls.js
index 37df497f..ad126de1 100644
--- a/src/js/controls.js
+++ b/src/js/controls.js
@@ -12,18 +12,18 @@ 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,
+ 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';
@@ -35,917 +35,915 @@ import { formatTime, getHours } from './utils/time';
// TODO: Don't export a massive object - break down and create class
const controls = {
- // Get icon URL
- getIconUrl() {
- const url = new URL(this.config.iconUrl, window.location);
- const cors = url.host !== window.location.host || (browser.isIE && !window.svg4everybody);
-
- return {
- url: this.config.iconUrl,
- cors,
- };
- },
-
- // Find the UI controls
- findElements() {
- try {
- this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);
-
- // Buttons
- this.elements.buttons = {
- 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 = getElement.call(this, this.config.selectors.progress);
-
- // Inputs
- this.elements.inputs = {
- seek: getElement.call(this, this.config.selectors.inputs.seek),
- volume: getElement.call(this, this.config.selectors.inputs.volume),
- };
-
- // Display
- this.elements.display = {
- 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 (is.element(this.elements.progress)) {
- this.elements.display.seekTooltip = this.elements.progress.querySelector(
- `.${this.config.classNames.tooltip}`,
- );
- }
-
- return true;
- } catch (error) {
- // Log it
- this.debug.warn('It looks like there is a problem with your custom controls HTML', error);
-
- // Restore native video controls
- this.toggleNativeControls(true);
-
- return false;
- }
- },
-
- // Create <svg> icon
- createIcon(type, attributes) {
- const namespace = 'http://www.w3.org/2000/svg';
- const iconUrl = controls.getIconUrl.call(this);
- const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`;
- // Create <svg>
- const icon = document.createElementNS(namespace, 'svg');
- setAttributes(
- icon,
- extend(attributes, {
- 'aria-hidden': 'true',
- focusable: 'false',
- }),
- );
-
- // Create the <use> to reference sprite
- const use = document.createElementNS(namespace, 'use');
- const path = `${iconPath}-${type}`;
-
- // Set `href` attributes
- // https://github.com/sampotts/plyr/issues/460
- // 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);
- }
-
- // 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);
-
- return icon;
- },
-
- // Create hidden text label
- createLabel(key, attr = {}) {
- const text = i18n.get(key, this.config);
- const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') };
-
- return createElement('span', attributes, text);
- },
-
- // Create a badge
- createBadge(text) {
- if (is.empty(text)) {
- return null;
- }
-
- const badge = createElement('span', {
- class: this.config.classNames.menu.value,
+ // Get icon URL
+ getIconUrl() {
+ const url = new URL(this.config.iconUrl, window.location);
+ const cors = url.host !== window.location.host || (browser.isIE && !window.svg4everybody);
+
+ return {
+ url: this.config.iconUrl,
+ cors,
+ };
+ },
+
+ // Find the UI controls
+ findElements() {
+ try {
+ this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);
+
+ // Buttons
+ this.elements.buttons = {
+ 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 = getElement.call(this, this.config.selectors.progress);
+
+ // Inputs
+ this.elements.inputs = {
+ seek: getElement.call(this, this.config.selectors.inputs.seek),
+ volume: getElement.call(this, this.config.selectors.inputs.volume),
+ };
+
+ // Display
+ this.elements.display = {
+ 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 (is.element(this.elements.progress)) {
+ this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`);
+ }
+
+ return true;
+ } catch (error) {
+ // Log it
+ this.debug.warn('It looks like there is a problem with your custom controls HTML', error);
+
+ // Restore native video controls
+ this.toggleNativeControls(true);
+
+ return false;
+ }
+ },
+
+ // Create <svg> icon
+ createIcon(type, attributes) {
+ const namespace = 'http://www.w3.org/2000/svg';
+ const iconUrl = controls.getIconUrl.call(this);
+ const iconPath = `${!iconUrl.cors ? iconUrl.url : ''}#${this.config.iconPrefix}`;
+ // Create <svg>
+ const icon = document.createElementNS(namespace, 'svg');
+ setAttributes(
+ icon,
+ extend(attributes, {
+ 'aria-hidden': 'true',
+ focusable: 'false',
+ }),
+ );
+
+ // Create the <use> to reference sprite
+ const use = document.createElementNS(namespace, 'use');
+ const path = `${iconPath}-${type}`;
+
+ // Set `href` attributes
+ // https://github.com/sampotts/plyr/issues/460
+ // 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);
+ }
+
+ // 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);
+
+ return icon;
+ },
+
+ // Create hidden text label
+ createLabel(key, attr = {}) {
+ const text = i18n.get(key, this.config);
+ const attributes = { ...attr, class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' ') };
+
+ return createElement('span', attributes, text);
+ },
+
+ // Create a badge
+ createBadge(text) {
+ if (is.empty(text)) {
+ return null;
+ }
+
+ const badge = createElement('span', {
+ class: this.config.classNames.menu.value,
+ });
+
+ badge.appendChild(
+ createElement(
+ 'span',
+ {
+ class: this.config.classNames.menu.badge,
+ },
+ text,
+ ),
+ );
+
+ return badge;
+ },
+
+ // Create a <button>
+ createButton(buttonType, attr) {
+ const attributes = extend({}, attr);
+ let type = toCamelCase(buttonType);
+
+ const props = {
+ element: 'button',
+ toggle: false,
+ label: null,
+ icon: null,
+ labelPressed: null,
+ iconPressed: null,
+ };
+
+ ['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';
+ }
+
+ // Set class name
+ if (Object.keys(attributes).includes('class')) {
+ if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) {
+ extend(attributes, {
+ class: `${attributes.class} ${this.config.classNames.control}`,
});
-
- badge.appendChild(
- createElement(
- 'span',
- {
- class: this.config.classNames.menu.badge,
- },
- text,
- ),
- );
-
- return badge;
- },
-
- // Create a <button>
- createButton(buttonType, attr) {
- const attributes = extend({}, attr);
- let type = toCamelCase(buttonType);
-
- const props = {
- element: 'button',
- toggle: false,
- label: null,
- icon: null,
- labelPressed: null,
- iconPressed: null,
- };
-
- ['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';
- }
-
- // Set class name
- if (Object.keys(attributes).includes('class')) {
- if (!attributes.class.split(' ').some(c => c === this.config.classNames.control)) {
- extend(attributes, {
- class: `${attributes.class} ${this.config.classNames.control}`,
- });
- }
+ }
+ } else {
+ attributes.class = this.config.classNames.control;
+ }
+
+ // Large play button
+ switch (buttonType) {
+ case 'play':
+ props.toggle = true;
+ props.label = 'play';
+ props.labelPressed = 'pause';
+ props.icon = 'play';
+ props.iconPressed = 'pause';
+ break;
+
+ case 'mute':
+ props.toggle = true;
+ props.label = 'mute';
+ props.labelPressed = 'unmute';
+ props.icon = 'volume';
+ props.iconPressed = 'muted';
+ break;
+
+ case 'captions':
+ props.toggle = true;
+ props.label = 'enableCaptions';
+ props.labelPressed = 'disableCaptions';
+ props.icon = 'captions-off';
+ props.iconPressed = 'captions-on';
+ break;
+
+ case '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';
+ props.label = 'play';
+ props.icon = 'play';
+ break;
+
+ default:
+ 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 (props.toggle) {
+ // Icon
+ 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, props.labelPressed, {
+ class: 'label--pressed',
+ }),
+ );
+ button.appendChild(
+ controls.createLabel.call(this, props.label, {
+ class: 'label--not-pressed',
+ }),
+ );
+ } else {
+ button.appendChild(controls.createIcon.call(this, props.icon));
+ button.appendChild(controls.createLabel.call(this, props.label));
+ }
+
+ // 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 (!is.array(this.elements.buttons[type])) {
+ this.elements.buttons[type] = [];
+ }
+
+ this.elements.buttons[type].push(button);
+ } else {
+ this.elements.buttons[type] = button;
+ }
+
+ return button;
+ },
+
+ // Create an <input type='range'>
+ createRange(type, attributes) {
+ // Seek input
+ const input = createElement(
+ 'input',
+ extend(
+ getAttributesFromSelector(this.config.selectors.inputs[type]),
+ {
+ type: 'range',
+ min: 0,
+ max: 100,
+ step: 0.01,
+ value: 0,
+ autocomplete: 'off',
+ // A11y fixes for https://github.com/sampotts/plyr/issues/905
+ role: 'slider',
+ 'aria-label': i18n.get(type, this.config),
+ 'aria-valuemin': 0,
+ 'aria-valuemax': 100,
+ 'aria-valuenow': 0,
+ },
+ attributes,
+ ),
+ );
+
+ this.elements.inputs[type] = input;
+
+ // Set the fill for webkit now
+ controls.updateRangeFill.call(this, input);
+
+ // Improve support on touch devices
+ RangeTouch.setup(input);
+
+ return input;
+ },
+
+ // Create a <progress>
+ createProgress(type, attributes) {
+ const progress = createElement(
+ 'progress',
+ extend(
+ getAttributesFromSelector(this.config.selectors.display[type]),
+ {
+ min: 0,
+ max: 100,
+ value: 0,
+ role: 'progressbar',
+ 'aria-hidden': true,
+ },
+ attributes,
+ ),
+ );
+
+ // Create the label inside
+ if (type !== 'volume') {
+ progress.appendChild(createElement('span', null, '0'));
+
+ const suffixKey = {
+ played: 'played',
+ buffer: 'buffered',
+ }[type];
+ const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';
+
+ progress.innerText = `% ${suffix.toLowerCase()}`;
+ }
+
+ this.elements.display[type] = progress;
+
+ return progress;
+ },
+
+ // Create time display
+ createTime(type, attrs) {
+ const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);
+
+ const container = createElement(
+ 'div',
+ extend(attributes, {
+ class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(),
+ 'aria-label': i18n.get(type, this.config),
+ }),
+ '00:00',
+ );
+
+ // Reference for updates
+ this.elements.display[type] = container;
+
+ 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) {
+ // Navigate through menus via arrow keys and space
+ on.call(
+ this,
+ 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 {
- attributes.class = this.config.classNames.control;
- }
+ let target;
- // Large play button
- switch (buttonType) {
- case 'play':
- props.toggle = true;
- props.label = 'play';
- props.labelPressed = 'pause';
- props.icon = 'play';
- props.iconPressed = 'pause';
- break;
-
- case 'mute':
- props.toggle = true;
- props.label = 'mute';
- props.labelPressed = 'unmute';
- props.icon = 'volume';
- props.iconPressed = 'muted';
- break;
-
- case 'captions':
- props.toggle = true;
- props.label = 'enableCaptions';
- props.labelPressed = 'disableCaptions';
- props.icon = 'captions-off';
- props.iconPressed = 'captions-on';
- break;
-
- case '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';
- props.label = 'play';
- props.icon = 'play';
- break;
-
- default:
- if (is.empty(props.label)) {
- props.label = type;
- }
- if (is.empty(props.icon)) {
- props.icon = buttonType;
- }
- }
+ if (event.which !== 32) {
+ if (event.which === 40 || (isRadioButton && event.which === 39)) {
+ target = menuItem.nextElementSibling;
- const button = createElement(props.element);
+ if (!is.element(target)) {
+ target = menuItem.parentNode.firstElementChild;
+ }
+ } else {
+ target = menuItem.previousElementSibling;
- // Setup toggle icon and labels
- if (props.toggle) {
- // Icon
- 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, props.labelPressed, {
- class: 'label--pressed',
- }),
- );
- button.appendChild(
- controls.createLabel.call(this, props.label, {
- class: 'label--not-pressed',
- }),
- );
- } else {
- button.appendChild(controls.createIcon.call(this, props.icon));
- button.appendChild(controls.createLabel.call(this, props.label));
- }
-
- // 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 (!is.array(this.elements.buttons[type])) {
- this.elements.buttons[type] = [];
+ if (!is.element(target)) {
+ target = menuItem.parentNode.lastElementChild;
+ }
}
- this.elements.buttons[type].push(button);
- } else {
- this.elements.buttons[type] = button;
+ 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.call(this, menuItem, 'keyup', event => {
+ if (event.which !== 13) {
+ return;
+ }
- return button;
- },
-
- // Create an <input type='range'>
- createRange(type, attributes) {
- // Seek input
- const input = createElement(
- 'input',
- extend(
- getAttributesFromSelector(this.config.selectors.inputs[type]),
- {
- type: 'range',
- min: 0,
- max: 100,
- step: 0.01,
- value: 0,
- autocomplete: 'off',
- // A11y fixes for https://github.com/sampotts/plyr/issues/905
- role: 'slider',
- 'aria-label': i18n.get(type, this.config),
- 'aria-valuemin': 0,
- 'aria-valuemax': 100,
- 'aria-valuenow': 0,
- },
- attributes,
- ),
- );
-
- this.elements.inputs[type] = input;
-
- // Set the fill for webkit now
- controls.updateRangeFill.call(this, input);
-
- // Improve support on touch devices
- RangeTouch.setup(input);
-
- return input;
- },
-
- // Create a <progress>
- createProgress(type, attributes) {
- const progress = createElement(
- 'progress',
- extend(
- getAttributesFromSelector(this.config.selectors.display[type]),
- {
- min: 0,
- max: 100,
- value: 0,
- role: 'progressbar',
- 'aria-hidden': true,
- },
- attributes,
- ),
- );
-
- // Create the label inside
- if (type !== 'volume') {
- progress.appendChild(createElement('span', null, '0'));
-
- const suffixKey = {
- played: 'played',
- buffer: 'buffered',
- }[type];
- const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';
-
- progress.innerText = `% ${suffix.toLowerCase()}`;
- }
-
- this.elements.display[type] = progress;
-
- return progress;
- },
-
- // Create time display
- createTime(type, attrs) {
- const attributes = getAttributesFromSelector(this.config.selectors.display[type], attrs);
-
- const container = createElement(
- 'div',
- extend(attributes, {
- class: `${attributes.class ? attributes.class : ''} ${this.config.classNames.display.time} `.trim(),
- 'aria-label': i18n.get(type, this.config),
- }),
- '00:00',
- );
-
- // Reference for updates
- this.elements.display[type] = container;
-
- 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) {
- // Navigate through menus via arrow keys and space
- on.call(
- this,
- 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,
- );
-
- // 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.call(this, menuItem, 'keyup', event => {
- if (event.which !== 13) {
- return;
- }
+ controls.focusFirstMenuItem.call(this, null, true);
+ });
+ },
- controls.focusFirstMenuItem.call(this, null, true);
+ // 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,
+ }),
+ );
+
+ const flex = createElement('span');
+
+ // We have to set as HTML incase of special characters
+ flex.innerHTML = title;
+
+ if (is.element(badge)) {
+ flex.appendChild(badge);
+ }
+
+ menuItem.appendChild(flex);
+
+ // Replicate radio button behaviour
+ Object.defineProperty(menuItem, 'checked', {
+ enumerable: true,
+ get() {
+ return menuItem.getAttribute('aria-checked') === 'true';
+ },
+ set(check) {
+ // Ensure exclusivity
+ if (check) {
+ Array.from(menuItem.parentNode.children)
+ .filter(node => matches(node, '[role="menuitemradio"]'))
+ .forEach(node => node.setAttribute('aria-checked', 'false'));
+ }
+
+ menuItem.setAttribute('aria-checked', check ? '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 val = 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 = val;
+
+ // Update text label inside
+ const label = progress.getElementsByTagName('span')[0];
+ if (is.element(label)) {
+ label.childNodes[0].nodeValue = val;
+ }
+ }
+ };
+
+ 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
+ updateSeekTooltip(event) {
+ // Bail if setting not true
+ if (
+ !this.config.tooltips.seek ||
+ !is.element(this.elements.inputs.seek) ||
+ !is.element(this.elements.display.seekTooltip) ||
+ this.duration === 0
+ ) {
+ return;
+ }
+
+ const visible = `${this.config.classNames.tooltip}--visible`;
+ const toggle = show => toggleClass(this.elements.display.seekTooltip, visible, show);
+
+ // Hide on touch
+ if (this.touch) {
+ toggle(false);
+ return;
+ }
+
+ // Determine percentage, if already visible
+ let percent = 0;
+ const clientRect = this.elements.progress.getBoundingClientRect();
+
+ 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;
+ }
+
+ // Set bounds
+ if (percent < 0) {
+ percent = 0;
+ } else if (percent > 100) {
+ percent = 100;
+ }
+
+ // Display the time a click would seek to
+ 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 (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
+ toggle(event.type === 'mouseenter');
+ }
+ },
+
+ // 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);
+ },
+
+ // 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;
+ }
+
+ // 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;
+ }
+
+ // Update ARIA values
+ if (is.element(this.elements.inputs.seek)) {
+ this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);
+ }
+
+ // If there's a spot to display duration
+ const hasDuration = is.element(this.elements.display.duration);
+
+ // 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 there's a duration element, update content
+ if (hasDuration) {
+ controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
+ }
+
+ // Update the tooltip (if visible)
+ controls.updateSeekTooltip.call(this);
+ },
+
+ // Hide/show a tab
+ 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.panels.quality)) {
+ return;
+ }
+
+ const type = 'quality';
+ 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)) {
+ this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality));
+ }
+
+ // Toggle the pane and tab
+ const toggle = !is.empty(this.options.quality) && this.options.quality.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);
+
+ // If we're hiding, nothing more to do
+ if (!toggle) {
+ return;
+ }
+
+ // Get the badge HTML for HD, 4K etc
+ const getBadge = quality => {
+ const label = i18n.get(`qualityBadge.${quality}`, this.config);
+
+ if (!label.length) {
+ return null;
+ }
+
+ return controls.createBadge.call(this, label);
+ };
+
+ // 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),
});
- },
-
- // 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,
- }),
- );
-
- const flex = createElement('span');
-
- // We have to set as HTML incase of special characters
- flex.innerHTML = title;
-
- if (is.element(badge)) {
- flex.appendChild(badge);
- }
-
- menuItem.appendChild(flex);
-
- // Replicate radio button behaviour
- Object.defineProperty(menuItem, 'checked', {
- enumerable: true,
- get() {
- return menuItem.getAttribute('aria-checked') === 'true';
- },
- set(check) {
- // Ensure exclusivity
- if (check) {
- Array.from(menuItem.parentNode.children)
- .filter(node => matches(node, '[role="menuitemradio"]'))
- .forEach(node => node.setAttribute('aria-checked', 'false'));
- }
-
- menuItem.setAttribute('aria-checked', check ? '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 val = 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 = val;
-
- // Update text label inside
- const label = progress.getElementsByTagName('span')[0];
- if (is.element(label)) {
- label.childNodes[0].nodeValue = val;
- }
- }
- };
-
- 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
- updateSeekTooltip(event) {
- // Bail if setting not true
- if (
- !this.config.tooltips.seek ||
- !is.element(this.elements.inputs.seek) ||
- !is.element(this.elements.display.seekTooltip) ||
- this.duration === 0
- ) {
- return;
- }
-
- const visible = `${this.config.classNames.tooltip}--visible`;
- const toggle = show => toggleClass(this.elements.display.seekTooltip, visible, show);
-
- // Hide on touch
- if (this.touch) {
- toggle(false);
- return;
- }
-
- // Determine percentage, if already visible
- let percent = 0;
- const clientRect = this.elements.progress.getBoundingClientRect();
-
- 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;
- }
-
- // Set bounds
- if (percent < 0) {
- percent = 0;
- } else if (percent > 100) {
- percent = 100;
- }
-
- // Display the time a click would seek to
- 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 (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
- toggle(event.type === 'mouseenter');
- }
- },
-
- // 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);
- },
-
- // 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;
- }
-
- // 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;
- }
-
- // Update ARIA values
- if (is.element(this.elements.inputs.seek)) {
- this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);
- }
-
- // If there's a spot to display duration
- const hasDuration = is.element(this.elements.display.duration);
-
- // 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 there's a duration element, update content
- if (hasDuration) {
- controls.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
- }
-
- // Update the tooltip (if visible)
- controls.updateSeekTooltip.call(this);
- },
-
- // Hide/show a tab
- 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;
- }
- },
+ 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;
- }
- },
-
- // Set the quality menu
- setQualityMenu(options) {
- // Menu required
- if (!is.element(this.elements.settings.panels.quality)) {
- return;
- }
-
- const type = 'quality';
- 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)) {
- this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality));
- }
-
- // Toggle the pane and tab
- const toggle = !is.empty(this.options.quality) && this.options.quality.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);
-
- // If we're hiding, nothing more to do
- if (!toggle) {
- return;
- }
-
- // Get the badge HTML for HD, 4K etc
- const getBadge = quality => {
- const label = i18n.get(`qualityBadge.${quality}`, this.config);
-
- if (!label.length) {
- return null;
- }
-
- return controls.createBadge.call(this, label);
- };
-
- // 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),
- });
- });
-
- controls.updateSetting.call(this, type, list);
- },
-
- // Set the looping options
- /* setLoopMenu() {
+ // Set the looping options
+ /* setLoopMenu() {
// Menu required
if (!is.element(this.elements.settings.panels.loop)) {
return;
@@ -988,768 +986,766 @@ const controls = {
});
}, */
- // Get current selected caption language
- // TODO: rework this to user the getter in the API?
-
- // 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.panels.captions.querySelector('[role="menu"]');
- const tracks = captions.getTracks.call(this);
- const toggle = Boolean(tracks.length);
-
- // Toggle the pane and tab
- controls.toggleMenuButton.call(this, type, toggle);
-
- // Empty the menu
- emptyElement(list);
-
- // Check if we need to toggle the parent
- controls.checkMenu.call(this);
-
- // If there's no captions, bail
- if (!toggle) {
- return;
- }
-
- // 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
- options.unshift({
- value: -1,
- checked: !this.captions.toggled,
- title: i18n.get('disabled', this.config),
- list,
- type: 'language',
- });
-
- // Generate options
- options.forEach(controls.createMenuItem.bind(this));
-
- controls.updateSetting.call(this, type, list);
- },
-
- // Set a list of available captions languages
- setSpeedMenu() {
- // Menu required
- if (!is.element(this.elements.settings.panels.speed)) {
- return;
- }
-
- const type = 'speed';
- const list = this.elements.settings.panels.speed.querySelector('[role="menu"]');
-
- // Filter out invalid speeds
- this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed);
-
- // Toggle the pane and tab
- 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);
-
- // If we're hiding, nothing more to do
- if (!toggle) {
- return;
- }
-
- // Create items
- this.options.speed.forEach(speed => {
- controls.createMenuItem.call(this, {
- value: speed,
- list,
- type,
- title: controls.getLabel.call(this, 'speed', speed),
- });
+ // Get current selected caption language
+ // TODO: rework this to user the getter in the API?
+
+ // 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.panels.captions.querySelector('[role="menu"]');
+ const tracks = captions.getTracks.call(this);
+ const toggle = Boolean(tracks.length);
+
+ // Toggle the pane and tab
+ controls.toggleMenuButton.call(this, type, toggle);
+
+ // Empty the menu
+ emptyElement(list);
+
+ // Check if we need to toggle the parent
+ controls.checkMenu.call(this);
+
+ // If there's no captions, bail
+ if (!toggle) {
+ return;
+ }
+
+ // 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
+ options.unshift({
+ value: -1,
+ checked: !this.captions.toggled,
+ title: i18n.get('disabled', this.config),
+ list,
+ type: 'language',
+ });
+
+ // Generate options
+ options.forEach(controls.createMenuItem.bind(this));
+
+ controls.updateSetting.call(this, type, list);
+ },
+
+ // Set a list of available captions languages
+ setSpeedMenu() {
+ // Menu required
+ if (!is.element(this.elements.settings.panels.speed)) {
+ return;
+ }
+
+ const type = 'speed';
+ const list = this.elements.settings.panels.speed.querySelector('[role="menu"]');
+
+ // Filter out invalid speeds
+ this.options.speed = this.options.speed.filter(o => o >= this.minimumSpeed && o <= this.maximumSpeed);
+
+ // Toggle the pane and tab
+ 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);
+
+ // If we're hiding, nothing more to do
+ if (!toggle) {
+ return;
+ }
+
+ // Create items
+ this.options.speed.forEach(speed => {
+ controls.createMenuItem.call(this, {
+ value: speed,
+ list,
+ type,
+ title: controls.getLabel.call(this, 'speed', speed),
+ });
+ });
+
+ controls.updateSetting.call(this, type, list);
+ },
+
+ // Check if we need to hide/show the settings menu
+ checkMenu() {
+ const { buttons } = this.elements.settings;
+ const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden);
+
+ 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(p => !p.hidden);
+ }
+
+ const firstItem = target.querySelector('[role^="menuitem"]');
+
+ setFocus.call(this, firstItem, tabFocus);
+ },
+
+ // Show/hide menu
+ toggleMenu(input) {
+ const { popup } = this.elements.settings;
+ const button = this.elements.buttons.settings;
+
+ // Menu and button are required
+ if (!is.element(popup) || !is.element(button)) {
+ return;
+ }
+
+ // True toggle by default
+ const { hidden } = popup;
+ let show = hidden;
+
+ if (is.boolean(input)) {
+ show = input;
+ } else if (is.keyboardEvent(input) && input.which === 27) {
+ show = false;
+ } else if (is.event(input)) {
+ // If Plyr is in a shadowDOM, the event target is set to the component, instead of the
+ // Element in the shadowDOM. The path, if available, is complete.
+ const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target;
+ const isMenuItem = popup.contains(target);
+
+ // If the click was inside the menu or if the click
+ // wasn't the button or menu item and we're trying to
+ // show the menu (a doc click shouldn't show the menu)
+ if (isMenuItem || (!isMenuItem && input.target !== button && show)) {
+ return;
+ }
+ }
+
+ // 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)) {
+ 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 menu panel
+ getMenuSize(tab) {
+ const clone = tab.cloneNode(true);
+ clone.style.position = 'absolute';
+ clone.style.opacity = 0;
+ clone.removeAttribute('hidden');
+
+ // Append to parent so we get the "real" size
+ tab.parentNode.appendChild(clone);
+
+ // Get the sizes before we remove
+ const width = clone.scrollWidth;
+ const height = clone.scrollHeight;
+
+ // Remove from the DOM
+ removeElement(clone);
+
+ return {
+ width,
+ height,
+ };
+ },
+
+ // Show a panel in the menu
+ showMenuPanel(type = '', tabFocus = false) {
+ const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`);
+
+ // Nothing to show, bail
+ if (!is.element(target)) {
+ return;
+ }
+
+ // 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) {
+ // Set the current width as a base
+ container.style.width = `${current.scrollWidth}px`;
+ container.style.height = `${current.scrollHeight}px`;
+
+ // Get potential sizes
+ const size = controls.getMenuSize.call(this, target);
+
+ // Restore auto height/width
+ const restore = event => {
+ // We're only bothered about height and width on the container
+ if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {
+ return;
+ }
+
+ // Revert back to auto
+ container.style.width = '';
+ container.style.height = '';
+
+ // Only listen once
+ off.call(this, container, transitionEndEvent, restore);
+ };
+
+ // Listen for the transition finishing and restore auto height/width
+ on.call(this, container, transitionEndEvent, restore);
+
+ // Set dimensions to target
+ container.style.width = `${size.width}px`;
+ container.style.height = `${size.height}px`;
+ }
+
+ // Set attributes on current tab
+ toggleHidden(current, true);
+
+ // Set attributes on target
+ toggleHidden(target, false);
+
+ // Focus the first item
+ controls.focusFirstMenuItem.call(this, target, tabFocus);
+ },
+
+ // Set the download URL
+ setDownloadUrl() {
+ const button = this.elements.buttons.download;
+
+ // Bail if no button
+ if (!is.element(button)) {
+ return;
+ }
+
+ // Set attribute
+ button.setAttribute('href', this.download);
+ },
+
+ // Build the default HTML
+ create(data) {
+ const {
+ bindMenuItemShortcuts,
+ createButton,
+ createProgress,
+ createRange,
+ createTime,
+ setQualityMenu,
+ setSpeedMenu,
+ showMenuPanel,
+ } = controls;
+ this.elements.controls = null;
+
+ // Larger overlaid play button
+ if (is.array(this.config.controls) && this.config.controls.includes('play-large')) {
+ this.elements.container.appendChild(createButton.call(this, 'play-large'));
+ }
+
+ // Create the container
+ const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
+ this.elements.controls = container;
+
+ // Default item attributes
+ const defaultAttributes = { class: 'plyr__controls__item' };
+
+ // Loop through controls in order
+ dedupe(is.array(this.config.controls) ? this.config.controls: []).forEach(control => {
+ // Restart button
+ if (control === 'restart') {
+ container.appendChild(createButton.call(this, 'restart', defaultAttributes));
+ }
+
+ // Rewind button
+ if (control === 'rewind') {
+ container.appendChild(createButton.call(this, 'rewind', defaultAttributes));
+ }
+
+ // Play/Pause button
+ if (control === 'play') {
+ container.appendChild(createButton.call(this, 'play', defaultAttributes));
+ }
+
+ // Fast forward button
+ if (control === 'fast-forward') {
+ container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes));
+ }
+
+ // Progress
+ if (control === 'progress') {
+ const progressContainer = createElement('div', {
+ class: `${defaultAttributes.class} plyr__progress__container`,
});
- controls.updateSetting.call(this, type, list);
- },
+ const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
- // Check if we need to hide/show the settings menu
- checkMenu() {
- const { buttons } = this.elements.settings;
- const visible = !is.empty(buttons) && Object.values(buttons).some(button => !button.hidden);
+ // Seek range slider
+ progress.appendChild(
+ createRange.call(this, 'seek', {
+ id: `plyr-seek-${data.id}`,
+ }),
+ );
- toggleHidden(this.elements.settings.menu, !visible);
- },
+ // Buffer progress
+ progress.appendChild(createProgress.call(this, 'buffer'));
- // Focus the first menu item in a given (or visible) menu
- focusFirstMenuItem(pane, tabFocus = false) {
- if (this.elements.settings.popup.hidden) {
- return;
- }
+ // TODO: Add loop display indicator
- let target = pane;
+ // Seek tooltip
+ if (this.config.tooltips.seek) {
+ const tooltip = createElement(
+ 'span',
+ {
+ class: this.config.classNames.tooltip,
+ },
+ '00:00',
+ );
- if (!is.element(target)) {
- target = Object.values(this.elements.settings.panels).find(p => !p.hidden);
+ progress.appendChild(tooltip);
+ this.elements.display.seekTooltip = tooltip;
}
- const firstItem = target.querySelector('[role^="menuitem"]');
-
- setFocus.call(this, firstItem, tabFocus);
- },
+ this.elements.progress = progress;
+ progressContainer.appendChild(this.elements.progress);
+ container.appendChild(progressContainer);
+ }
- // Show/hide menu
- toggleMenu(input) {
- const { popup } = this.elements.settings;
- const button = this.elements.buttons.settings;
-
- // Menu and button are required
- if (!is.element(popup) || !is.element(button)) {
- return;
- }
+ // Media current time display
+ if (control === 'current-time') {
+ container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));
+ }
- // True toggle by default
- const { hidden } = popup;
- let show = hidden;
-
- if (is.boolean(input)) {
- show = input;
- } else if (is.keyboardEvent(input) && input.which === 27) {
- show = false;
- } else if (is.event(input)) {
- // If Plyr is in a shadowDOM, the event target is set to the component, instead of the
- // Element in the shadowDOM. The path, if available, is complete.
- const target = is.function(input.composedPath) ? input.composedPath()[0] : input.target;
- const isMenuItem = popup.contains(target);
-
- // If the click was inside the menu or if the click
- // wasn't the button or menu item and we're trying to
- // show the menu (a doc click shouldn't show the menu)
- if (isMenuItem || (!isMenuItem && input.target !== button && show)) {
- return;
- }
- }
+ // Media duration display
+ if (control === 'duration') {
+ container.appendChild(createTime.call(this, 'duration', defaultAttributes));
+ }
- // Set button attributes
- button.setAttribute('aria-expanded', show);
+ // Volume controls
+ if (control === 'mute' || control === 'volume') {
+ let { volume } = this.elements;
- // Show the actual popup
- toggleHidden(popup, !show);
+ // Create the volume container if needed
+ if (!is.element(volume) || !container.contains(volume)) {
+ volume = createElement(
+ 'div',
+ extend({}, defaultAttributes, {
+ class: `${defaultAttributes.class} plyr__volume`.trim(),
+ }),
+ );
- // Add class hook
- toggleClass(this.elements.container, this.config.classNames.menu.open, show);
+ this.elements.volume = volume;
- // 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));
+ container.appendChild(volume);
}
- },
-
- // 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');
-
- // Append to parent so we get the "real" size
- tab.parentNode.appendChild(clone);
-
- // Get the sizes before we remove
- const width = clone.scrollWidth;
- const height = clone.scrollHeight;
-
- // Remove from the DOM
- removeElement(clone);
-
- return {
- width,
- height,
- };
- },
-
- // Show a panel in the menu
- showMenuPanel(type = '', tabFocus = false) {
- const target = this.elements.container.querySelector(`#plyr-settings-${this.id}-${type}`);
- // Nothing to show, bail
- if (!is.element(target)) {
- return;
+ // Toggle mute button
+ if (control === 'mute') {
+ volume.appendChild(createButton.call(this, 'mute'));
}
- // Hide all other panels
- const container = target.parentNode;
- const current = Array.from(container.children).find(node => !node.hidden);
+ // Volume range control
+ // Ignored on iOS as it's handled globally
+ // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
+ if (control === 'volume' && !browser.isIos) {
+ // Set the attributes
+ const attributes = {
+ max: 1,
+ step: 0.05,
+ value: this.config.volume,
+ };
- // If we can do fancy animations, we'll animate the height/width
- if (support.transitions && !support.reducedMotion) {
- // Set the current width as a base
- container.style.width = `${current.scrollWidth}px`;
- container.style.height = `${current.scrollHeight}px`;
+ // Create the volume range slider
+ volume.appendChild(
+ createRange.call(
+ this,
+ 'volume',
+ extend(attributes, {
+ id: `plyr-volume-${data.id}`,
+ }),
+ ),
+ );
+ }
+ }
+
+ // Toggle captions button
+ if (control === 'captions') {
+ container.appendChild(createButton.call(this, 'captions', defaultAttributes));
+ }
+
+ // Settings button / menu
+ if (control === 'settings' && !is.empty(this.config.settings)) {
+ const wrapper = createElement(
+ 'div',
+ extend({}, defaultAttributes, {
+ class: `${defaultAttributes.class} plyr__menu`.trim(),
+ hidden: '',
+ }),
+ );
- // Get potential sizes
- const size = controls.getMenuSize.call(this, target);
+ wrapper.appendChild(
+ createButton.call(this, 'settings', {
+ 'aria-haspopup': true,
+ 'aria-controls': `plyr-settings-${data.id}`,
+ 'aria-expanded': false,
+ }),
+ );
- // Restore auto height/width
- const restore = event => {
- // We're only bothered about height and width on the container
- if (event.target !== container || !['width', 'height'].includes(event.propertyName)) {
- return;
- }
+ const popup = createElement('div', {
+ class: 'plyr__menu__container',
+ id: `plyr-settings-${data.id}`,
+ hidden: '',
+ });
- // Revert back to auto
- container.style.width = '';
- container.style.height = '';
+ const inner = createElement('div');
- // Only listen once
- off.call(this, container, transitionEndEvent, restore);
- };
+ const home = createElement('div', {
+ id: `plyr-settings-${data.id}-home`,
+ });
- // Listen for the transition finishing and restore auto height/width
- on.call(this, container, transitionEndEvent, restore);
+ // Create the menu
+ const menu = createElement('div', {
+ role: 'menu',
+ });
- // Set dimensions to target
- container.style.width = `${size.width}px`;
- container.style.height = `${size.height}px`;
- }
+ home.appendChild(menu);
+ inner.appendChild(home);
+ this.elements.settings.panels.home = home;
- // Set attributes on current tab
- toggleHidden(current, true);
+ // Build the menu items
+ this.config.settings.forEach(type => {
+ // TODO: bundle this with the createMenuItem helper and bindings
+ const menuItem = createElement(
+ 'button',
+ extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {
+ type: 'button',
+ class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
+ role: 'menuitem',
+ 'aria-haspopup': true,
+ hidden: '',
+ }),
+ );
- // Set attributes on target
- toggleHidden(target, false);
+ // Bind menu shortcuts for keyboard users
+ bindMenuItemShortcuts.call(this, menuItem, type);
- // Focus the first item
- controls.focusFirstMenuItem.call(this, target, tabFocus);
- },
+ // Show menu on click
+ on.call(this, menuItem, 'click', () => {
+ showMenuPanel.call(this, type, false);
+ });
- // Set the download URL
- setDownloadUrl() {
- const button = this.elements.buttons.download;
+ const flex = createElement('span', null, i18n.get(type, this.config));
- // Bail if no button
- if (!is.element(button)) {
- return;
- }
-
- // Set attribute
- button.setAttribute('href', this.download);
- },
-
- // Build the default HTML
- create(data) {
- const {
- bindMenuItemShortcuts,
- createButton,
- createProgress,
- createRange,
- createTime,
- setQualityMenu,
- setSpeedMenu,
- showMenuPanel,
- } = controls;
- this.elements.controls = null;
-
- // Larger overlaid play button
- if (this.config.controls.includes('play-large')) {
- this.elements.container.appendChild(createButton.call(this, 'play-large'));
- }
-
- // Create the container
- const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
- this.elements.controls = container;
+ const value = createElement('span', {
+ class: this.config.classNames.menu.value,
+ });
- // Default item attributes
- const defaultAttributes = { class: 'plyr__controls__item' };
+ // Speed contains HTML entities
+ value.innerHTML = data[type];
- // Loop through controls in order
- dedupe(this.config.controls).forEach(control => {
- // Restart button
- if (control === 'restart') {
- container.appendChild(createButton.call(this, 'restart', defaultAttributes));
- }
+ flex.appendChild(value);
+ menuItem.appendChild(flex);
+ menu.appendChild(menuItem);
- // Rewind button
- if (control === 'rewind') {
- container.appendChild(createButton.call(this, 'rewind', defaultAttributes));
- }
+ // Build the panes
+ const pane = createElement('div', {
+ id: `plyr-settings-${data.id}-${type}`,
+ hidden: '',
+ });
- // Play/Pause button
- if (control === 'play') {
- container.appendChild(createButton.call(this, 'play', defaultAttributes));
- }
+ // Back button
+ const backButton = createElement('button', {
+ type: 'button',
+ class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
+ });
- // Fast forward button
- if (control === 'fast-forward') {
- container.appendChild(createButton.call(this, 'fast-forward', defaultAttributes));
- }
+ // Visible label
+ backButton.appendChild(
+ createElement(
+ 'span',
+ {
+ 'aria-hidden': true,
+ },
+ i18n.get(type, this.config),
+ ),
+ );
- // Progress
- if (control === 'progress') {
- const progressContainer = createElement('div', {
- class: `${defaultAttributes.class} plyr__progress__container`,
- });
-
- const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
-
- // Seek range slider
- progress.appendChild(
- createRange.call(this, 'seek', {
- id: `plyr-seek-${data.id}`,
- }),
- );
-
- // Buffer progress
- progress.appendChild(createProgress.call(this, 'buffer'));
-
- // TODO: Add loop display indicator
-
- // Seek tooltip
- if (this.config.tooltips.seek) {
- const tooltip = createElement(
- 'span',
- {
- class: this.config.classNames.tooltip,
- },
- '00:00',
- );
-
- progress.appendChild(tooltip);
- this.elements.display.seekTooltip = tooltip;
- }
-
- this.elements.progress = progress;
- progressContainer.appendChild(this.elements.progress);
- container.appendChild(progressContainer);
- }
+ // Screen reader label
+ backButton.appendChild(
+ createElement(
+ 'span',
+ {
+ class: this.config.classNames.hidden,
+ },
+ i18n.get('menuBack', this.config),
+ ),
+ );
- // Media current time display
- if (control === 'current-time') {
- container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));
- }
+ // Go back via keyboard
+ on.call(
+ this,
+ pane,
+ 'keydown',
+ event => {
+ // We only care about <-
+ if (event.which !== 37) {
+ return;
+ }
- // Media duration display
- if (control === 'duration') {
- container.appendChild(createTime.call(this, 'duration', defaultAttributes));
- }
+ // Prevent seek
+ event.preventDefault();
+ event.stopPropagation();
- // Volume controls
- if (control === 'mute' || control === 'volume') {
- let { volume } = this.elements;
-
- // Create the volume container if needed
- if (!is.element(volume) || !container.contains(volume)) {
- volume = createElement(
- 'div',
- extend({}, defaultAttributes, {
- class: `${defaultAttributes.class} plyr__volume`.trim(),
- }),
- );
-
- this.elements.volume = volume;
-
- container.appendChild(volume);
- }
-
- // Toggle mute button
- if (control === 'mute') {
- volume.appendChild(createButton.call(this, 'mute'));
- }
-
- // Volume range control
- // Ignored on iOS as it's handled globally
- // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
- if (control === 'volume' && !browser.isIos) {
- // Set the attributes
- const attributes = {
- max: 1,
- step: 0.05,
- value: this.config.volume,
- };
-
- // Create the volume range slider
- volume.appendChild(
- createRange.call(
- this,
- 'volume',
- extend(attributes, {
- id: `plyr-volume-${data.id}`,
- }),
- ),
- );
- }
- }
+ // Show the respective menu
+ showMenuPanel.call(this, 'home', true);
+ },
+ false,
+ );
- // Toggle captions button
- if (control === 'captions') {
- container.appendChild(createButton.call(this, 'captions', defaultAttributes));
- }
+ // Go back via button click
+ on.call(this, backButton, 'click', () => {
+ showMenuPanel.call(this, 'home', false);
+ });
- // Settings button / menu
- if (control === 'settings' && !is.empty(this.config.settings)) {
- const wrapper = createElement(
- 'div',
- extend({}, defaultAttributes, {
- class: `${defaultAttributes.class} plyr__menu`.trim(),
- hidden: '',
- }),
- );
-
- wrapper.appendChild(
- createButton.call(this, 'settings', {
- 'aria-haspopup': true,
- 'aria-controls': `plyr-settings-${data.id}`,
- 'aria-expanded': false,
- }),
- );
-
- const popup = createElement('div', {
- class: 'plyr__menu__container',
- id: `plyr-settings-${data.id}`,
- hidden: '',
- });
-
- const inner = createElement('div');
-
- const home = createElement('div', {
- id: `plyr-settings-${data.id}-home`,
- });
-
- // Create the menu
- const menu = createElement('div', {
- role: 'menu',
- });
-
- home.appendChild(menu);
- inner.appendChild(home);
- this.elements.settings.panels.home = home;
-
- // Build the menu items
- this.config.settings.forEach(type => {
- // TODO: bundle this with the createMenuItem helper and bindings
- const menuItem = createElement(
- 'button',
- extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {
- type: 'button',
- class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
- role: 'menuitem',
- 'aria-haspopup': true,
- hidden: '',
- }),
- );
-
- // Bind menu shortcuts for keyboard users
- bindMenuItemShortcuts.call(this, menuItem, type);
-
- // Show menu on click
- on.call(this, menuItem, 'click', () => {
- showMenuPanel.call(this, type, false);
- });
-
- const flex = createElement('span', null, i18n.get(type, this.config));
-
- const value = createElement('span', {
- class: this.config.classNames.menu.value,
- });
-
- // Speed contains HTML entities
- value.innerHTML = data[type];
-
- flex.appendChild(value);
- menuItem.appendChild(flex);
- menu.appendChild(menuItem);
-
- // Build the panes
- const pane = createElement('div', {
- id: `plyr-settings-${data.id}-${type}`,
- hidden: '',
- });
-
- // Back button
- const backButton = createElement('button', {
- type: 'button',
- class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
- });
-
- // Visible label
- backButton.appendChild(
- createElement(
- 'span',
- {
- 'aria-hidden': true,
- },
- i18n.get(type, this.config),
- ),
- );
-
- // Screen reader label
- backButton.appendChild(
- createElement(
- 'span',
- {
- class: this.config.classNames.hidden,
- },
- i18n.get('menuBack', this.config),
- ),
- );
-
- // Go back via keyboard
- on.call(
- this,
- pane,
- 'keydown',
- event => {
- // We only care about <-
- if (event.which !== 37) {
- return;
- }
-
- // Prevent seek
- event.preventDefault();
- event.stopPropagation();
-
- // Show the respective menu
- showMenuPanel.call(this, 'home', true);
- },
- false,
- );
-
- // Go back via button click
- on.call(this, backButton, 'click', () => {
- showMenuPanel.call(this, 'home', false);
- });
-
- // Add to pane
- pane.appendChild(backButton);
-
- // Menu
- pane.appendChild(
- createElement('div', {
- role: 'menu',
- }),
- );
-
- inner.appendChild(pane);
-
- this.elements.settings.buttons[type] = menuItem;
- this.elements.settings.panels[type] = pane;
- });
-
- popup.appendChild(inner);
- wrapper.appendChild(popup);
- container.appendChild(wrapper);
-
- this.elements.settings.popup = popup;
- this.elements.settings.menu = wrapper;
- }
+ // Add to pane
+ pane.appendChild(backButton);
- // Picture in picture button
- if (control === 'pip' && support.pip) {
- container.appendChild(createButton.call(this, 'pip', defaultAttributes));
- }
+ // Menu
+ pane.appendChild(
+ createElement('div', {
+ role: 'menu',
+ }),
+ );
- // Airplay button
- if (control === 'airplay' && support.airplay) {
- container.appendChild(createButton.call(this, 'airplay', defaultAttributes));
- }
+ inner.appendChild(pane);
- // Download button
- if (control === 'download') {
- const attributes = extend({}, defaultAttributes, {
- element: 'a',
- href: this.download,
- target: '_blank',
- });
-
- // Set download attribute for HTML5 only
- if (this.isHTML5) {
- attributes.download = '';
- }
-
- const { download } = this.config.urls;
-
- if (!is.url(download) && this.isEmbed) {
- extend(attributes, {
- icon: `logo-${this.provider}`,
- label: this.provider,
- });
- }
-
- container.appendChild(createButton.call(this, 'download', attributes));
- }
+ this.elements.settings.buttons[type] = menuItem;
+ this.elements.settings.panels[type] = pane;
+ });
- // Toggle fullscreen button
- if (control === 'fullscreen') {
- container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes));
- }
+ popup.appendChild(inner);
+ wrapper.appendChild(popup);
+ container.appendChild(wrapper);
+
+ this.elements.settings.popup = popup;
+ this.elements.settings.menu = wrapper;
+ }
+
+ // Picture in picture button
+ if (control === 'pip' && support.pip) {
+ container.appendChild(createButton.call(this, 'pip', defaultAttributes));
+ }
+
+ // Airplay button
+ if (control === 'airplay' && support.airplay) {
+ container.appendChild(createButton.call(this, 'airplay', defaultAttributes));
+ }
+
+ // Download button
+ if (control === 'download') {
+ const attributes = extend({}, defaultAttributes, {
+ element: 'a',
+ href: this.download,
+ target: '_blank',
});
- // Set available quality levels
+ // Set download attribute for HTML5 only
if (this.isHTML5) {
- setQualityMenu.call(this, html5.getQualityOptions.call(this));
- }
-
- setSpeedMenu.call(this);
-
- return container;
- },
-
- // Insert controls
- inject() {
- // Sprite
- if (this.config.loadSprite) {
- const icon = controls.getIconUrl.call(this);
-
- // Only load external sprite using AJAX
- if (icon.cors) {
- loadSprite(icon.url, 'sprite-plyr');
- }
- }
-
- // Create a unique ID
- this.id = Math.floor(Math.random() * 10000);
-
- // Null by default
- let container = null;
- this.elements.controls = null;
-
- // Set template properties
- const props = {
- id: this.id,
- seektime: this.config.seekTime,
- title: this.config.title,
- };
- let update = true;
-
- // 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 {
- // Create controls
- container = controls.create.call(this, {
- id: this.id,
- seektime: this.config.seekTime,
- speed: this.speed,
- quality: this.quality,
- captions: captions.getLabel.call(this),
- // TODO: Looping
- // loop: 'None',
- });
- update = false;
- }
-
- // Replace props with their value
- const replace = input => {
- let result = input;
-
- Object.entries(props).forEach(([key, value]) => {
- result = replaceAll(result, `{${key}}`, value);
- });
-
- return result;
- };
-
- // Update markup
- if (update) {
- if (is.string(this.config.controls)) {
- container = replace(container);
- } else if (is.element(container)) {
- container.innerHTML = replace(container.innerHTML);
- }
- }
-
- // Controls container
- let target;
-
- // Inject to custom location
- if (is.string(this.config.selectors.controls.container)) {
- target = document.querySelector(this.config.selectors.controls.container);
- }
-
- // Inject into the container by default
- if (!is.element(target)) {
- target = this.elements.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 (!is.element(this.elements.controls)) {
- controls.findElements.call(this);
- }
-
- // 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 { classNames, selectors } = this.config;
- const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;
- const labels = getElements.call(this, selector);
-
- Array.from(labels).forEach(label => {
- toggleClass(label, this.config.classNames.hidden, false);
- toggleClass(label, this.config.classNames.tooltip, true);
- });
- }
- },
+ attributes.download = '';
+ }
+
+ const { download } = this.config.urls;
+
+ if (!is.url(download) && this.isEmbed) {
+ extend(attributes, {
+ icon: `logo-${this.provider}`,
+ label: this.provider,
+ });
+ }
+
+ container.appendChild(createButton.call(this, 'download', attributes));
+ }
+
+ // Toggle fullscreen button
+ if (control === 'fullscreen') {
+ container.appendChild(createButton.call(this, 'fullscreen', defaultAttributes));
+ }
+ });
+
+ // Set available quality levels
+ if (this.isHTML5) {
+ setQualityMenu.call(this, html5.getQualityOptions.call(this));
+ }
+
+ setSpeedMenu.call(this);
+
+ return container;
+ },
+
+ // Insert controls
+ inject() {
+ // Sprite
+ if (this.config.loadSprite) {
+ const icon = controls.getIconUrl.call(this);
+
+ // Only load external sprite using AJAX
+ if (icon.cors) {
+ loadSprite(icon.url, 'sprite-plyr');
+ }
+ }
+
+ // Create a unique ID
+ this.id = Math.floor(Math.random() * 10000);
+
+ // Null by default
+ let container = null;
+ this.elements.controls = null;
+
+ // Set template properties
+ const props = {
+ id: this.id,
+ seektime: this.config.seekTime,
+ title: this.config.title,
+ };
+ let update = true;
+
+ // 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 {
+ // Create controls
+ container = controls.create.call(this, {
+ id: this.id,
+ seektime: this.config.seekTime,
+ speed: this.speed,
+ quality: this.quality,
+ captions: captions.getLabel.call(this),
+ // TODO: Looping
+ // loop: 'None',
+ });
+ update = false;
+ }
+
+ // Replace props with their value
+ const replace = input => {
+ let result = input;
+
+ Object.entries(props).forEach(([key, value]) => {
+ result = replaceAll(result, `{${key}}`, value);
+ });
+
+ return result;
+ };
+
+ // Update markup
+ if (update) {
+ if (is.string(this.config.controls)) {
+ container = replace(container);
+ }
+ }
+
+ // Controls container
+ let target;
+
+ // Inject to custom location
+ if (is.string(this.config.selectors.controls.container)) {
+ target = document.querySelector(this.config.selectors.controls.container);
+ }
+
+ // Inject into the container by default
+ if (!is.element(target)) {
+ target = this.elements.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 (!is.element(this.elements.controls)) {
+ controls.findElements.call(this);
+ }
+
+ // 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 { classNames, selectors } = this.config;
+ const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;
+ const labels = getElements.call(this, selector);
+
+ Array.from(labels).forEach(label => {
+ toggleClass(label, this.config.classNames.hidden, false);
+ toggleClass(label, this.config.classNames.tooltip, true);
+ });
+ }
+ },
};
export default controls;