plyr/src/js/controls.js
Sam Potts e8d883edba
v3.6.3 (#2016)
* force fullscreen events to trigger on plyr element (media element in iOS) and not fullscreen container

* Fixing "missing code in detail" for PlyrEvent type

When using typescript and listening for youtube statechange event, it is missing the code property definition inside the event (even though it is provided in the code).
By making events a map of key-value, we can add easily custom event type for specific event name. Since YouTube "statechange" event differs from the basic PlyrEvent, I added a new Event Type "PlyrStateChangeEvent" having a code property corresponding to a YoutubeState enum defined by the YouTube API documentation.
This pattern follows how addEventListener in the lib.dom.d.ts is defined.

* Update link to working dash.js demo (was broken)

* Fix PreviewThumbnailsOptions type

According to the docs, the `src` should also accept an array of strings.

* fix issue #1872

* Check if key is a string before attempt --plyr checking

* Fix for Slow loading videos not autoplaying

* Fix for Slow loading videos not autoplaying

* Network requests are not cancelled after the player is destroyed

* Fix for apect ratio problem when using Vimeo player on mobile devices (issue #1940)

* chore: update packages and linting

* Invoke custom listener on triggering fullscreen via double-click

* Fix volume when unmuting from volume 0

* adding a nice Svelte plugin that I found

* Add missing unit to calc in media query

* Assigning player's lastSeekTime on rewind/fast forward to prevent immediate controls hide on mobile

* Fix youtube not working when player is inside shadow dom

* v3.6.2

* ESLint to use common config

* add BitChute to users list

* Fix aspect ratio issue

* Revert noCookie change

* feat: demo radius tweaks

* fix: poster image shouldn’t receive click events

* chore: package updates

* chore: linting

* feat: custom controls option for embedded players

* Package upgrades

* ESLint to use common config

* Linting changes

* Update README.md

* chore: formatting

* fix: revert pointer events change for poster

* fix: hack for Safari 14 not repainting Vimeo embed on entering fullscreen

* fix: demo using custom controls for YouTube

* doc: Add STROLLÿN among the list of Plyr users

* Fixes #2005

* fix: overflowing volume slider

* chore: clean up CSS

* fix: hide poster when not using custom controls

* Package upgrades

* ESLint to use common config

* Linting changes

* chore: revert customControls default option (to prevent breaking change)

* docs: changelog for v3.6.3

Co-authored-by: Som Meaden <som@theprojectsomething.com>
Co-authored-by: akuma06 <demon.akuma06@gmail.com>
Co-authored-by: Jonathan Arbely <dev@jonathanarbely.de>
Co-authored-by: Takeshi <iwatakeshi@users.noreply.github.com>
Co-authored-by: Hex <hex@codeigniter.org.cn>
Co-authored-by: Syed Husain <syed.husain@appspace.com>
Co-authored-by: Danielh112 <Daniel@sbgsportssoftware.com>
Co-authored-by: Danil Stoyanov <d.stoyanov@corp.mail.ru>
Co-authored-by: Guru Prasad Srinivasa <gurupras@buffalo.edu>
Co-authored-by: Stephane Fortin Bouchard <stephane.f.bouchard@gmail.com>
Co-authored-by: Zev Averbach <zev@averba.ch>
Co-authored-by: Vincent Orback <hello@vincentorback.se>
Co-authored-by: trafium <trafium@gmail.com>
Co-authored-by: xansen <27698939+xansen@users.noreply.github.com>
Co-authored-by: zoomerdev <59863739+zoomerdev@users.noreply.github.com>
Co-authored-by: Mikaël Castellani <mikael.castellani@gmail.com>
Co-authored-by: dirkjf <d.j.faber@outlook.com>
2020-11-14 13:24:11 +11:00

1750 lines
50 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}&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() {
// 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 (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`,
});
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);
}
}
// 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;