SVG icons should be ignored by screen readers since they have complimentary labels (aria-label or plyr__sr-only). The current « presentation » role simply makes the element behave like a « span » which is incorrect, aria-hidden prevents screen readers from taking care of these elements at all.
1756 lines
58 KiB
JavaScript
1756 lines
58 KiB
JavaScript
// ==========================================================================
|
|
// Plyr controls
|
|
// TODO: This needs to be split into smaller files and cleaned up
|
|
// ==========================================================================
|
|
|
|
import RangeTouch from 'rangetouch';
|
|
|
|
import captions from './captions';
|
|
import html5 from './html5';
|
|
import support from './support';
|
|
import { repaint, transitionEndEvent } from './utils/animation';
|
|
import { dedupe } from './utils/arrays';
|
|
import browser from './utils/browser';
|
|
import {
|
|
createElement,
|
|
emptyElement,
|
|
getAttributesFromSelector,
|
|
getElement,
|
|
getElements,
|
|
hasClass,
|
|
matches,
|
|
removeElement,
|
|
setAttributes,
|
|
setFocus,
|
|
toggleClass,
|
|
toggleHidden,
|
|
} from './utils/elements';
|
|
import { off, on } from './utils/events';
|
|
import i18n from './utils/i18n';
|
|
import is from './utils/is';
|
|
import loadSprite from './utils/load-sprite';
|
|
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 = {
|
|
// 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}`,
|
|
});
|
|
}
|
|
} 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 {
|
|
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);
|
|
});
|
|
},
|
|
|
|
// 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}×`;
|
|
|
|
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() {
|
|
// Menu required
|
|
if (!is.element(this.elements.settings.panels.loop)) {
|
|
return;
|
|
}
|
|
|
|
const options = ['start', 'end', 'all', 'reset'];
|
|
const list = this.elements.settings.panels.loop.querySelector('[role="menu"]');
|
|
|
|
// Show the pane and tab
|
|
toggleHidden(this.elements.settings.buttons.loop, false);
|
|
toggleHidden(this.elements.settings.panels.loop, false);
|
|
|
|
// Toggle the pane and tab
|
|
const toggle = !is.empty(this.loop.options);
|
|
controls.toggleMenuButton.call(this, 'loop', toggle);
|
|
|
|
// Empty the menu
|
|
emptyElement(list);
|
|
|
|
options.forEach(option => {
|
|
const item = createElement('li');
|
|
|
|
const button = createElement(
|
|
'button',
|
|
extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {
|
|
type: 'button',
|
|
class: this.config.classNames.control,
|
|
'data-plyr-loop-action': option,
|
|
}),
|
|
i18n.get(option, this.config)
|
|
);
|
|
|
|
if (['start', 'end'].includes(option)) {
|
|
const badge = controls.createBadge.call(this, '00:00');
|
|
button.appendChild(badge);
|
|
}
|
|
|
|
item.appendChild(button);
|
|
list.appendChild(item);
|
|
});
|
|
}, */
|
|
|
|
// 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 (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(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`,
|
|
});
|
|
|
|
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);
|
|
}
|
|
|
|
// Media current time display
|
|
if (control === 'current-time') {
|
|
container.appendChild(createTime.call(this, 'currentTime', defaultAttributes));
|
|
}
|
|
|
|
// Media duration display
|
|
if (control === 'duration') {
|
|
container.appendChild(createTime.call(this, 'duration', defaultAttributes));
|
|
}
|
|
|
|
// 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}`,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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: '',
|
|
}),
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
// 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 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));
|
|
}
|
|
|
|
// 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);
|
|
} 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);
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
export default controls;
|