aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/js/captions.js231
-rw-r--r--src/js/console.js28
-rw-r--r--src/js/controls.js1257
-rw-r--r--src/js/defaults.js383
-rw-r--r--src/js/fullscreen.js204
-rw-r--r--src/js/listeners.js595
-rw-r--r--src/js/media.js106
-rw-r--r--src/js/plugins/ads.js574
-rw-r--r--src/js/plugins/vimeo.js323
-rw-r--r--src/js/plugins/youtube.js417
-rw-r--r--src/js/plyr.js4585
-rw-r--r--src/js/plyr.polyfilled.js14
-rw-r--r--src/js/source.js148
-rw-r--r--src/js/storage.js71
-rw-r--r--src/js/support.js157
-rw-r--r--src/js/types.js16
-rw-r--r--src/js/ui.js323
-rw-r--r--src/js/utils.js866
-rw-r--r--src/less/mixins.less29
-rw-r--r--src/less/plyr.less770
-rw-r--r--src/less/variables.less72
-rw-r--r--src/sass/base.scss59
-rw-r--r--src/sass/components/badges.scss12
-rw-r--r--src/sass/components/captions.scss58
-rw-r--r--src/sass/components/control.scss89
-rw-r--r--src/sass/components/controls.scss105
-rw-r--r--src/sass/components/embed.scss36
-rw-r--r--src/sass/components/menus.scss198
-rw-r--r--src/sass/components/progress.scss94
-rw-r--r--src/sass/components/sliders.scss154
-rw-r--r--src/sass/components/times.scss24
-rw-r--r--src/sass/components/tooltips.scss85
-rw-r--r--src/sass/components/video.scss21
-rw-r--r--src/sass/components/volume.scss29
-rw-r--r--src/sass/lib/animation.scss31
-rw-r--r--src/sass/lib/functions.scss7
-rw-r--r--src/sass/lib/mixins.scss101
-rw-r--r--src/sass/plugins/ads.scss56
-rw-r--r--src/sass/plyr.scss47
-rw-r--r--src/sass/settings/badges.scss6
-rw-r--r--src/sass/settings/breakpoints.scss12
-rw-r--r--src/sass/settings/captions.scss10
-rw-r--r--src/sass/settings/colors.scss9
-rw-r--r--src/sass/settings/controls.scss19
-rw-r--r--src/sass/settings/cosmetics.scss5
-rw-r--r--src/sass/settings/helpers.scss7
-rw-r--r--src/sass/settings/menus.scss10
-rw-r--r--src/sass/settings/progress.scss11
-rw-r--r--src/sass/settings/sliders.scss24
-rw-r--r--src/sass/settings/tooltips.scss10
-rw-r--r--src/sass/settings/type.scss19
-rw-r--r--src/sass/states/error.scss25
-rw-r--r--src/sass/states/fullscreen.scss34
-rw-r--r--src/sass/utils/animation.scss7
-rw-r--r--src/sass/utils/hidden.scss33
-rw-r--r--src/scss/mixins.scss29
-rw-r--r--src/scss/plyr.scss770
-rw-r--r--src/scss/variables.scss73
-rw-r--r--src/sprite/plyr-airplay.svg7
-rw-r--r--src/sprite/plyr-pip.svg7
-rw-r--r--src/sprite/plyr-settings.svg6
61 files changed, 8088 insertions, 5420 deletions
diff --git a/src/js/captions.js b/src/js/captions.js
new file mode 100644
index 00000000..c8bc5833
--- /dev/null
+++ b/src/js/captions.js
@@ -0,0 +1,231 @@
+// ==========================================================================
+// Plyr Captions
+// TODO: Create as class
+// ==========================================================================
+
+import support from './support';
+import utils from './utils';
+import controls from './controls';
+
+const captions = {
+ // Setup captions
+ setup() {
+ // Requires UI support
+ if (!this.supported.ui) {
+ return;
+ }
+
+ // Set default language if not set
+ const stored = this.storage.get('language');
+
+ if (!utils.is.empty(stored)) {
+ this.captions.language = stored;
+ }
+
+ if (utils.is.empty(this.captions.language)) {
+ this.captions.language = this.config.captions.language.toLowerCase();
+ }
+
+ // Set captions enabled state if not set
+ if (!utils.is.boolean(this.captions.active)) {
+ const active = this.storage.get('captions');
+
+ if (utils.is.boolean(active)) {
+ this.captions.active = active;
+ } else {
+ this.captions.active = this.config.captions.active;
+ }
+ }
+
+ // Only Vimeo and HTML5 video supported at this point
+ if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) {
+ // Clear menu and hide
+ if (utils.is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
+ controls.setCaptionsMenu.call(this);
+ }
+
+ return;
+ }
+ // Inject the container
+ if (!utils.is.element(this.elements.captions)) {
+ this.elements.captions = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.captions));
+
+ utils.insertAfter(this.elements.captions, this.elements.wrapper);
+ }
+
+ // Set the class hook
+ utils.toggleClass(this.elements.container, this.config.classNames.captions.enabled, !utils.is.empty(captions.getTracks.call(this)));
+
+ // Get tracks
+ const tracks = captions.getTracks.call(this);
+
+ // If no caption file exists, hide container for caption text
+ if (utils.is.empty(tracks)) {
+ return;
+ }
+
+ // Get browser info
+ const browser = utils.getBrowser();
+
+ // Fix IE captions if CORS is used
+ // Fetch captions and inject as blobs instead (data URIs not supported!)
+ if (browser.isIE && window.URL) {
+ const elements = this.media.querySelectorAll('track');
+
+ Array.from(elements).forEach(track => {
+ const src = track.getAttribute('src');
+ const href = utils.parseUrl(src);
+
+ if (href.hostname !== window.location.href.hostname && [
+ 'http:',
+ 'https:',
+ ].includes(href.protocol)) {
+ utils
+ .fetch(src, 'blob')
+ .then(blob => {
+ track.setAttribute('src', window.URL.createObjectURL(blob));
+ })
+ .catch(() => {
+ utils.removeElement(track);
+ });
+ }
+ });
+ }
+
+ // Set language
+ captions.setLanguage.call(this);
+
+ // Enable UI
+ captions.show.call(this);
+
+ // Set available languages in list
+ if (utils.is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
+ controls.setCaptionsMenu.call(this);
+ }
+ },
+
+ // Set the captions language
+ setLanguage() {
+ // Setup HTML5 track rendering
+ if (this.isHTML5 && this.isVideo) {
+ captions.getTracks.call(this).forEach(track => {
+ // Show track
+ utils.on(track, 'cuechange', event => captions.setCue.call(this, event));
+
+ // Turn off native caption rendering to avoid double captions
+ // eslint-disable-next-line
+ track.mode = 'hidden';
+ });
+
+ // Get current track
+ const currentTrack = captions.getCurrentTrack.call(this);
+
+ // Check if suported kind
+ if (utils.is.track(currentTrack)) {
+ // If we change the active track while a cue is already displayed we need to update it
+ if (Array.from(currentTrack.activeCues || []).length) {
+ captions.setCue.call(this, currentTrack);
+ }
+ }
+ } else if (this.isVimeo && this.captions.active) {
+ this.embed.enableTextTrack(this.language);
+ }
+ },
+
+ // Get the tracks
+ getTracks() {
+ // Return empty array at least
+ if (utils.is.nullOrUndefined(this.media)) {
+ return [];
+ }
+
+ // Only get accepted kinds
+ return Array.from(this.media.textTracks || []).filter(track => [
+ 'captions',
+ 'subtitles',
+ ].includes(track.kind));
+ },
+
+ // Get the current track for the current language
+ getCurrentTrack() {
+ return captions.getTracks.call(this).find(track => track.language.toLowerCase() === this.language);
+ },
+
+ // Display active caption if it contains text
+ setCue(input) {
+ // Get the track from the event if needed
+ const track = utils.is.event(input) ? input.target : input;
+ const { activeCues } = track;
+ const active = activeCues.length && activeCues[0];
+ const currentTrack = captions.getCurrentTrack.call(this);
+
+ // Only display current track
+ if (track !== currentTrack) {
+ return;
+ }
+
+ // Display a cue, if there is one
+ if (utils.is.cue(active)) {
+ captions.setText.call(this, active.getCueAsHTML());
+ } else {
+ captions.setText.call(this, null);
+ }
+
+ utils.dispatchEvent.call(this, this.media, 'cuechange');
+ },
+
+ // Set the current caption
+ setText(input) {
+ // Requires UI
+ if (!this.supported.ui) {
+ return;
+ }
+
+ if (utils.is.element(this.elements.captions)) {
+ const content = utils.createElement('span');
+
+ // Empty the container
+ utils.emptyElement(this.elements.captions);
+
+ // Default to empty
+ const caption = !utils.is.nullOrUndefined(input) ? input : '';
+
+ // Set the span content
+ if (utils.is.string(caption)) {
+ content.textContent = caption.trim();
+ } else {
+ content.appendChild(caption);
+ }
+
+ // Set new caption text
+ this.elements.captions.appendChild(content);
+ } else {
+ this.debug.warn('No captions element to render to');
+ }
+ },
+
+ // Display captions container and button (for initialization)
+ show() {
+ // If there's no caption toggle, bail
+ if (!utils.is.element(this.elements.buttons.captions)) {
+ return;
+ }
+
+ // Try to load the value from storage
+ let active = this.storage.get('captions');
+
+ // Otherwise fall back to the default config
+ if (!utils.is.boolean(active)) {
+ ({ active } = this.config.captions);
+ } else {
+ this.captions.active = active;
+ }
+
+ if (active) {
+ utils.toggleClass(this.elements.container, this.config.classNames.captions.active, true);
+ utils.toggleState(this.elements.buttons.captions, true);
+ }
+ },
+};
+
+export default captions;
diff --git a/src/js/console.js b/src/js/console.js
new file mode 100644
index 00000000..7c5ec1b4
--- /dev/null
+++ b/src/js/console.js
@@ -0,0 +1,28 @@
+// ==========================================================================
+// Console wrapper
+// ==========================================================================
+
+const noop = () => {};
+
+export default class Console {
+ constructor(enabled = false) {
+ this.enabled = window.console && enabled;
+
+ if (this.enabled) {
+ this.log('Debugging enabled');
+ }
+ }
+
+ get log() {
+ // eslint-disable-next-line no-console
+ return this.enabled ? Function.prototype.bind.call(console.log, console) : noop;
+ }
+ get warn() {
+ // eslint-disable-next-line no-console
+ return this.enabled ? Function.prototype.bind.call(console.warn, console) : noop;
+ }
+ get error() {
+ // eslint-disable-next-line no-console
+ return this.enabled ? Function.prototype.bind.call(console.error, console) : noop;
+ }
+}
diff --git a/src/js/controls.js b/src/js/controls.js
new file mode 100644
index 00000000..4fdbe6d0
--- /dev/null
+++ b/src/js/controls.js
@@ -0,0 +1,1257 @@
+// ==========================================================================
+// Plyr controls
+// ==========================================================================
+
+import support from './support';
+import utils from './utils';
+import ui from './ui';
+import captions from './captions';
+
+// Sniff out the browser
+const browser = utils.getBrowser();
+
+const controls = {
+ // Webkit polyfill for lower fill range
+ updateRangeFill(target) {
+ // WebKit only
+ if (!browser.isWebkit) {
+ return;
+ }
+
+ // Get range from event if event passed
+ const range = utils.is.event(target) ? target.target : target;
+
+ // Needs to be a valid <input type='range'>
+ if (!utils.is.element(range) || range.getAttribute('type') !== 'range') {
+ return;
+ }
+
+ // Set CSS custom property
+ range.style.setProperty('--value', `${range.value / range.max * 100}%`);
+ },
+
+ // Get icon URL
+ getIconUrl() {
+ return {
+ url: this.config.iconUrl,
+ absolute: this.config.iconUrl.indexOf('http') === 0 || (browser.isIE && !window.svg4everybody),
+ };
+ },
+
+ // Create <svg> icon
+ createIcon(type, attributes) {
+ const namespace = 'http://www.w3.org/2000/svg';
+ const iconUrl = controls.getIconUrl.call(this);
+ const iconPath = `${!iconUrl.absolute ? iconUrl.url : ''}#${this.config.iconPrefix}`;
+
+ // Create <svg>
+ const icon = document.createElementNS(namespace, 'svg');
+ utils.setAttributes(
+ icon,
+ utils.extend(attributes, {
+ role: 'presentation',
+ }),
+ );
+
+ // 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);
+ } else {
+ 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(type, attr) {
+ let text = this.config.i18n[type];
+ const attributes = Object.assign({}, attr);
+
+ switch (type) {
+ case 'pip':
+ text = 'PIP';
+ break;
+
+ case 'airplay':
+ text = 'AirPlay';
+ break;
+
+ default:
+ break;
+ }
+
+ if ('class' in attributes) {
+ attributes.class += ` ${this.config.classNames.hidden}`;
+ } else {
+ attributes.class = this.config.classNames.hidden;
+ }
+
+ return utils.createElement('span', attributes, text);
+ },
+
+ // Create a badge
+ createBadge(text) {
+ if (utils.is.empty(text)) {
+ return null;
+ }
+
+ const badge = utils.createElement('span', {
+ class: this.config.classNames.menu.value,
+ });
+
+ badge.appendChild(
+ utils.createElement(
+ 'span',
+ {
+ class: this.config.classNames.menu.badge,
+ },
+ text,
+ ),
+ );
+
+ return badge;
+ },
+
+ // Create a <button>
+ createButton(buttonType, attr) {
+ const button = utils.createElement('button');
+ const attributes = Object.assign({}, attr);
+ let type = buttonType;
+
+ let toggle = false;
+ let label;
+ let icon;
+ let labelPressed;
+ let iconPressed;
+
+ if (!('type' in attributes)) {
+ attributes.type = 'button';
+ }
+
+ if ('class' in attributes) {
+ if (attributes.class.includes(this.config.classNames.control)) {
+ attributes.class += ` ${this.config.classNames.control}`;
+ }
+ } else {
+ attributes.class = this.config.classNames.control;
+ }
+
+ // Large play button
+ switch (type) {
+ case 'play':
+ toggle = true;
+ label = 'play';
+ labelPressed = 'pause';
+ icon = 'play';
+ iconPressed = 'pause';
+ break;
+
+ case 'mute':
+ toggle = true;
+ label = 'mute';
+ labelPressed = 'unmute';
+ icon = 'volume';
+ iconPressed = 'muted';
+ break;
+
+ case 'captions':
+ toggle = true;
+ label = 'enableCaptions';
+ labelPressed = 'disableCaptions';
+ icon = 'captions-off';
+ iconPressed = 'captions-on';
+ break;
+
+ case 'fullscreen':
+ toggle = true;
+ label = 'enterFullscreen';
+ labelPressed = 'exitFullscreen';
+ icon = 'enter-fullscreen';
+ iconPressed = 'exit-fullscreen';
+ break;
+
+ case 'play-large':
+ attributes.class += ` ${this.config.classNames.control}--overlaid`;
+ type = 'play';
+ label = 'play';
+ icon = 'play';
+ break;
+
+ default:
+ label = type;
+ icon = type;
+ }
+
+ // Setup toggle icon and labels
+ if (toggle) {
+ // Icon
+ button.appendChild(controls.createIcon.call(this, iconPressed, { class: 'icon--pressed' }));
+ button.appendChild(controls.createIcon.call(this, icon, { class: 'icon--not-pressed' }));
+
+ // Label/Tooltip
+ button.appendChild(controls.createLabel.call(this, labelPressed, { class: 'label--pressed' }));
+ button.appendChild(controls.createLabel.call(this, label, { class: 'label--not-pressed' }));
+
+ // Add aria attributes
+ attributes['aria-pressed'] = false;
+ attributes['aria-label'] = this.config.i18n[label];
+ } else {
+ button.appendChild(controls.createIcon.call(this, icon));
+ button.appendChild(controls.createLabel.call(this, label));
+ }
+
+ // Merge attributes
+ utils.extend(attributes, utils.getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
+
+ utils.setAttributes(button, attributes);
+
+ // We have multiple play buttons
+ if (type === 'play') {
+ if (!utils.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 label
+ const label = utils.createElement(
+ 'label',
+ {
+ for: attributes.id,
+ class: this.config.classNames.hidden,
+ },
+ this.config.i18n[type],
+ );
+
+ // Seek input
+ const input = utils.createElement(
+ 'input',
+ utils.extend(
+ utils.getAttributesFromSelector(this.config.selectors.inputs[type]),
+ {
+ type: 'range',
+ min: 0,
+ max: 100,
+ step: 0.01,
+ value: 0,
+ autocomplete: 'off',
+ },
+ attributes,
+ ),
+ );
+
+ this.elements.inputs[type] = input;
+
+ // Set the fill for webkit now
+ controls.updateRangeFill.call(this, input);
+
+ return {
+ label,
+ input,
+ };
+ },
+
+ // Create a <progress>
+ createProgress(type, attributes) {
+ const progress = utils.createElement(
+ 'progress',
+ utils.extend(
+ utils.getAttributesFromSelector(this.config.selectors.display[type]),
+ {
+ min: 0,
+ max: 100,
+ value: 0,
+ },
+ attributes,
+ ),
+ );
+
+ // Create the label inside
+ if (type !== 'volume') {
+ progress.appendChild(utils.createElement('span', null, '0'));
+
+ let suffix = '';
+ switch (type) {
+ case 'played':
+ suffix = this.config.i18n.played;
+ break;
+
+ case 'buffer':
+ suffix = this.config.i18n.buffered;
+ break;
+
+ default:
+ break;
+ }
+
+ progress.textContent = `% ${suffix.toLowerCase()}`;
+ }
+
+ this.elements.display[type] = progress;
+
+ return progress;
+ },
+
+ // Create time display
+ createTime(type) {
+ const container = utils.createElement('div', {
+ class: 'plyr__time',
+ });
+
+ container.appendChild(
+ utils.createElement(
+ 'span',
+ {
+ class: this.config.classNames.hidden,
+ },
+ this.config.i18n[type],
+ ),
+ );
+
+ container.appendChild(utils.createElement('span', utils.getAttributesFromSelector(this.config.selectors.display[type]), '00:00'));
+
+ this.elements.display[type] = container;
+
+ return container;
+ },
+
+ // Create a settings menu item
+ createMenuItem(value, list, type, title, badge = null, checked = false) {
+ const item = utils.createElement('li');
+
+ const label = utils.createElement('label', {
+ class: this.config.classNames.control,
+ });
+
+ const radio = utils.createElement(
+ 'input',
+ utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs[type]), {
+ type: 'radio',
+ name: `plyr-${type}`,
+ value,
+ checked,
+ class: 'plyr__sr-only',
+ }),
+ );
+
+ const faux = utils.createElement('span', { 'aria-hidden': true });
+
+ label.appendChild(radio);
+ label.appendChild(faux);
+ label.insertAdjacentHTML('beforeend', title);
+
+ if (utils.is.element(badge)) {
+ label.appendChild(badge);
+ }
+
+ item.appendChild(label);
+ list.appendChild(item);
+ },
+
+ // Update hover tooltip for seeking
+ updateSeekTooltip(event) {
+ // Bail if setting not true
+ if (
+ !this.config.tooltips.seek ||
+ !utils.is.element(this.elements.inputs.seek) ||
+ !utils.is.element(this.elements.display.seekTooltip) ||
+ this.duration === 0
+ ) {
+ return;
+ }
+
+ // Calculate percentage
+ let percent = 0;
+ const clientRect = this.elements.inputs.seek.getBoundingClientRect();
+ const visible = `${this.config.classNames.tooltip}--visible`;
+
+ // Determine percentage, if already visible
+ if (utils.is.event(event)) {
+ percent = 100 / clientRect.width * (event.pageX - clientRect.left);
+ } else if (utils.hasClass(this.elements.display.seekTooltip, visible)) {
+ 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
+ ui.updateTimeDisplay.call(this, this.elements.display.seekTooltip, this.duration / 100 * percent);
+
+ // Set position
+ this.elements.display.seekTooltip.style.left = `${percent}%`;
+
+ // Show/hide the tooltip
+ // If the event is a moues in/out and percentage is inside bounds
+ if (utils.is.event(event) && [
+ 'mouseenter',
+ 'mouseleave',
+ ].includes(event.type)) {
+ utils.toggleClass(this.elements.display.seekTooltip, visible, event.type === 'mouseenter');
+ }
+ },
+
+ // Hide/show a tab
+ toggleTab(setting, toggle) {
+ const tab = this.elements.settings.tabs[setting];
+ const pane = this.elements.settings.panes[setting];
+
+ utils.toggleHidden(tab, !toggle);
+ utils.toggleHidden(pane, !toggle);
+ },
+
+ // Set the YouTube quality menu
+ // TODO: Support for HTML5
+ setQualityMenu(options) {
+ // Menu required
+ if (!utils.is.element(this.elements.settings.panes.quality)) {
+ return;
+ }
+
+ const type = 'quality';
+ const list = this.elements.settings.panes.quality.querySelector('ul');
+
+ // Set options if passed and filter based on config
+ if (utils.is.array(options)) {
+ this.options.quality = options.filter(quality => this.config.quality.options.includes(quality));
+ } else {
+ this.options.quality = this.config.quality.options;
+ }
+
+ // Toggle the pane and tab
+ const toggle = !utils.is.empty(this.options.quality) && this.isYouTube;
+ controls.toggleTab.call(this, type, toggle);
+
+ // If we're hiding, nothing more to do
+ if (!toggle) {
+ return;
+ }
+
+ // Empty the menu
+ utils.emptyElement(list);
+
+ // Get the badge HTML for HD, 4K etc
+ const getBadge = quality => {
+ let label = '';
+
+ switch (quality) {
+ case 'hd2160':
+ label = '4K';
+ break;
+
+ case 'hd1440':
+ label = 'WQHD';
+ break;
+
+ case 'hd1080':
+ label = 'HD';
+ break;
+
+ case 'hd720':
+ label = 'HD';
+ break;
+
+ default:
+ break;
+ }
+
+ if (!label.length) {
+ return null;
+ }
+
+ return controls.createBadge.call(this, label);
+ };
+
+ this.options.quality.forEach(quality =>
+ controls.createMenuItem.call(this, quality, list, type, controls.getLabel.call(this, 'quality', quality), getBadge(quality)),
+ );
+
+ controls.updateSetting.call(this, type, list);
+ },
+
+ // Translate a value into a nice label
+ // TODO: Localisation
+ getLabel(setting, value) {
+ switch (setting) {
+ case 'speed':
+ return value === 1 ? 'Normal' : `${value}&times;`;
+
+ case 'quality':
+ switch (value) {
+ case 'hd2160':
+ return '2160P';
+ case 'hd1440':
+ return '1440P';
+ case 'hd1080':
+ return '1080P';
+ case 'hd720':
+ return '720P';
+ case 'large':
+ return '480P';
+ case 'medium':
+ return '360P';
+ case 'small':
+ return '240P';
+ case 'tiny':
+ return 'Tiny';
+ case 'default':
+ return 'Auto';
+ default:
+ return value;
+ }
+
+ case 'captions':
+ return controls.getLanguage.call(this);
+
+ default:
+ return null;
+ }
+ },
+
+ // Update the selected setting
+ updateSetting(setting, container) {
+ const pane = this.elements.settings.panes[setting];
+ let value = null;
+ let list = container;
+
+ switch (setting) {
+ case 'captions':
+ value = this.captions.active ? this.captions.language : '';
+ break;
+
+ default:
+ value = this[setting];
+
+ // Get default
+ if (utils.is.empty(value)) {
+ value = this.config[setting].default;
+ }
+
+ // Unsupported value
+ if (!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;
+ }
+
+ break;
+ }
+
+ // Get the list if we need to
+ if (!utils.is.element(list)) {
+ list = pane && pane.querySelector('ul');
+ }
+
+ // Update the label
+ if (!utils.is.empty(value)) {
+ const label = this.elements.settings.tabs[setting].querySelector(`.${this.config.classNames.menu.value}`);
+ label.innerHTML = controls.getLabel.call(this, setting, value);
+ }
+
+ // Find the radio option
+ const target = list && list.querySelector(`input[value="${value}"]`);
+
+ if (utils.is.element(target)) {
+ // Check it
+ target.checked = true;
+ }
+ },
+
+ // Set the looping options
+ /* setLoopMenu() {
+ // Menu required
+ if (!utils.is.element(this.elements.settings.panes.loop)) {
+ return;
+ }
+
+ const options = ['start', 'end', 'all', 'reset'];
+ const list = this.elements.settings.panes.loop.querySelector('ul');
+
+ // Show the pane and tab
+ utils.toggleHidden(this.elements.settings.tabs.loop, false);
+ utils.toggleHidden(this.elements.settings.panes.loop, false);
+
+ // Toggle the pane and tab
+ const toggle = !utils.is.empty(this.loop.options);
+ controls.toggleTab.call(this, 'loop', toggle);
+
+ // Empty the menu
+ utils.emptyElement(list);
+
+ options.forEach(option => {
+ const item = utils.createElement('li');
+
+ const button = utils.createElement(
+ 'button',
+ utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.loop), {
+ type: 'button',
+ class: this.config.classNames.control,
+ 'data-plyr-loop-action': option,
+ }),
+ this.config.i18n[option]
+ );
+
+ 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?
+ getLanguage() {
+ if (!this.supported.ui) {
+ return null;
+ }
+
+ if (!support.textTracks || !captions.getTracks.call(this).length) {
+ return this.config.i18n.none;
+ }
+
+ if (this.captions.active) {
+ const currentTrack = captions.getCurrentTrack.call(this);
+
+ if (utils.is.track(currentTrack)) {
+ return currentTrack.label;
+ }
+ }
+
+ return this.config.i18n.disabled;
+ },
+
+ // Set a list of available captions languages
+ setCaptionsMenu() {
+ // TODO: Captions or language? Currently it's mixed
+ const type = 'captions';
+ const list = this.elements.settings.panes.captions.querySelector('ul');
+
+ // Toggle the pane and tab
+ const hasTracks = captions.getTracks.call(this).length;
+ controls.toggleTab.call(this, type, hasTracks);
+
+ // Empty the menu
+ utils.emptyElement(list);
+
+ // If there's no captions, bail
+ if (!hasTracks) {
+ return;
+ }
+
+ // Re-map the tracks into just the data we need
+ const tracks = captions.getTracks.call(this).map(track => ({
+ language: track.language,
+ label: !utils.is.empty(track.label) ? track.label : track.language.toUpperCase(),
+ }));
+
+ // Add the "None" option to turn off captions
+ tracks.unshift({
+ language: '',
+ label: this.config.i18n.none,
+ });
+
+ // Generate options
+ tracks.forEach(track => {
+ controls.createMenuItem.call(
+ this,
+ track.language,
+ list,
+ 'language',
+ track.label || track.language,
+ controls.createBadge.call(this, track.language.toUpperCase()),
+ track.language.toLowerCase() === this.captions.language.toLowerCase(),
+ );
+ });
+
+ controls.updateSetting.call(this, type, list);
+ },
+
+ // Set a list of available captions languages
+ setSpeedMenu() {
+ // Menu required
+ if (!utils.is.element(this.elements.settings.panes.speed)) {
+ return;
+ }
+
+ const type = 'speed';
+
+ // Set the default speeds
+ if (!utils.is.object(this.options.speed) || !Object.keys(this.options.speed).length) {
+ this.options.speed = [
+ 0.5,
+ 0.75,
+ 1,
+ 1.25,
+ 1.5,
+ 1.75,
+ 2,
+ ];
+ }
+
+ // Set options if passed and filter based on config
+ this.options.speed = this.options.speed.filter(speed => this.config.speed.options.includes(speed));
+
+ // Toggle the pane and tab
+ const toggle = !utils.is.empty(this.options.speed);
+ controls.toggleTab.call(this, type, toggle);
+
+ // If we're hiding, nothing more to do
+ if (!toggle) {
+ return;
+ }
+
+ // Get the list to populate
+ const list = this.elements.settings.panes.speed.querySelector('ul');
+
+ // Show the pane and tab
+ utils.toggleHidden(this.elements.settings.tabs.speed, false);
+ utils.toggleHidden(this.elements.settings.panes.speed, false);
+
+ // Empty the menu
+ utils.emptyElement(list);
+
+ // Create items
+ this.options.speed.forEach(speed => controls.createMenuItem.call(this, speed, list, type, controls.getLabel.call(this, 'speed', speed)));
+
+ controls.updateSetting.call(this, type, list);
+ },
+
+ // Show/hide menu
+ toggleMenu(event) {
+ const { form } = this.elements.settings;
+ const button = this.elements.buttons.settings;
+
+ // Menu and button are required
+ if (!utils.is.element(form) || !utils.is.element(button)) {
+ return;
+ }
+
+ const show = utils.is.boolean(event) ? event : utils.is.element(form) && form.getAttribute('aria-hidden') === 'true';
+
+ if (utils.is.event(event)) {
+ const isMenuItem = utils.is.element(form) && form.contains(event.target);
+ const isButton = event.target === this.elements.buttons.settings;
+
+ // If the click was inside the form or if the click
+ // wasn't the button or menu item and we're trying to
+ // show the menu (a doc click shouldn't show the menu)
+ if (isMenuItem || (!isMenuItem && !isButton && show)) {
+ return;
+ }
+
+ // Prevent the toggle being caught by the doc listener
+ if (isButton) {
+ event.stopPropagation();
+ }
+ }
+
+ // Set form and button attributes
+ if (utils.is.element(button)) {
+ button.setAttribute('aria-expanded', show);
+ }
+
+ if (utils.is.element(form)) {
+ form.setAttribute('aria-hidden', !show);
+ utils.toggleClass(this.elements.container, this.config.classNames.menu.open, show);
+
+ if (show) {
+ form.removeAttribute('tabindex');
+ } else {
+ form.setAttribute('tabindex', -1);
+ }
+ }
+ },
+
+ // Get the natural size of a tab
+ getTabSize(tab) {
+ const clone = tab.cloneNode(true);
+ clone.style.position = 'absolute';
+ clone.style.opacity = 0;
+ clone.setAttribute('aria-hidden', false);
+
+ // Prevent input's being unchecked due to the name being identical
+ Array.from(clone.querySelectorAll('input[name]')).forEach(input => {
+ const name = input.getAttribute('name');
+ input.setAttribute('name', `${name}-clone`);
+ });
+
+ // Append to parent so we get the "real" size
+ tab.parentNode.appendChild(clone);
+
+ // Get the sizes before we remove
+ const width = clone.scrollWidth;
+ const height = clone.scrollHeight;
+
+ // Remove from the DOM
+ utils.removeElement(clone);
+
+ return {
+ width,
+ height,
+ };
+ },
+
+ // Toggle Menu
+ showTab(event) {
+ const { menu } = this.elements.settings;
+ const tab = event.target;
+ const show = tab.getAttribute('aria-expanded') === 'false';
+ const pane = document.getElementById(tab.getAttribute('aria-controls'));
+
+ // Nothing to show, bail
+ if (!utils.is.element(pane)) {
+ return;
+ }
+
+ // Are we targetting a tab? If not, bail
+ const isTab = pane.getAttribute('role') === 'tabpanel';
+ if (!isTab) {
+ return;
+ }
+
+ // Hide all other tabs
+ // Get other tabs
+ const current = menu.querySelector('[role="tabpanel"][aria-hidden="false"]');
+ const container = current.parentNode;
+
+ // Set other toggles to be expanded false
+ Array.from(menu.querySelectorAll(`[aria-controls="${current.getAttribute('id')}"]`)).forEach(toggle => {
+ toggle.setAttribute('aria-expanded', false);
+ });
+
+ // 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.getTabSize.call(this, pane);
+
+ // Restore auto height/width
+ const restore = e => {
+ // We're only bothered about height and width on the container
+ if (e.target !== container || ![
+ 'width',
+ 'height',
+ ].includes(e.propertyName)) {
+ return;
+ }
+
+ // Revert back to auto
+ container.style.width = '';
+ container.style.height = '';
+
+ // Only listen once
+ utils.off(container, utils.transitionEndEvent, restore);
+ };
+
+ // Listen for the transition finishing and restore auto height/width
+ utils.on(container, utils.transitionEndEvent, restore);
+
+ // Set dimensions to target
+ container.style.width = `${size.width}px`;
+ container.style.height = `${size.height}px`;
+ }
+
+ // Set attributes on current tab
+ current.setAttribute('aria-hidden', true);
+ current.setAttribute('tabindex', -1);
+
+ // Set attributes on target
+ pane.setAttribute('aria-hidden', !show);
+ tab.setAttribute('aria-expanded', show);
+ pane.removeAttribute('tabindex');
+
+ // Focus the first item
+ pane.querySelectorAll('button:not(:disabled), input:not(:disabled), [tabindex]')[0].focus();
+ },
+
+ // Build the default HTML
+ // TODO: Set order based on order in the config.controls array?
+ create(data) {
+ // Do nothing if we want no controls
+ if (utils.is.empty(this.config.controls)) {
+ return null;
+ }
+
+ // Create the container
+ const container = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.controls.wrapper));
+
+ // Restart button
+ if (this.config.controls.includes('restart')) {
+ container.appendChild(controls.createButton.call(this, 'restart'));
+ }
+
+ // Rewind button
+ if (this.config.controls.includes('rewind')) {
+ container.appendChild(controls.createButton.call(this, 'rewind'));
+ }
+
+ // Play/Pause button
+ if (this.config.controls.includes('play')) {
+ container.appendChild(controls.createButton.call(this, 'play'));
+ }
+
+ // Fast forward button
+ if (this.config.controls.includes('fast-forward')) {
+ container.appendChild(controls.createButton.call(this, 'fast-forward'));
+ }
+
+ // Progress
+ if (this.config.controls.includes('progress')) {
+ const progress = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.progress));
+
+ // Seek range slider
+ const seek = controls.createRange.call(this, 'seek', {
+ id: `plyr-seek-${data.id}`,
+ });
+ progress.appendChild(seek.label);
+ progress.appendChild(seek.input);
+
+ // Buffer progress
+ progress.appendChild(controls.createProgress.call(this, 'buffer'));
+
+ // TODO: Add loop display indicator
+
+ // Seek tooltip
+ if (this.config.tooltips.seek) {
+ const tooltip = utils.createElement(
+ 'span',
+ {
+ role: 'tooltip',
+ class: this.config.classNames.tooltip,
+ },
+ '00:00',
+ );
+
+ progress.appendChild(tooltip);
+ this.elements.display.seekTooltip = tooltip;
+ }
+
+ this.elements.progress = progress;
+ container.appendChild(this.elements.progress);
+ }
+
+ // Media current time display
+ if (this.config.controls.includes('current-time')) {
+ container.appendChild(controls.createTime.call(this, 'currentTime'));
+ }
+
+ // Media duration display
+ if (this.config.controls.includes('duration')) {
+ container.appendChild(controls.createTime.call(this, 'duration'));
+ }
+
+ // Toggle mute button
+ if (this.config.controls.includes('mute')) {
+ container.appendChild(controls.createButton.call(this, 'mute'));
+ }
+
+ // Volume range control
+ if (this.config.controls.includes('volume')) {
+ const volume = utils.createElement('div', {
+ class: 'plyr__volume',
+ });
+
+ // Set the attributes
+ const attributes = {
+ max: 1,
+ step: 0.05,
+ value: this.config.volume,
+ };
+
+ // Create the volume range slider
+ const range = controls.createRange.call(
+ this,
+ 'volume',
+ utils.extend(attributes, {
+ id: `plyr-volume-${data.id}`,
+ }),
+ );
+ volume.appendChild(range.label);
+ volume.appendChild(range.input);
+
+ this.elements.volume = volume;
+
+ container.appendChild(volume);
+ }
+
+ // Toggle captions button
+ if (this.config.controls.includes('captions')) {
+ container.appendChild(controls.createButton.call(this, 'captions'));
+ }
+
+ // Settings button / menu
+ if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) {
+ const menu = utils.createElement('div', {
+ class: 'plyr__menu',
+ });
+
+ menu.appendChild(
+ controls.createButton.call(this, 'settings', {
+ id: `plyr-settings-toggle-${data.id}`,
+ 'aria-haspopup': true,
+ 'aria-controls': `plyr-settings-${data.id}`,
+ 'aria-expanded': false,
+ }),
+ );
+
+ const form = utils.createElement('form', {
+ class: 'plyr__menu__container',
+ id: `plyr-settings-${data.id}`,
+ 'aria-hidden': true,
+ 'aria-labelled-by': `plyr-settings-toggle-${data.id}`,
+ role: 'tablist',
+ tabindex: -1,
+ });
+
+ const inner = utils.createElement('div');
+
+ const home = utils.createElement('div', {
+ id: `plyr-settings-${data.id}-home`,
+ 'aria-hidden': false,
+ 'aria-labelled-by': `plyr-settings-toggle-${data.id}`,
+ role: 'tabpanel',
+ });
+
+ // Create the tab list
+ const tabs = utils.createElement('ul', {
+ role: 'tablist',
+ });
+
+ // Build the tabs
+ this.config.settings.forEach(type => {
+ const tab = utils.createElement('li', {
+ role: 'tab',
+ hidden: '',
+ });
+
+ const button = utils.createElement(
+ 'button',
+ utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.settings), {
+ type: 'button',
+ class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
+ id: `plyr-settings-${data.id}-${type}-tab`,
+ 'aria-haspopup': true,
+ 'aria-controls': `plyr-settings-${data.id}-${type}`,
+ 'aria-expanded': false,
+ }),
+ this.config.i18n[type],
+ );
+
+ const value = utils.createElement('span', {
+ class: this.config.classNames.menu.value,
+ });
+
+ // Speed contains HTML entities
+ value.innerHTML = data[type];
+
+ button.appendChild(value);
+ tab.appendChild(button);
+ tabs.appendChild(tab);
+
+ this.elements.settings.tabs[type] = tab;
+ });
+
+ home.appendChild(tabs);
+ inner.appendChild(home);
+
+ // Build the panes
+ this.config.settings.forEach(type => {
+ const pane = utils.createElement('div', {
+ id: `plyr-settings-${data.id}-${type}`,
+ 'aria-hidden': true,
+ 'aria-labelled-by': `plyr-settings-${data.id}-${type}-tab`,
+ role: 'tabpanel',
+ tabindex: -1,
+ hidden: '',
+ });
+
+ const back = utils.createElement(
+ 'button',
+ {
+ type: 'button',
+ class: `${this.config.classNames.control} ${this.config.classNames.control}--back`,
+ 'aria-haspopup': true,
+ 'aria-controls': `plyr-settings-${data.id}-home`,
+ 'aria-expanded': false,
+ },
+ this.config.i18n[type],
+ );
+
+ pane.appendChild(back);
+
+ const options = utils.createElement('ul');
+
+ pane.appendChild(options);
+ inner.appendChild(pane);
+
+ this.elements.settings.panes[type] = pane;
+ });
+
+ form.appendChild(inner);
+ menu.appendChild(form);
+ container.appendChild(menu);
+
+ this.elements.settings.form = form;
+ this.elements.settings.menu = menu;
+ }
+
+ // Picture in picture button
+ if (this.config.controls.includes('pip') && support.pip) {
+ container.appendChild(controls.createButton.call(this, 'pip'));
+ }
+
+ // Airplay button
+ if (this.config.controls.includes('airplay') && support.airplay) {
+ container.appendChild(controls.createButton.call(this, 'airplay'));
+ }
+
+ // Toggle fullscreen button
+ if (this.config.controls.includes('fullscreen')) {
+ container.appendChild(controls.createButton.call(this, 'fullscreen'));
+ }
+
+ // Larger overlaid play button
+ if (this.config.controls.includes('play-large')) {
+ this.elements.container.appendChild(controls.createButton.call(this, 'play-large'));
+ }
+
+ this.elements.controls = container;
+
+ if (this.config.controls.includes('settings') && this.config.settings.includes('speed')) {
+ controls.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.absolute) {
+ utils.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;
+
+ // HTML or Element passed as the option
+ if (utils.is.string(this.config.controls) || utils.is.element(this.config.controls)) {
+ container = this.config.controls;
+ } else if (utils.is.function(this.config.controls)) {
+ // A custom function to build controls
+ // The function can return a HTMLElement or String
+ container = this.config.controls({
+ id: this.id,
+ seektime: this.config.seekTime,
+ title: this.config.title,
+ });
+ } else {
+ // Create controls
+ container = controls.create.call(this, {
+ id: this.id,
+ seektime: this.config.seekTime,
+ speed: this.speed,
+ quality: this.quality,
+ captions: controls.getLanguage.call(this),
+ // TODO: Looping
+ // loop: 'None',
+ });
+ }
+
+ // Controls container
+ let target;
+
+ // Inject to custom location
+ if (utils.is.string(this.config.selectors.controls.container)) {
+ target = document.querySelector(this.config.selectors.controls.container);
+ }
+
+ // Inject into the container by default
+ if (!utils.is.element(target)) {
+ target = this.elements.container;
+ }
+
+ // Inject controls HTML
+ if (utils.is.element(container)) {
+ target.appendChild(container);
+ } else {
+ target.insertAdjacentHTML('beforeend', container);
+ }
+
+ // Find the elements if need be
+ if (!utils.is.element(this.elements.controls)) {
+ utils.findElements.call(this);
+ }
+
+ // Edge sometimes doesn't finish the paint so force a redraw
+ if (window.navigator.userAgent.includes('Edge')) {
+ utils.repaint(target);
+ }
+
+ // Setup tooltips
+ if (this.config.tooltips.controls) {
+ const labels = utils.getElements.call(
+ this,
+ [
+ this.config.selectors.controls.wrapper,
+ ' ',
+ this.config.selectors.labels,
+ ' .',
+ this.config.classNames.hidden,
+ ].join(''),
+ );
+
+ Array.from(labels).forEach(label => {
+ utils.toggleClass(label, this.config.classNames.hidden, false);
+ utils.toggleClass(label, this.config.classNames.tooltip, true);
+ label.setAttribute('role', 'tooltip');
+ });
+ }
+ },
+};
+
+export default controls;
diff --git a/src/js/defaults.js b/src/js/defaults.js
new file mode 100644
index 00000000..a7d017ef
--- /dev/null
+++ b/src/js/defaults.js
@@ -0,0 +1,383 @@
+// ==========================================================================
+// Plyr default config
+// ==========================================================================
+
+const defaults = {
+ // Disable
+ enabled: true,
+
+ // Custom media title
+ title: '',
+
+ // Logging to console
+ debug: false,
+
+ // Auto play (if supported)
+ autoplay: false,
+
+ // Only allow one media playing at once (vimeo only)
+ autopause: true,
+
+ // Default time to skip when rewind/fast forward
+ seekTime: 10,
+
+ // Default volume
+ volume: 1,
+ muted: false,
+
+ // Pass a custom duration
+ duration: null,
+
+ // Display the media duration on load in the current time position
+ // If you have opted to display both duration and currentTime, this is ignored
+ displayDuration: true,
+
+ // Invert the current time to be a countdown
+ invertTime: true,
+
+ // Clicking the currentTime inverts it's value to show time left rather than elapsed
+ toggleInvert: true,
+
+ // Aspect ratio (for embeds)
+ ratio: '16:9',
+
+ // Click video container to play/pause
+ clickToPlay: true,
+
+ // Auto hide the controls
+ hideControls: true,
+
+ // Revert to poster on finish (HTML5 - will cause reload)
+ showPosterOnEnd: false,
+
+ // Disable the standard context menu
+ disableContextMenu: true,
+
+ // Sprite (for icons)
+ loadSprite: true,
+ iconPrefix: 'plyr',
+ iconUrl: 'https://cdn.plyr.io/3.0.0-beta.20/plyr.svg',
+
+ // Blank video (used to prevent errors on source change)
+ blankVideo: 'https://cdn.plyr.io/static/blank.mp4',
+
+ // Quality default
+ quality: {
+ default: 'default',
+ options: [
+ 'hd2160',
+ 'hd1440',
+ 'hd1080',
+ 'hd720',
+ 'large',
+ 'medium',
+ 'small',
+ 'tiny',
+ 'default',
+ ],
+ },
+
+ // Set loops
+ loop: {
+ active: false,
+ // start: null,
+ // end: null,
+ },
+
+ // Speed default and options to display
+ speed: {
+ selected: 1,
+ options: [
+ 0.5,
+ 0.75,
+ 1,
+ 1.25,
+ 1.5,
+ 1.75,
+ 2,
+ ],
+ },
+
+ // Keyboard shortcut settings
+ keyboard: {
+ focused: true,
+ global: false,
+ },
+
+ // Display tooltips
+ tooltips: {
+ controls: false,
+ seek: true,
+ },
+
+ // Captions settings
+ captions: {
+ active: false,
+ language: window.navigator.language.split('-')[0],
+ },
+
+ // Fullscreen settings
+ fullscreen: {
+ enabled: true, // Allow fullscreen?
+ fallback: true, // Fallback for vintage browsers
+ iosNative: false, // Use the native fullscreen in iOS (disables custom controls)
+ },
+
+ // Local storage
+ storage: {
+ enabled: true,
+ key: 'plyr',
+ },
+
+ // Default controls
+ controls: [
+ 'play-large',
+ 'play',
+ 'progress',
+ 'current-time',
+ 'mute',
+ 'volume',
+ 'captions',
+ 'settings',
+ 'pip',
+ 'airplay',
+ 'fullscreen',
+ ],
+ settings: [
+ 'captions',
+ 'quality',
+ 'speed',
+ ],
+
+ // Localisation
+ i18n: {
+ restart: 'Restart',
+ rewind: 'Rewind {seektime} secs',
+ play: 'Play',
+ pause: 'Pause',
+ forward: 'Forward {seektime} secs',
+ seek: 'Seek',
+ played: 'Played',
+ buffered: 'Buffered',
+ currentTime: 'Current time',
+ duration: 'Duration',
+ volume: 'Volume',
+ mute: 'Mute',
+ unmute: 'Unmute',
+ enableCaptions: 'Enable captions',
+ disableCaptions: 'Disable captions',
+ enterFullscreen: 'Enter fullscreen',
+ exitFullscreen: 'Exit fullscreen',
+ frameTitle: 'Player for {title}',
+ captions: 'Captions',
+ settings: 'Settings',
+ speed: 'Speed',
+ quality: 'Quality',
+ loop: 'Loop',
+ start: 'Start',
+ end: 'End',
+ all: 'All',
+ reset: 'Reset',
+ none: 'None',
+ disabled: 'Disabled',
+ advertisement: 'Ad',
+ },
+
+ // URLs
+ urls: {
+ vimeo: {
+ api: 'https://player.vimeo.com/api/player.js',
+ },
+ youtube: {
+ api: 'https://www.youtube.com/iframe_api',
+ },
+ googleIMA: {
+ api: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js',
+ },
+ },
+
+ // Custom control listeners
+ listeners: {
+ seek: null,
+ play: null,
+ pause: null,
+ restart: null,
+ rewind: null,
+ forward: null,
+ mute: null,
+ volume: null,
+ captions: null,
+ fullscreen: null,
+ pip: null,
+ airplay: null,
+ speed: null,
+ quality: null,
+ loop: null,
+ language: null,
+ },
+
+ // Events to watch and bubble
+ events: [
+ // Events to watch on HTML5 media elements and bubble
+ // https://developer.mozilla.org/en/docs/Web/Guide/Events/Media_events
+ 'ended',
+ 'progress',
+ 'stalled',
+ 'playing',
+ 'waiting',
+ 'canplay',
+ 'canplaythrough',
+ 'loadstart',
+ 'loadeddata',
+ 'loadedmetadata',
+ 'timeupdate',
+ 'volumechange',
+ 'play',
+ 'pause',
+ 'error',
+ 'seeking',
+ 'seeked',
+ 'emptied',
+ 'ratechange',
+ 'cuechange',
+
+ // Custom events
+ 'enterfullscreen',
+ 'exitfullscreen',
+ 'captionsenabled',
+ 'captionsdisabled',
+ 'languagechange',
+ 'controlshidden',
+ 'controlsshown',
+ 'ready',
+
+ // YouTube
+ 'statechange',
+ 'qualitychange',
+ 'qualityrequested',
+
+ // Ads
+ 'adsloaded',
+ 'adscontentpause',
+ 'adscontentresume',
+ 'adstarted',
+ 'adsmidpoint',
+ 'adscomplete',
+ 'adsallcomplete',
+ 'adsimpression',
+ 'adsclick',
+ ],
+
+ // Selectors
+ // Change these to match your template if using custom HTML
+ selectors: {
+ editable: 'input, textarea, select, [contenteditable]',
+ container: '.plyr',
+ controls: {
+ container: null,
+ wrapper: '.plyr__controls',
+ },
+ labels: '[data-plyr]',
+ buttons: {
+ play: '[data-plyr="play"]',
+ pause: '[data-plyr="pause"]',
+ restart: '[data-plyr="restart"]',
+ rewind: '[data-plyr="rewind"]',
+ forward: '[data-plyr="fast-forward"]',
+ mute: '[data-plyr="mute"]',
+ captions: '[data-plyr="captions"]',
+ fullscreen: '[data-plyr="fullscreen"]',
+ pip: '[data-plyr="pip"]',
+ airplay: '[data-plyr="airplay"]',
+ settings: '[data-plyr="settings"]',
+ loop: '[data-plyr="loop"]',
+ },
+ inputs: {
+ seek: '[data-plyr="seek"]',
+ volume: '[data-plyr="volume"]',
+ speed: '[data-plyr="speed"]',
+ language: '[data-plyr="language"]',
+ quality: '[data-plyr="quality"]',
+ },
+ display: {
+ currentTime: '.plyr__time--current',
+ duration: '.plyr__time--duration',
+ buffer: '.plyr__progress--buffer',
+ played: '.plyr__progress--played',
+ loop: '.plyr__progress--loop',
+ volume: '.plyr__volume--display',
+ },
+ progress: '.plyr__progress',
+ captions: '.plyr__captions',
+ menu: {
+ quality: '.js-plyr__menu__list--quality',
+ },
+ },
+
+ // Class hooks added to the player in different states
+ classNames: {
+ video: 'plyr__video-wrapper',
+ embed: 'plyr__video-embed',
+ ads: 'plyr__ads',
+ control: 'plyr__control',
+ type: 'plyr--{0}',
+ provider: 'plyr--{0}',
+ stopped: 'plyr--stopped',
+ playing: 'plyr--playing',
+ loading: 'plyr--loading',
+ error: 'plyr--has-error',
+ hover: 'plyr--hover',
+ tooltip: 'plyr__tooltip',
+ cues: 'plyr__cues',
+ hidden: 'plyr__sr-only',
+ hideControls: 'plyr--hide-controls',
+ isIos: 'plyr--is-ios',
+ isTouch: 'plyr--is-touch',
+ uiSupported: 'plyr--full-ui',
+ noTransition: 'plyr--no-transition',
+ menu: {
+ value: 'plyr__menu__value',
+ badge: 'plyr__badge',
+ open: 'plyr--menu-open',
+ },
+ captions: {
+ enabled: 'plyr--captions-enabled',
+ active: 'plyr--captions-active',
+ },
+ fullscreen: {
+ enabled: 'plyr--fullscreen-enabled',
+ fallback: 'plyr--fullscreen-fallback',
+ },
+ pip: {
+ supported: 'plyr--pip-supported',
+ active: 'plyr--pip-active',
+ },
+ airplay: {
+ supported: 'plyr--airplay-supported',
+ active: 'plyr--airplay-active',
+ },
+ tabFocus: 'plyr__tab-focus',
+ },
+
+ // Embed attributes
+ attributes: {
+ embed: {
+ provider: 'data-plyr-provider',
+ id: 'data-plyr-embed-id',
+ },
+ },
+
+ // API keys
+ keys: {
+ google: null,
+ },
+
+ // Advertisements plugin
+ // Register for an account here: http://vi.ai/publisher-video-monetization/?aid=plyrio
+ ads: {
+ enabled: false,
+ publisherId: '',
+ },
+};
+
+export default defaults;
diff --git a/src/js/fullscreen.js b/src/js/fullscreen.js
new file mode 100644
index 00000000..6d90bd6e
--- /dev/null
+++ b/src/js/fullscreen.js
@@ -0,0 +1,204 @@
+// ==========================================================================
+// Fullscreen wrapper
+// ==========================================================================
+
+import utils from './utils';
+
+const browser = utils.getBrowser();
+
+function onChange() {
+ if (!this.enabled) {
+ return;
+ }
+
+ // Update toggle button
+ const button = this.player.elements.buttons.fullscreen;
+ if (utils.is.element(button)) {
+ utils.toggleState(button, this.active);
+ }
+
+ // Trigger an event
+ utils.dispatchEvent(this.target, this.active ? 'enterfullscreen' : 'exitfullscreen', true);
+
+ // Trap focus in container
+ if (!browser.isIos) {
+ utils.trapFocus.call(this.player, this.target, this.active);
+ }
+}
+
+function toggleFallback(toggle = false) {
+ // Store or restore scroll position
+ if (toggle) {
+ this.scrollPosition = {
+ x: window.scrollX || 0,
+ y: window.scrollY || 0,
+ };
+ } else {
+ window.scrollTo(this.scrollPosition.x, this.scrollPosition.y);
+ }
+
+ // Toggle scroll
+ document.body.style.overflow = toggle ? 'hidden' : '';
+
+ // Toggle class hook
+ utils.toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle);
+
+ // Toggle button and fire events
+ onChange.call(this);
+}
+
+class Fullscreen {
+ constructor(player) {
+ // Keep reference to parent
+ this.player = player;
+
+ // Get prefix
+ this.prefix = Fullscreen.prefix;
+
+ // Scroll position
+ this.scrollPosition = { x: 0, y: 0 };
+
+ // Register event listeners
+ // Handle event (incase user presses escape etc)
+ utils.on(document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => {
+ // TODO: Filter for target??
+ onChange.call(this);
+ });
+
+ // Fullscreen toggle on double click
+ utils.on(this.player.elements.container, 'dblclick', () => {
+ this.toggle();
+ });
+
+ // Prevent double click on controls bubbling up
+ utils.on(this.player.elements.controls, 'dblclick', event => event.stopPropagation());
+
+ // Update the UI
+ this.update();
+ }
+
+ // Determine if native supported
+ static get native() {
+ return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled);
+ }
+
+ // Get the prefix for handlers
+ static get prefix() {
+ // No prefix
+ if (utils.is.function(document.cancelFullScreen)) {
+ return false;
+ }
+
+ // Check for fullscreen support by vendor prefix
+ let value = '';
+ const prefixes = [
+ 'webkit',
+ 'moz',
+ 'ms',
+ ];
+
+ prefixes.some(pre => {
+ if (utils.is.function(document[`${pre}CancelFullScreen`])) {
+ value = pre;
+ return true;
+ } else if (utils.is.function(document.msExitFullscreen)) {
+ value = 'ms';
+ return true;
+ }
+
+ return false;
+ });
+
+ return value;
+ }
+
+ // Determine if fullscreen is enabled
+ get enabled() {
+ const fallback = this.player.config.fullscreen.fallback && !utils.inFrame();
+
+ return (Fullscreen.native || fallback) && this.player.config.fullscreen.enabled && this.player.supported.ui && this.player.isVideo;
+ }
+
+ // Get active state
+ get active() {
+ if (!this.enabled) {
+ return false;
+ }
+
+ // Fallback using classname
+ if (!Fullscreen.native) {
+ return utils.hasClass(this.target, this.player.config.classNames.fullscreen.fallback);
+ }
+
+ const element = !this.prefix ? document.fullscreenElement : document[`${this.prefix}FullscreenElement`];
+
+ return element === this.target;
+ }
+
+ // Get target element
+ get target() {
+ return browser.isIos && this.player.config.fullscreen.iosNative ? this.player.media : this.player.elements.container;
+ }
+
+ // Update UI
+ update() {
+ if (this.enabled) {
+ this.player.debug.log(`${Fullscreen.native ? 'Native' : 'Fallback'} fullscreen enabled`);
+ } else {
+ this.player.debug.log('Fullscreen not supported and fallback disabled');
+ }
+
+ // Add styling hook to show button
+ utils.toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.enabled);
+ }
+
+ // Make an element fullscreen
+ enter() {
+ if (!this.enabled) {
+ return;
+ }
+
+ // iOS native fullscreen doesn't need the request step
+ if (browser.isIos && this.player.config.fullscreen.iosNative) {
+ if (this.player.playing) {
+ this.target.webkitEnterFullscreen();
+ }
+ } else if (!Fullscreen.native) {
+ toggleFallback.call(this, true);
+ } else if (!this.prefix) {
+ this.target.requestFullScreen();
+ } else if (!utils.is.empty(this.prefix)) {
+ this.target[`${this.prefix}${this.prefix === 'ms' ? 'RequestFullscreen' : 'RequestFullScreen'}`]();
+ }
+ }
+
+ // Bail from fullscreen
+ exit() {
+ if (!this.enabled) {
+ return;
+ }
+
+ // iOS native fullscreen
+ if (browser.isIos && this.player.config.fullscreen.iosNative) {
+ this.target.webkitExitFullscreen();
+ this.player.play();
+ } else if (!Fullscreen.native) {
+ toggleFallback.call(this, false);
+ } else if (!this.prefix) {
+ document.cancelFullScreen();
+ } else if (!utils.is.empty(this.prefix)) {
+ document[`${this.prefix}${this.prefix === 'ms' ? 'ExitFullscreen' : 'CancelFullScreen'}`]();
+ }
+ }
+
+ // Toggle state
+ toggle() {
+ if (!this.active) {
+ this.enter();
+ } else {
+ this.exit();
+ }
+ }
+}
+
+export default Fullscreen;
diff --git a/src/js/listeners.js b/src/js/listeners.js
new file mode 100644
index 00000000..10011d14
--- /dev/null
+++ b/src/js/listeners.js
@@ -0,0 +1,595 @@
+// ==========================================================================
+// Plyr Event Listeners
+// ==========================================================================
+
+import support from './support';
+import utils from './utils';
+import controls from './controls';
+import ui from './ui';
+
+// Sniff out the browser
+const browser = utils.getBrowser();
+
+class Listeners {
+ constructor(player) {
+ this.player = player;
+ this.lastKey = null;
+
+ this.handleKey = this.handleKey.bind(this);
+ this.toggleMenu = this.toggleMenu.bind(this);
+ }
+
+ // Handle key presses
+ handleKey(event) {
+ const code = event.keyCode ? event.keyCode : event.which;
+ const pressed = event.type === 'keydown';
+ const repeat = pressed && code === this.lastKey;
+
+ // Bail if a modifier key is set
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+
+ // If the event is bubbled from the media element
+ // Firefox doesn't get the keycode for whatever reason
+ if (!utils.is.number(code)) {
+ return;
+ }
+
+ // Seek by the number keys
+ const seekByKey = () => {
+ // Divide the max duration into 10th's and times by the number value
+ this.player.currentTime = this.player.duration / 10 * (code - 48);
+ };
+
+ // Handle the key on keydown
+ // Reset on keyup
+ if (pressed) {
+ // Which keycodes should we prevent default
+ const preventDefault = [
+ 48,
+ 49,
+ 50,
+ 51,
+ 52,
+ 53,
+ 54,
+ 56,
+ 57,
+ 32,
+ 75,
+ 38,
+ 40,
+ 77,
+ 39,
+ 37,
+ 70,
+ 67,
+ 73,
+ 76,
+ 79,
+ ];
+
+ // Check focused element
+ // and if the focused element is not editable (e.g. text input)
+ // and any that accept key input http://webaim.org/techniques/keyboard/
+ const focused = utils.getFocusElement();
+ if (utils.is.element(focused) && utils.matches(focused, this.player.config.selectors.editable)) {
+ return;
+ }
+
+ // If the code is found prevent default (e.g. prevent scrolling for arrows)
+ if (preventDefault.includes(code)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ switch (code) {
+ case 48:
+ case 49:
+ case 50:
+ case 51:
+ case 52:
+ case 53:
+ case 54:
+ case 55:
+ case 56:
+ case 57:
+ // 0-9
+ if (!repeat) {
+ seekByKey();
+ }
+ break;
+
+ case 32:
+ case 75:
+ // Space and K key
+ if (!repeat) {
+ this.player.togglePlay();
+ }
+ break;
+
+ case 38:
+ // Arrow up
+ this.player.increaseVolume(0.1);
+ break;
+
+ case 40:
+ // Arrow down
+ this.player.decreaseVolume(0.1);
+ break;
+
+ case 77:
+ // M key
+ if (!repeat) {
+ this.player.muted = !this.player.muted;
+ }
+ break;
+
+ case 39:
+ // Arrow forward
+ this.player.forward();
+ break;
+
+ case 37:
+ // Arrow back
+ this.player.rewind();
+ break;
+
+ case 70:
+ // F key
+ this.player.fullscreen.toggle();
+ break;
+
+ case 67:
+ // C key
+ if (!repeat) {
+ this.player.toggleCaptions();
+ }
+ break;
+
+ case 76:
+ // L key
+ this.player.loop = !this.player.loop;
+ break;
+
+ /* case 73:
+ this.setLoop('start');
+ break;
+
+ case 76:
+ this.setLoop();
+ break;
+
+ case 79:
+ this.setLoop('end');
+ break; */
+
+ default:
+ break;
+ }
+
+ // Escape is handle natively when in full screen
+ // So we only need to worry about non native
+ if (!this.player.fullscreen.enabled && this.player.fullscreen.active && code === 27) {
+ this.player.fullscreen.toggle();
+ }
+
+ // Store last code for next cycle
+ this.lastKey = code;
+ } else {
+ this.lastKey = null;
+ }
+ }
+
+ // Toggle menu
+ toggleMenu(event) {
+ controls.toggleMenu.call(this.player, event);
+ }
+
+ // Global window & document listeners
+ global(toggle = true) {
+ // Keyboard shortcuts
+ if (this.player.config.keyboard.global) {
+ utils.toggleListener(window, 'keydown keyup', this.handleKey, toggle, false);
+ }
+
+ // Click anywhere closes menu
+ utils.toggleListener(document.body, 'click', this.toggleMenu, toggle);
+ }
+
+ // Container listeners
+ container() {
+ // Keyboard shortcuts
+ if (!this.player.config.keyboard.global && this.player.config.keyboard.focused) {
+ utils.on(this.player.elements.container, 'keydown keyup', this.handleKey, false);
+ }
+
+ // Detect tab focus
+ // Remove class on blur/focusout
+ utils.on(this.player.elements.container, 'focusout', event => {
+ utils.toggleClass(event.target, this.player.config.classNames.tabFocus, false);
+ });
+
+ // Add classname to tabbed elements
+ utils.on(this.player.elements.container, 'keydown', event => {
+ if (event.keyCode !== 9) {
+ return;
+ }
+
+ // Delay the adding of classname until the focus has changed
+ // This event fires before the focusin event
+ setTimeout(() => {
+ utils.toggleClass(utils.getFocusElement(), this.player.config.classNames.tabFocus, true);
+ }, 0);
+ });
+
+ // Toggle controls visibility based on mouse movement
+ if (this.player.config.hideControls) {
+ // Toggle controls on mouse events and entering fullscreen
+ utils.on(this.player.elements.container, 'mouseenter mouseleave mousemove touchstart touchend touchmove enterfullscreen exitfullscreen', event => {
+ this.player.toggleControls(event);
+ });
+ }
+ }
+
+ // Listen for media events
+ media() {
+ // Time change on media
+ utils.on(this.player.media, 'timeupdate seeking', event => ui.timeUpdate.call(this.player, event));
+
+ // Display duration
+ utils.on(this.player.media, 'durationchange loadedmetadata', event => ui.durationUpdate.call(this.player, event));
+
+ // Check for audio tracks on load
+ // We can't use `loadedmetadata` as it doesn't seem to have audio tracks at that point
+ utils.on(this.player.media, 'loadeddata', () => {
+ utils.toggleHidden(this.player.elements.volume, !this.player.hasAudio);
+ utils.toggleHidden(this.player.elements.buttons.mute, !this.player.hasAudio);
+ });
+
+ // Handle the media finishing
+ utils.on(this.player.media, 'ended', () => {
+ // Show poster on end
+ if (this.player.isHTML5 && this.player.isVideo && this.player.config.showPosterOnEnd) {
+ // Restart
+ this.player.restart();
+
+ // Re-load media
+ this.player.media.load();
+ }
+ });
+
+ // Check for buffer progress
+ utils.on(this.player.media, 'progress playing', event => ui.updateProgress.call(this.player, event));
+
+ // Handle native mute
+ utils.on(this.player.media, 'volumechange', event => ui.updateVolume.call(this.player, event));
+
+ // Handle native play/pause
+ utils.on(this.player.media, 'playing play pause ended', event => ui.checkPlaying.call(this.player, event));
+
+ // Loading
+ utils.on(this.player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(this.player, event));
+
+ // Check if media failed to load
+ // utils.on(this.player.media, 'play', event => ui.checkFailed.call(this.player, event));
+
+ // Click video
+ if (this.player.supported.ui && this.player.config.clickToPlay && !this.player.isAudio) {
+ // Re-fetch the wrapper
+ const wrapper = utils.getElement.call(this.player, `.${this.player.config.classNames.video}`);
+
+ // Bail if there's no wrapper (this should never happen)
+ if (!utils.is.element(wrapper)) {
+ return;
+ }
+
+ // On click play, pause ore restart
+ utils.on(wrapper, 'click', () => {
+ // Touch devices will just show controls (if we're hiding controls)
+ if (this.player.config.hideControls && support.touch && !this.player.paused) {
+ return;
+ }
+
+ if (this.player.paused) {
+ this.player.play();
+ } else if (this.player.ended) {
+ this.player.restart();
+ this.player.play();
+ } else {
+ this.player.pause();
+ }
+ });
+ }
+
+ // Disable right click
+ if (this.player.supported.ui && this.player.config.disableContextMenu) {
+ utils.on(
+ this.player.media,
+ 'contextmenu',
+ event => {
+ event.preventDefault();
+ },
+ false,
+ );
+ }
+
+ // Volume change
+ utils.on(this.player.media, 'volumechange', () => {
+ // Save to storage
+ this.player.storage.set({ volume: this.player.volume, muted: this.player.muted });
+ });
+
+ // Speed change
+ utils.on(this.player.media, 'ratechange', () => {
+ // Update UI
+ controls.updateSetting.call(this.player, 'speed');
+
+ // Save to storage
+ this.player.storage.set({ speed: this.player.speed });
+ });
+
+ // Quality change
+ utils.on(this.player.media, 'qualitychange', () => {
+ // Update UI
+ controls.updateSetting.call(this.player, 'quality');
+
+ // Save to storage
+ this.player.storage.set({ quality: this.player.quality });
+ });
+
+ // Caption language change
+ utils.on(this.player.media, 'languagechange', () => {
+ // Update UI
+ controls.updateSetting.call(this.player, 'captions');
+
+ // Save to storage
+ this.player.storage.set({ language: this.player.language });
+ });
+
+ // Captions toggle
+ utils.on(this.player.media, 'captionsenabled captionsdisabled', () => {
+ // Update UI
+ controls.updateSetting.call(this.player, 'captions');
+
+ // Save to storage
+ this.player.storage.set({ captions: this.player.captions.active });
+ });
+
+ // Proxy events to container
+ // Bubble up key events for Edge
+ utils.on(this.player.media, this.player.config.events.concat([
+ 'keyup',
+ 'keydown',
+ ]).join(' '), event => {
+ let detail = {};
+
+ // Get error details from media
+ if (event.type === 'error') {
+ detail = this.player.media.error;
+ }
+
+ utils.dispatchEvent.call(this.player, this.player.elements.container, event.type, true, detail);
+ });
+ }
+
+ // Listen for control events
+ controls() {
+ // IE doesn't support input event, so we fallback to change
+ const inputEvent = browser.isIE ? 'change' : 'input';
+
+ // Trigger custom and default handlers
+ const proxy = (event, handlerKey, defaultHandler) => {
+ const customHandler = this.player.config.listeners[handlerKey];
+
+ // Execute custom handler
+ if (utils.is.function(customHandler)) {
+ customHandler.call(this.player, event);
+ }
+
+ // Only call default handler if not prevented in custom handler
+ if (!event.defaultPrevented && utils.is.function(defaultHandler)) {
+ defaultHandler.call(this.player, event);
+ }
+ };
+
+ // Play/pause toggle
+ utils.on(this.player.elements.buttons.play, 'click', event =>
+ proxy(event, 'play', () => {
+ this.player.togglePlay();
+ }),
+ );
+
+ // Pause
+ utils.on(this.player.elements.buttons.restart, 'click', event =>
+ proxy(event, 'restart', () => {
+ this.player.restart();
+ }),
+ );
+
+ // Rewind
+ utils.on(this.player.elements.buttons.rewind, 'click', event =>
+ proxy(event, 'rewind', () => {
+ this.player.rewind();
+ }),
+ );
+
+ // Rewind
+ utils.on(this.player.elements.buttons.forward, 'click', event =>
+ proxy(event, 'forward', () => {
+ this.player.forward();
+ }),
+ );
+
+ // Mute toggle
+ utils.on(this.player.elements.buttons.mute, 'click', event =>
+ proxy(event, 'mute', () => {
+ this.player.muted = !this.player.muted;
+ }),
+ );
+
+ // Captions toggle
+ utils.on(this.player.elements.buttons.captions, 'click', event =>
+ proxy(event, 'captions', () => {
+ this.player.toggleCaptions();
+ }),
+ );
+
+ // Fullscreen toggle
+ utils.on(this.player.elements.buttons.fullscreen, 'click', event =>
+ proxy(event, 'fullscreen', () => {
+ this.player.fullscreen.toggle();
+ }),
+ );
+
+ // Picture-in-Picture
+ utils.on(this.player.elements.buttons.pip, 'click', event =>
+ proxy(event, 'pip', () => {
+ this.player.pip = 'toggle';
+ }),
+ );
+
+ // Airplay
+ utils.on(this.player.elements.buttons.airplay, 'click', event =>
+ proxy(event, 'airplay', () => {
+ this.player.airplay();
+ }),
+ );
+
+ // Settings menu
+ utils.on(this.player.elements.buttons.settings, 'click', event => {
+ controls.toggleMenu.call(this.player, event);
+ });
+
+ // Settings menu
+ utils.on(this.player.elements.settings.form, 'click', event => {
+ event.stopPropagation();
+
+ // Settings menu items - use event delegation as items are added/removed
+ if (utils.matches(event.target, this.player.config.selectors.inputs.language)) {
+ proxy(event, 'language', () => {
+ this.player.language = event.target.value;
+ });
+ } else if (utils.matches(event.target, this.player.config.selectors.inputs.quality)) {
+ proxy(event, 'quality', () => {
+ this.player.quality = event.target.value;
+ });
+ } else if (utils.matches(event.target, this.player.config.selectors.inputs.speed)) {
+ proxy(event, 'speed', () => {
+ this.player.speed = parseFloat(event.target.value);
+ });
+ } else {
+ controls.showTab.call(this.player, event);
+ }
+ });
+
+ // Seek
+ utils.on(this.player.elements.inputs.seek, inputEvent, event =>
+ proxy(event, 'seek', () => {
+ this.player.currentTime = event.target.value / event.target.max * this.player.duration;
+ }),
+ );
+
+ // Current time invert
+ // Only if one time element is used for both currentTime and duration
+ if (this.player.config.toggleInvert && !utils.is.element(this.player.elements.display.duration)) {
+ utils.on(this.player.elements.display.currentTime, 'click', () => {
+ // Do nothing if we're at the start
+ if (this.player.currentTime === 0) {
+ return;
+ }
+
+ this.player.config.invertTime = !this.player.config.invertTime;
+ ui.timeUpdate.call(this.player);
+ });
+ }
+
+ // Volume
+ utils.on(this.player.elements.inputs.volume, inputEvent, event =>
+ proxy(event, 'volume', () => {
+ this.player.volume = event.target.value;
+ }),
+ );
+
+ // Polyfill for lower fill in <input type="range"> for webkit
+ if (browser.isWebkit) {
+ utils.on(utils.getElements.call(this.player, 'input[type="range"]'), 'input', event => {
+ controls.updateRangeFill.call(this.player, event.target);
+ });
+ }
+
+ // Seek tooltip
+ utils.on(this.player.elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(this.player, event));
+
+ // Toggle controls visibility based on mouse movement
+ if (this.player.config.hideControls) {
+ // Watch for cursor over controls so they don't hide when trying to interact
+ utils.on(this.player.elements.controls, 'mouseenter mouseleave', event => {
+ this.player.elements.controls.hover = event.type === 'mouseenter';
+ });
+
+ // Watch for cursor over controls so they don't hide when trying to interact
+ utils.on(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
+ this.player.elements.controls.pressed = [
+ 'mousedown',
+ 'touchstart',
+ ].includes(event.type);
+ });
+
+ // Focus in/out on controls
+ utils.on(this.player.elements.controls, 'focusin focusout', event => {
+ this.player.toggleControls(event);
+ });
+ }
+
+ // Mouse wheel for volume
+ utils.on(
+ this.player.elements.inputs.volume,
+ 'wheel',
+ event =>
+ proxy(event, 'volume', () => {
+ // Detect "natural" scroll - suppored on OS X Safari only
+ // Other browsers on OS X will be inverted until support improves
+ const inverted = event.webkitDirectionInvertedFromDevice;
+ const step = 1 / 50;
+ let direction = 0;
+
+ // Scroll down (or up on natural) to decrease
+ if (event.deltaY < 0 || event.deltaX > 0) {
+ if (inverted) {
+ this.player.decreaseVolume(step);
+ direction = -1;
+ } else {
+ this.player.increaseVolume(step);
+ direction = 1;
+ }
+ }
+
+ // Scroll up (or down on natural) to increase
+ if (event.deltaY > 0 || event.deltaX < 0) {
+ if (inverted) {
+ this.player.increaseVolume(step);
+ direction = 1;
+ } else {
+ this.player.decreaseVolume(step);
+ direction = -1;
+ }
+ }
+
+ // Don't break page scrolling at max and min
+ if ((direction === 1 && this.player.media.volume < 1) || (direction === -1 && this.player.media.volume > 0)) {
+ event.preventDefault();
+ }
+ }),
+ false,
+ );
+ }
+
+ // Reset on destroy
+ clear() {
+ this.global(false);
+ }
+}
+
+export default Listeners;
diff --git a/src/js/media.js b/src/js/media.js
new file mode 100644
index 00000000..494c5376
--- /dev/null
+++ b/src/js/media.js
@@ -0,0 +1,106 @@
+// ==========================================================================
+// Plyr Media
+// ==========================================================================
+
+import support from './support';
+import utils from './utils';
+import youtube from './plugins/youtube';
+import vimeo from './plugins/vimeo';
+import ui from './ui';
+
+// Sniff out the browser
+const browser = utils.getBrowser();
+
+const media = {
+ // Setup media
+ setup() {
+ // If there's no media, bail
+ if (!this.media) {
+ this.debug.warn('No media element found!');
+ return;
+ }
+
+ // Add type class
+ utils.toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true);
+
+ // Add provider class
+ utils.toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true);
+
+ // Add video class for embeds
+ // This will require changes if audio embeds are added
+ if (this.isEmbed) {
+ utils.toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true);
+ }
+
+ if (this.supported.ui) {
+ // Check for picture-in-picture support
+ utils.toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo);
+
+ // Check for airplay support
+ utils.toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);
+
+ // If there's no autoplay attribute, assume the video is stopped and add state class
+ utils.toggleClass(this.elements.container, this.config.classNames.stopped, this.config.autoplay);
+
+ // Add iOS class
+ utils.toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos);
+
+ // Add touch class
+ utils.toggleClass(this.elements.container, this.config.classNames.isTouch, support.touch);
+ }
+
+ // Inject the player wrapper
+ if (this.isVideo) {
+ // Create the wrapper div
+ this.elements.wrapper = utils.createElement('div', {
+ class: this.config.classNames.video,
+ });
+
+ // Wrap the video in a container
+ utils.wrap(this.media, this.elements.wrapper);
+ }
+
+ if (this.isEmbed) {
+ switch (this.provider) {
+ case 'youtube':
+ youtube.setup.call(this);
+ break;
+
+ case 'vimeo':
+ vimeo.setup.call(this);
+ break;
+
+ default:
+ break;
+ }
+ } else if (this.isHTML5) {
+ ui.setTitle.call(this);
+ }
+ },
+
+ // Cancel current network requests
+ // See https://github.com/sampotts/plyr/issues/174
+ cancelRequests() {
+ if (!this.isHTML5) {
+ return;
+ }
+
+ // Remove child sources
+ utils.removeElement(this.media.querySelectorAll('source'));
+
+ // Set blank video src attribute
+ // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error
+ // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection
+ this.media.setAttribute('src', this.config.blankVideo);
+
+ // Load the new empty source
+ // This will cancel existing requests
+ // See https://github.com/sampotts/plyr/issues/174
+ this.media.load();
+
+ // Debugging
+ this.debug.log('Cancelled network requests');
+ },
+};
+
+export default media;
diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js
new file mode 100644
index 00000000..31a797c2
--- /dev/null
+++ b/src/js/plugins/ads.js
@@ -0,0 +1,574 @@
+// ==========================================================================
+// Advertisement plugin using Google IMA HTML5 SDK
+// Create an account with our ad partner, vi here:
+// https://www.vi.ai/publisher-video-monetization/
+// ==========================================================================
+
+/* global google */
+
+import utils from '../utils';
+
+class Ads {
+ /**
+ * Ads constructor.
+ * @param {object} player
+ * @return {Ads}
+ */
+ constructor(player) {
+ this.player = player;
+ this.publisherId = player.config.ads.publisherId;
+ this.enabled = player.isHTML5 && player.isVideo && player.config.ads.enabled && utils.is.string(this.publisherId) && this.publisherId.length;
+ this.playing = false;
+ this.initialized = false;
+ this.elements = {
+ container: null,
+ displayContainer: null,
+ };
+ this.manager = null;
+ this.loader = null;
+ this.cuePoints = null;
+ this.events = {};
+ this.safetyTimer = null;
+ this.countdownTimer = null;
+
+ // Setup a promise to resolve when the IMA manager is ready
+ this.managerPromise = new Promise((resolve, reject) => {
+ // The ad is loaded and ready
+ this.on('loaded', resolve);
+
+ // Ads failed
+ this.on('error', reject);
+ });
+
+ this.load();
+ }
+
+ /**
+ * Load the IMA SDK
+ */
+ load() {
+ if (this.enabled) {
+ // Check if the Google IMA3 SDK is loaded or load it ourselves
+ if (!utils.is.object(window.google) || !utils.is.object(window.google.ima)) {
+ utils
+ .loadScript(this.player.config.urls.googleIMA.api)
+ .then(() => {
+ this.ready();
+ })
+ .catch(() => {
+ // Script failed to load or is blocked
+ this.trigger('error', new Error('Google IMA SDK failed to load'));
+ });
+ } else {
+ this.ready();
+ }
+ }
+ }
+
+ /**
+ * Get the ads instance ready
+ */
+ ready() {
+ // Start ticking our safety timer. If the whole advertisement
+ // thing doesn't resolve within our set time; we bail
+ this.startSafetyTimer(12000, 'ready()');
+
+ // Clear the safety timer
+ this.managerPromise.then(() => {
+ this.clearSafetyTimer('onAdsManagerLoaded()');
+ });
+
+ // Set listeners on the Plyr instance
+ this.listeners();
+
+ // Setup the IMA SDK
+ this.setupIMA();
+ }
+
+ // Build the default tag URL
+ get tagUrl() {
+ const params = {
+ AV_PUBLISHERID: '58c25bb0073ef448b1087ad6',
+ AV_CHANNELID: '5a0458dc28a06145e4519d21',
+ AV_URL: location.hostname,
+ cb: Date.now(),
+ AV_WIDTH: 640,
+ AV_HEIGHT: 480,
+ AV_CDIM2: this.publisherId,
+ };
+
+ const base = 'https://go.aniview.com/api/adserver6/vast/';
+
+ return `${base}?${utils.buildUrlParams(params)}`;
+ }
+
+ /**
+ * In order for the SDK to display ads for our video, we need to tell it where to put them,
+ * so here we define our ad container. This div is set up to render on top of the video player.
+ * Using the code below, we tell the SDK to render ads within that div. We also provide a
+ * handle to the content video player - the SDK will poll the current time of our player to
+ * properly place mid-rolls. After we create the ad display container, we initialize it. On
+ * mobile devices, this initialization is done as the result of a user action.
+ */
+ setupIMA() {
+ // Create the container for our advertisements
+ this.elements.container = utils.createElement('div', {
+ class: this.player.config.classNames.ads,
+ });
+ this.player.elements.container.appendChild(this.elements.container);
+
+ // So we can run VPAID2
+ google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED);
+
+ // Set language
+ google.ima.settings.setLocale(this.player.config.ads.language);
+
+ // We assume the adContainer is the video container of the plyr element
+ // that will house the ads
+ this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container);
+
+ // Request video ads to be pre-loaded
+ this.requestAds();
+ }
+
+ /**
+ * Request advertisements
+ */
+ requestAds() {
+ const { container } = this.player.elements;
+
+ try {
+ // Create ads loader
+ this.loader = new google.ima.AdsLoader(this.elements.displayContainer);
+
+ // Listen and respond to ads loaded and error events
+ this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, event => this.onAdsManagerLoaded(event), false);
+ this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false);
+
+ // Request video ads
+ const request = new google.ima.AdsRequest();
+ request.adTagUrl = this.tagUrl;
+
+ // Specify the linear and nonlinear slot sizes. This helps the SDK
+ // to select the correct creative if multiple are returned
+ request.linearAdSlotWidth = container.offsetWidth;
+ request.linearAdSlotHeight = container.offsetHeight;
+ request.nonLinearAdSlotWidth = container.offsetWidth;
+ request.nonLinearAdSlotHeight = container.offsetHeight;
+
+ // We only overlay ads as we only support video.
+ request.forceNonLinearFullSlot = false;
+
+ this.loader.requestAds(request);
+ } catch (e) {
+ this.onAdError(e);
+ }
+ }
+
+ /**
+ * Update the ad countdown
+ * @param {boolean} start
+ */
+ pollCountdown(start = false) {
+ if (!start) {
+ clearInterval(this.countdownTimer);
+ this.elements.container.removeAttribute('data-badge-text');
+ return;
+ }
+
+ const update = () => {
+ const time = utils.formatTime(Math.max(this.manager.getRemainingTime(), 0));
+ const label = `${this.player.config.i18n.advertisement} - ${time}`;
+ this.elements.container.setAttribute('data-badge-text', label);
+ };
+
+ this.countdownTimer = setInterval(update, 100);
+ }
+
+ /**
+ * This method is called whenever the ads are ready inside the AdDisplayContainer
+ * @param {Event} adsManagerLoadedEvent
+ */
+ onAdsManagerLoaded(event) {
+ // Get the ads manager
+ const settings = new google.ima.AdsRenderingSettings();
+
+ // Tell the SDK to save and restore content video state on our behalf
+ settings.restoreCustomPlaybackStateOnAdBreakComplete = true;
+ settings.enablePreloading = true;
+
+ // The SDK is polling currentTime on the contentPlayback. And needs a duration
+ // so it can determine when to start the mid- and post-roll
+ this.manager = event.getAdsManager(this.player, settings);
+
+ // Get the cue points for any mid-rolls by filtering out the pre- and post-roll
+ this.cuePoints = this.manager.getCuePoints();
+
+ // Add advertisement cue's within the time line if available
+ this.cuePoints.forEach(cuePoint => {
+ if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < this.player.duration) {
+ const seekElement = this.player.elements.progress;
+
+ if (seekElement) {
+ const cuePercentage = 100 / this.player.duration * cuePoint;
+ const cue = utils.createElement('span', {
+ class: this.player.config.classNames.cues,
+ });
+
+ cue.style.left = `${cuePercentage.toString()}%`;
+ seekElement.appendChild(cue);
+ }
+ }
+ });
+
+ // Get skippable state
+ // TODO: Skip button
+ // this.manager.getAdSkippableState();
+
+ // Set volume to match player
+ this.manager.setVolume(this.player.volume);
+
+ // Add listeners to the required events
+ // Advertisement error events
+ this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error));
+
+ // Advertisement regular events
+ Object.keys(google.ima.AdEvent.Type).forEach(type => {
+ this.manager.addEventListener(google.ima.AdEvent.Type[type], event => this.onAdEvent(event));
+ });
+
+ // Resolve our adsManager
+ this.trigger('loaded');
+ }
+
+ /**
+ * This is where all the event handling takes place. Retrieve the ad from the event. Some
+ * events (e.g. ALL_ADS_COMPLETED) don't have the ad object associated
+ * https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/apis#ima.AdEvent.Type
+ * @param {Event} event
+ */
+ onAdEvent(event) {
+ const { container } = this.player.elements;
+
+ // Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED)
+ // don't have ad object associated
+ const ad = event.getAd();
+
+ // Proxy event
+ const dispatchEvent = type => {
+ const event = `ads${type.replace(/_/g, '').toLowerCase()}`;
+ utils.dispatchEvent.call(this.player, this.player.media, event);
+ };
+
+ switch (event.type) {
+ case google.ima.AdEvent.Type.LOADED:
+ // This is the first event sent for an ad - it is possible to determine whether the
+ // ad is a video ad or an overlay
+ this.trigger('loaded');
+
+ // Bubble event
+ dispatchEvent(event.type);
+
+ // Start countdown
+ this.pollCountdown(true);
+
+ if (!ad.isLinear()) {
+ // Position AdDisplayContainer correctly for overlay
+ ad.width = container.offsetWidth;
+ ad.height = container.offsetHeight;
+ }
+
+ // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex());
+ // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset());
+ break;
+
+ case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:
+ // All ads for the current videos are done. We can now request new advertisements
+ // in case the video is re-played
+
+ // Fire event
+ dispatchEvent(event.type);
+
+ // TODO: Example for what happens when a next video in a playlist would be loaded.
+ // So here we load a new video when all ads are done.
+ // Then we load new ads within a new adsManager. When the video
+ // Is started - after - the ads are loaded, then we get ads.
+ // You can also easily test cancelling and reloading by running
+ // player.ads.cancel() and player.ads.play from the console I guess.
+ // this.player.source = {
+ // type: 'video',
+ // title: 'View From A Blue Moon',
+ // sources: [{
+ // src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.mp4', type:
+ // 'video/mp4', }], poster:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg', tracks:
+ // [ { kind: 'captions', label: 'English', srclang: 'en', src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt',
+ // default: true, }, { kind: 'captions', label: 'French', srclang: 'fr', src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt', }, ],
+ // };
+
+ // TODO: So there is still this thing where a video should only be allowed to start
+ // playing when the IMA SDK is ready or has failed
+
+ this.loadAds();
+ break;
+
+ case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:
+ // This event indicates the ad has started - the video player can adjust the UI,
+ // for example display a pause button and remaining time. Fired when content should
+ // be paused. This usually happens right before an ad is about to cover the content
+
+ dispatchEvent(event.type);
+
+ this.pauseContent();
+
+ break;
+
+ case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED:
+ // This event indicates the ad has finished - the video player can perform
+ // appropriate UI actions, such as removing the timer for remaining time detection.
+ // Fired when content should be resumed. This usually happens when an ad finishes
+ // or collapses
+
+ dispatchEvent(event.type);
+
+ this.pollCountdown();
+
+ this.resumeContent();
+
+ break;
+
+ case google.ima.AdEvent.Type.STARTED:
+ case google.ima.AdEvent.Type.MIDPOINT:
+ case google.ima.AdEvent.Type.COMPLETE:
+ case google.ima.AdEvent.Type.IMPRESSION:
+ case google.ima.AdEvent.Type.CLICK:
+ dispatchEvent(event.type);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Any ad error handling comes through here
+ * @param {Event} event
+ */
+ onAdError(event) {
+ this.cancel();
+ this.player.debug.warn('Ads error', event);
+ }
+
+ /**
+ * Setup hooks for Plyr and window events. This ensures
+ * the mid- and post-roll launch at the correct time. And
+ * resize the advertisement when the player resizes
+ */
+ listeners() {
+ const { container } = this.player.elements;
+ let time;
+
+ // Add listeners to the required events
+ this.player.on('ended', () => {
+ this.loader.contentComplete();
+ });
+
+ this.player.on('seeking', () => {
+ time = this.player.currentTime;
+ return time;
+ });
+
+ this.player.on('seeked', () => {
+ const seekedTime = this.player.currentTime;
+
+ this.cuePoints.forEach((cuePoint, index) => {
+ if (time < cuePoint && cuePoint < seekedTime) {
+ this.manager.discardAdBreak();
+ this.cuePoints.splice(index, 1);
+ }
+ });
+ });
+
+ // Listen to the resizing of the window. And resize ad accordingly
+ // TODO: eventually implement ResizeObserver
+ window.addEventListener('resize', () => {
+ this.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);
+ });
+ }
+
+ /**
+ * Initialize the adsManager and start playing advertisements
+ */
+ play() {
+ const { container } = this.player.elements;
+
+ if (!this.managerPromise) {
+ this.resumeContent();
+ }
+
+ // Play the requested advertisement whenever the adsManager is ready
+ this.managerPromise
+ .then(() => {
+ // Initialize the container. Must be done via a user action on mobile devices
+ this.elements.displayContainer.initialize();
+
+ try {
+ if (!this.initialized) {
+ // Initialize the ads manager. Ad rules playlist will start at this time
+ this.manager.init(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);
+
+ // Call play to start showing the ad. Single video and overlay ads will
+ // start at this time; the call will be ignored for ad rules
+ this.manager.start();
+ }
+
+ this.initialized = true;
+ } catch (adError) {
+ // An error may be thrown if there was a problem with the
+ // VAST response
+ this.onAdError(adError);
+ }
+ })
+ .catch(() => {});
+ }
+
+ /**
+ * Resume our video
+ */
+ resumeContent() {
+ // Hide the advertisement container
+ this.elements.container.style.zIndex = '';
+
+ // Ad is stopped
+ this.playing = false;
+
+ // Play our video
+ if (this.player.currentTime < this.player.duration) {
+ this.player.play();
+ }
+ }
+
+ /**
+ * Pause our video
+ */
+ pauseContent() {
+ // Show the advertisement container
+ this.elements.container.style.zIndex = 3;
+
+ // Ad is playing.
+ this.playing = true;
+
+ // Pause our video.
+ this.player.pause();
+ }
+
+ /**
+ * Destroy the adsManager so we can grab new ads after this. If we don't then we're not
+ * allowed to call new ads based on google policies, as they interpret this as an accidental
+ * video requests. https://developers.google.com/interactive-
+ * media-ads/docs/sdks/android/faq#8
+ */
+ cancel() {
+ // Pause our video
+ if (this.initialized) {
+ this.resumeContent();
+ }
+
+ // Tell our instance that we're done for now
+ this.trigger('error');
+
+ // Re-create our adsManager
+ this.loadAds();
+ }
+
+ /**
+ * Re-create our adsManager
+ */
+ loadAds() {
+ // Tell our adsManager to go bye bye
+ this.managerPromise
+ .then(() => {
+ // Destroy our adsManager
+ if (this.manager) {
+ this.manager.destroy();
+ }
+
+ // Re-set our adsManager promises
+ this.managerPromise = new Promise(resolve => {
+ this.on('loaded', resolve);
+ this.player.debug.log(this.manager);
+ });
+
+ // Now request some new advertisements
+ this.requestAds();
+ })
+ .catch(() => {});
+ }
+
+ /**
+ * Handles callbacks after an ad event was invoked
+ * @param {string} event - Event type
+ */
+ trigger(event, ...args) {
+ const handlers = this.events[event];
+
+ if (utils.is.array(handlers)) {
+ handlers.forEach(handler => {
+ if (utils.is.function(handler)) {
+ handler.apply(this, args);
+ }
+ });
+ }
+ }
+
+ /**
+ * Add event listeners
+ * @param {string} event - Event type
+ * @param {function} callback - Callback for when event occurs
+ * @return {Ads}
+ */
+ on(event, callback) {
+ if (!utils.is.array(this.events[event])) {
+ this.events[event] = [];
+ }
+
+ this.events[event].push(callback);
+
+ return this;
+ }
+
+ /**
+ * Setup a safety timer for when the ad network doesn't respond for whatever reason.
+ * The advertisement has 12 seconds to get its things together. We stop this timer when the
+ * advertisement is playing, or when a user action is required to start, then we clear the
+ * timer on ad ready
+ * @param {number} time
+ * @param {string} from
+ */
+ startSafetyTimer(time, from) {
+ this.player.debug.log(`Safety timer invoked from: ${from}`);
+
+ this.safetyTimer = setTimeout(() => {
+ this.cancel();
+ this.clearSafetyTimer('startSafetyTimer()');
+ }, time);
+ }
+
+ /**
+ * Clear our safety timer(s)
+ * @param {string} from
+ */
+ clearSafetyTimer(from) {
+ if (!utils.is.nullOrUndefined(this.safetyTimer)) {
+ this.player.debug.log(`Safety timer cleared from: ${from}`);
+
+ clearTimeout(this.safetyTimer);
+ this.safetyTimer = null;
+ }
+ }
+}
+
+export default Ads;
diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js
new file mode 100644
index 00000000..fcc4247c
--- /dev/null
+++ b/src/js/plugins/vimeo.js
@@ -0,0 +1,323 @@
+// ==========================================================================
+// Vimeo plugin
+// ==========================================================================
+
+import utils from './../utils';
+import captions from './../captions';
+import ui from './../ui';
+
+const vimeo = {
+ setup() {
+ // Add embed class for responsive
+ utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
+
+ // Set intial ratio
+ vimeo.setAspectRatio.call(this);
+
+ // Load the API if not already
+ if (!utils.is.object(window.Vimeo)) {
+ utils
+ .loadScript(this.config.urls.vimeo.api)
+ .then(() => {
+ vimeo.ready.call(this);
+ })
+ .catch(error => {
+ this.debug.warn('Vimeo API failed to load', error);
+ });
+ } else {
+ vimeo.ready.call(this);
+ }
+ },
+
+ // Set aspect ratio
+ // For Vimeo we have an extra 300% height <div> to hide the standard controls and UI
+ setAspectRatio(input) {
+ const ratio = utils.is.string(input) ? input.split(':') : this.config.ratio.split(':');
+ const padding = 100 / ratio[0] * ratio[1];
+ const height = 200;
+ const offset = (height - padding) / (height / 50);
+ this.elements.wrapper.style.paddingBottom = `${padding}%`;
+ this.media.style.transform = `translateY(-${offset}%)`;
+ },
+
+ // API Ready
+ ready() {
+ const player = this;
+
+ // Get Vimeo params for the iframe
+ const options = {
+ loop: player.config.loop.active,
+ autoplay: player.autoplay,
+ byline: false,
+ portrait: false,
+ title: false,
+ speed: true,
+ transparent: 0,
+ gesture: 'media',
+ };
+ const params = utils.buildUrlParams(options);
+
+ // Get the source URL or ID
+ let source = player.media.getAttribute('src');
+
+ // Get from <div> if needed
+ if (utils.is.empty(source)) {
+ source = player.media.getAttribute(this.config.attributes.embed.id);
+ }
+
+ const id = utils.parseVimeoId(source);
+
+ // Build an iframe
+ const iframe = utils.createElement('iframe');
+ const src = `https://player.vimeo.com/video/${id}?${params}`;
+ iframe.setAttribute('src', src);
+ iframe.setAttribute('allowfullscreen', '');
+ iframe.setAttribute('allowtransparency', '');
+ iframe.setAttribute('allow', 'autoplay');
+
+ // Inject the package
+ const wrapper = utils.createElement('div');
+ wrapper.appendChild(iframe);
+ player.media = utils.replaceElement(wrapper, player.media);
+
+ // Setup instance
+ // https://github.com/vimeo/player.js
+ player.embed = new window.Vimeo.Player(iframe);
+
+ player.media.paused = true;
+ player.media.currentTime = 0;
+
+ // Create a faux HTML5 API using the Vimeo API
+ player.media.play = () => {
+ player.embed.play().then(() => {
+ player.media.paused = false;
+ });
+ };
+
+ player.media.pause = () => {
+ player.embed.pause().then(() => {
+ player.media.paused = true;
+ });
+ };
+
+ player.media.stop = () => {
+ player.embed.stop().then(() => {
+ player.media.paused = true;
+ player.currentTime = 0;
+ });
+ };
+
+ // Seeking
+ let { currentTime } = player.media;
+ Object.defineProperty(player.media, 'currentTime', {
+ get() {
+ return currentTime;
+ },
+ set(time) {
+ // Get current paused state
+ // Vimeo will automatically play on seek
+ const { paused } = player.media;
+
+ // Set seeking flag
+ player.media.seeking = true;
+
+ // Trigger seeking
+ utils.dispatchEvent.call(player, player.media, 'seeking');
+
+ // Seek after events
+ player.embed.setCurrentTime(time);
+
+ // Restore pause state
+ if (paused) {
+ player.pause();
+ }
+ },
+ });
+
+ // Playback speed
+ let speed = player.config.speed.selected;
+ Object.defineProperty(player.media, 'playbackRate', {
+ get() {
+ return speed;
+ },
+ set(input) {
+ player.embed.setPlaybackRate(input).then(() => {
+ speed = input;
+ utils.dispatchEvent.call(player, player.media, 'ratechange');
+ });
+ },
+ });
+
+ // Volume
+ let { volume } = player.config;
+ Object.defineProperty(player.media, 'volume', {
+ get() {
+ return volume;
+ },
+ set(input) {
+ player.embed.setVolume(input).then(() => {
+ volume = input;
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ });
+ },
+ });
+
+ // Muted
+ let { muted } = player.config;
+ Object.defineProperty(player.media, 'muted', {
+ get() {
+ return muted;
+ },
+ set(input) {
+ const toggle = utils.is.boolean(input) ? input : false;
+
+ player.embed.setVolume(toggle ? 0 : player.config.volume).then(() => {
+ muted = toggle;
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ });
+ },
+ });
+
+ // Loop
+ let { loop } = player.config;
+ Object.defineProperty(player.media, 'loop', {
+ get() {
+ return loop;
+ },
+ set(input) {
+ const toggle = utils.is.boolean(input) ? input : player.config.loop.active;
+
+ player.embed.setLoop(toggle).then(() => {
+ loop = toggle;
+ });
+ },
+ });
+
+ // Source
+ let currentSrc;
+ player.embed.getVideoUrl().then(value => {
+ currentSrc = value;
+ });
+ Object.defineProperty(player.media, 'currentSrc', {
+ get() {
+ return currentSrc;
+ },
+ });
+
+ // Ended
+ Object.defineProperty(player.media, 'ended', {
+ get() {
+ return player.currentTime === player.duration;
+ },
+ });
+
+ // Set aspect ratio based on video size
+ Promise.all([
+ player.embed.getVideoWidth(),
+ player.embed.getVideoHeight(),
+ ]).then(dimensions => {
+ const ratio = utils.getAspectRatio(dimensions[0], dimensions[1]);
+ vimeo.setAspectRatio.call(this, ratio);
+ });
+
+ // Set autopause
+ player.embed.setAutopause(player.config.autopause).then(state => {
+ player.config.autopause = state;
+ });
+
+ // Get title
+ player.embed.getVideoTitle().then(title => {
+ player.config.title = title;
+ ui.setTitle.call(this);
+ });
+
+ // Get current time
+ player.embed.getCurrentTime().then(value => {
+ currentTime = value;
+ utils.dispatchEvent.call(player, player.media, 'timeupdate');
+ });
+
+ // Get duration
+ player.embed.getDuration().then(value => {
+ player.media.duration = value;
+ utils.dispatchEvent.call(player, player.media, 'durationchange');
+ });
+
+ // Get captions
+ player.embed.getTextTracks().then(tracks => {
+ player.media.textTracks = tracks;
+ captions.setup.call(player);
+ });
+
+ player.embed.on('cuechange', data => {
+ let cue = null;
+
+ if (data.cues.length) {
+ cue = utils.stripHTML(data.cues[0].text);
+ }
+
+ captions.setText.call(player, cue);
+ });
+
+ player.embed.on('loaded', () => {
+ if (utils.is.element(player.embed.element) && player.supported.ui) {
+ const frame = player.embed.element;
+
+ // Fix keyboard focus issues
+ // https://github.com/sampotts/plyr/issues/317
+ frame.setAttribute('tabindex', -1);
+ }
+ });
+
+ player.embed.on('play', () => {
+ // Only fire play if paused before
+ if (player.media.paused) {
+ utils.dispatchEvent.call(player, player.media, 'play');
+ }
+ player.media.paused = false;
+ utils.dispatchEvent.call(player, player.media, 'playing');
+ });
+
+ player.embed.on('pause', () => {
+ player.media.paused = true;
+ utils.dispatchEvent.call(player, player.media, 'pause');
+ });
+
+ player.embed.on('timeupdate', data => {
+ player.media.seeking = false;
+ currentTime = data.seconds;
+ utils.dispatchEvent.call(player, player.media, 'timeupdate');
+ });
+
+ player.embed.on('progress', data => {
+ player.media.buffered = data.percent;
+ utils.dispatchEvent.call(player, player.media, 'progress');
+
+ // Check all loaded
+ if (parseInt(data.percent, 10) === 1) {
+ utils.dispatchEvent.call(player, player.media, 'canplaythrough');
+ }
+ });
+
+ player.embed.on('seeked', () => {
+ player.media.seeking = false;
+ utils.dispatchEvent.call(player, player.media, 'seeked');
+ utils.dispatchEvent.call(player, player.media, 'play');
+ });
+
+ player.embed.on('ended', () => {
+ player.media.paused = true;
+ utils.dispatchEvent.call(player, player.media, 'ended');
+ });
+
+ player.embed.on('error', detail => {
+ player.media.error = detail;
+ utils.dispatchEvent.call(player, player.media, 'error');
+ });
+
+ // Rebuild UI
+ setTimeout(() => ui.build.call(player), 0);
+ },
+};
+
+export default vimeo;
diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js
new file mode 100644
index 00000000..0ded378a
--- /dev/null
+++ b/src/js/plugins/youtube.js
@@ -0,0 +1,417 @@
+// ==========================================================================
+// YouTube plugin
+// ==========================================================================
+
+import utils from './../utils';
+import controls from './../controls';
+import ui from './../ui';
+
+const youtube = {
+ setup() {
+ // Add embed class for responsive
+ utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
+
+ // Set aspect ratio
+ youtube.setAspectRatio.call(this);
+
+ // Setup API
+ if (utils.is.object(window.YT) && utils.is.function(window.YT.Player)) {
+ youtube.ready.call(this);
+ } else {
+ // Load the API
+ utils.loadScript(this.config.urls.youtube.api).catch(error => {
+ this.debug.warn('YouTube API failed to load', error);
+ });
+
+ // Setup callback for the API
+ // YouTube has it's own system of course...
+ window.onYouTubeReadyCallbacks = window.onYouTubeReadyCallbacks || [];
+
+ // Add to queue
+ window.onYouTubeReadyCallbacks.push(() => {
+ youtube.ready.call(this);
+ });
+
+ // Set callback to process queue
+ window.onYouTubeIframeAPIReady = () => {
+ window.onYouTubeReadyCallbacks.forEach(callback => {
+ callback();
+ });
+ };
+ }
+ },
+
+ // Get the media title
+ getTitle(videoId) {
+ // Try via undocumented API method first
+ // This method disappears now and then though...
+ // https://github.com/sampotts/plyr/issues/709
+ if (utils.is.function(this.embed.getVideoData)) {
+ const { title } = this.embed.getVideoData();
+
+ if (utils.is.empty(title)) {
+ this.config.title = title;
+ ui.setTitle.call(this);
+ return;
+ }
+ }
+
+ // Or via Google API
+ const key = this.config.keys.google;
+ if (utils.is.string(key) && !utils.is.empty(key)) {
+ const url = `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&key=${key}&fields=items(snippet(title))&part=snippet`;
+
+ utils
+ .fetch(url)
+ .then(result => {
+ if (utils.is.object(result)) {
+ this.config.title = result.items[0].snippet.title;
+ ui.setTitle.call(this);
+ }
+ })
+ .catch(() => {});
+ }
+ },
+
+ // Set aspect ratio
+ setAspectRatio() {
+ const ratio = this.config.ratio.split(':');
+ this.elements.wrapper.style.paddingBottom = `${100 / ratio[0] * ratio[1]}%`;
+ },
+
+ // API ready
+ ready() {
+ const player = this;
+
+ // Ignore already setup (race condition)
+ const currentId = player.media.getAttribute('id');
+ if (!utils.is.empty(currentId) && currentId.startsWith('youtube-')) {
+ return;
+ }
+
+ // Get the source URL or ID
+ let source = player.media.getAttribute('src');
+
+ // Get from <div> if needed
+ if (utils.is.empty(source)) {
+ source = player.media.getAttribute(this.config.attributes.embed.id);
+ }
+
+ // Replace the <iframe> with a <div> due to YouTube API issues
+ const videoId = utils.parseYouTubeId(source);
+ const id = utils.generateId(player.provider);
+ const container = utils.createElement('div', { id });
+ player.media = utils.replaceElement(container, player.media);
+
+ // Setup instance
+ // https://developers.google.com/youtube/iframe_api_reference
+ player.embed = new window.YT.Player(id, {
+ videoId,
+ playerVars: {
+ autoplay: player.config.autoplay ? 1 : 0, // Autoplay
+ controls: player.supported.ui ? 0 : 1, // Only show controls if not fully supported
+ rel: 0, // No related vids
+ showinfo: 0, // Hide info
+ iv_load_policy: 3, // Hide annotations
+ modestbranding: 1, // Hide logos as much as possible (they still show one in the corner when paused)
+ disablekb: 1, // Disable keyboard as we handle it
+ playsinline: 1, // Allow iOS inline playback
+
+ // Tracking for stats
+ // origin: window ? `${window.location.protocol}//${window.location.host}` : null,
+ widget_referrer: window ? window.location.href : null,
+
+ // Captions are flaky on YouTube
+ cc_load_policy: player.captions.active ? 1 : 0,
+ cc_lang_pref: player.config.captions.language,
+ },
+ events: {
+ onError(event) {
+ // If we've already fired an error, don't do it again
+ // YouTube fires onError twice
+ if (utils.is.object(player.media.error)) {
+ return;
+ }
+
+ const detail = {
+ code: event.data,
+ };
+
+ // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError
+ switch (event.data) {
+ case 2:
+ detail.message =
+ 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.';
+ break;
+
+ case 5:
+ detail.message =
+ 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.';
+ break;
+
+ case 100:
+ detail.message =
+ 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.';
+ break;
+
+ case 101:
+ case 150:
+ detail.message = 'The owner of the requested video does not allow it to be played in embedded players.';
+ break;
+
+ default:
+ detail.message = 'An unknown error occured';
+ break;
+ }
+
+ player.media.error = detail;
+
+ utils.dispatchEvent.call(player, player.media, 'error');
+ },
+ onPlaybackQualityChange(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Get current quality
+ player.media.quality = instance.getPlaybackQuality();
+
+ utils.dispatchEvent.call(player, player.media, 'qualitychange');
+ },
+ onPlaybackRateChange(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Get current speed
+ player.media.playbackRate = instance.getPlaybackRate();
+
+ utils.dispatchEvent.call(player, player.media, 'ratechange');
+ },
+ onReady(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Get the title
+ youtube.getTitle.call(player, videoId);
+
+ // Create a faux HTML5 API using the YouTube API
+ player.media.play = () => {
+ instance.playVideo();
+ };
+
+ player.media.pause = () => {
+ instance.pauseVideo();
+ };
+
+ player.media.stop = () => {
+ instance.stopVideo();
+ };
+
+ player.media.duration = instance.getDuration();
+ player.media.paused = true;
+
+ // Seeking
+ player.media.currentTime = 0;
+ Object.defineProperty(player.media, 'currentTime', {
+ get() {
+ return Number(instance.getCurrentTime());
+ },
+ set(time) {
+ // Set seeking flag
+ player.media.seeking = true;
+
+ // Trigger seeking
+ utils.dispatchEvent.call(player, player.media, 'seeking');
+
+ // Seek after events sent
+ instance.seekTo(time);
+ },
+ });
+
+ // Playback speed
+ Object.defineProperty(player.media, 'playbackRate', {
+ get() {
+ return instance.getPlaybackRate();
+ },
+ set(input) {
+ instance.setPlaybackRate(input);
+ },
+ });
+
+ // Quality
+ Object.defineProperty(player.media, 'quality', {
+ get() {
+ return instance.getPlaybackQuality();
+ },
+ set(input) {
+ // Trigger request event
+ utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, {
+ quality: input,
+ });
+
+ instance.setPlaybackQuality(input);
+ },
+ });
+
+ // Volume
+ let { volume } = player.config;
+ Object.defineProperty(player.media, 'volume', {
+ get() {
+ return volume;
+ },
+ set(input) {
+ volume = input;
+ instance.setVolume(volume * 100);
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ },
+ });
+
+ // Muted
+ let { muted } = player.config;
+ Object.defineProperty(player.media, 'muted', {
+ get() {
+ return muted;
+ },
+ set(input) {
+ const toggle = utils.is.boolean(input) ? input : muted;
+ muted = toggle;
+ instance[toggle ? 'mute' : 'unMute']();
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ },
+ });
+
+ // Source
+ Object.defineProperty(player.media, 'currentSrc', {
+ get() {
+ return instance.getVideoUrl();
+ },
+ });
+
+ // Ended
+ Object.defineProperty(player.media, 'ended', {
+ get() {
+ return player.currentTime === player.duration;
+ },
+ });
+
+ // Get available speeds
+ player.options.speed = instance.getAvailablePlaybackRates();
+
+ // Set the tabindex to avoid focus entering iframe
+ if (player.supported.ui) {
+ player.media.setAttribute('tabindex', -1);
+ }
+
+ utils.dispatchEvent.call(player, player.media, 'timeupdate');
+ utils.dispatchEvent.call(player, player.media, 'durationchange');
+
+ // Reset timer
+ clearInterval(player.timers.buffering);
+
+ // Setup buffering
+ player.timers.buffering = setInterval(() => {
+ // Get loaded % from YouTube
+ player.media.buffered = instance.getVideoLoadedFraction();
+
+ // Trigger progress only when we actually buffer something
+ if (player.media.lastBuffered === null || player.media.lastBuffered < player.media.buffered) {
+ utils.dispatchEvent.call(player, player.media, 'progress');
+ }
+
+ // Set last buffer point
+ player.media.lastBuffered = player.media.buffered;
+
+ // Bail if we're at 100%
+ if (player.media.buffered === 1) {
+ clearInterval(player.timers.buffering);
+
+ // Trigger event
+ utils.dispatchEvent.call(player, player.media, 'canplaythrough');
+ }
+ }, 200);
+
+ // Rebuild UI
+ setTimeout(() => ui.build.call(player), 50);
+ },
+ onStateChange(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Reset timer
+ clearInterval(player.timers.playing);
+
+ // Handle events
+ // -1 Unstarted
+ // 0 Ended
+ // 1 Playing
+ // 2 Paused
+ // 3 Buffering
+ // 5 Video cued
+ switch (event.data) {
+ case 0:
+ player.media.paused = true;
+
+ // YouTube doesn't support loop for a single video, so mimick it.
+ if (player.media.loop) {
+ // YouTube needs a call to `stopVideo` before playing again
+ instance.stopVideo();
+ instance.playVideo();
+ } else {
+ utils.dispatchEvent.call(player, player.media, 'ended');
+ }
+
+ break;
+
+ case 1:
+ // If we were seeking, fire seeked event
+ if (player.media.seeking) {
+ utils.dispatchEvent.call(player, player.media, 'seeked');
+ }
+ player.media.seeking = false;
+
+ // Only fire play if paused before
+ if (player.media.paused) {
+ utils.dispatchEvent.call(player, player.media, 'play');
+ }
+ player.media.paused = false;
+
+ utils.dispatchEvent.call(player, player.media, 'playing');
+
+ // Poll to get playback progress
+ player.timers.playing = setInterval(() => {
+ utils.dispatchEvent.call(player, player.media, 'timeupdate');
+ }, 50);
+
+ // Check duration again due to YouTube bug
+ // https://github.com/sampotts/plyr/issues/374
+ // https://code.google.com/p/gdata-issues/issues/detail?id=8690
+ if (player.media.duration !== instance.getDuration()) {
+ player.media.duration = instance.getDuration();
+ utils.dispatchEvent.call(player, player.media, 'durationchange');
+ }
+
+ // Get quality
+ controls.setQualityMenu.call(player, instance.getAvailableQualityLevels());
+
+ break;
+
+ case 2:
+ player.media.paused = true;
+
+ utils.dispatchEvent.call(player, player.media, 'pause');
+
+ break;
+
+ default:
+ break;
+ }
+
+ utils.dispatchEvent.call(player, player.elements.container, 'statechange', false, {
+ code: event.data,
+ });
+ },
+ },
+ });
+ },
+};
+
+export default youtube;
diff --git a/src/js/plyr.js b/src/js/plyr.js
index afd1b997..00b7a007 100644
--- a/src/js/plyr.js
+++ b/src/js/plyr.js
@@ -1,3961 +1,1192 @@
// ==========================================================================
// Plyr
-// plyr.js v2.0.18
+// plyr.js v3.0.0-beta.20
// https://github.com/sampotts/plyr
// License: The MIT License (MIT)
// ==========================================================================
-// Credits: http://paypal.github.io/accessible-html5-video-player/
-// ==========================================================================
-(function(root, factory) {
- 'use strict';
- /*global define,module*/
-
- if (typeof module === 'object' && typeof module.exports === 'object') {
- // Node, CommonJS-like
- module.exports = factory(root, document);
- } else if (typeof define === 'function' && define.amd) {
- // AMD
- define([], function() {
- return factory(root, document);
- });
- } else {
- // Browser globals (root is window)
- root.plyr = factory(root, document);
- }
-})(typeof window !== 'undefined' ? window : this, function(window, document) {
- 'use strict';
-
- // Globals
- var fullscreen,
- scroll = { x: 0, y: 0 },
- // Default config
- defaults = {
- enabled: true,
- debug: false,
- autoplay: false,
- loop: false,
- seekTime: 10,
- volume: 10,
- volumeMin: 0,
- volumeMax: 10,
- volumeStep: 1,
- duration: null,
- displayDuration: true,
- loadSprite: true,
- iconPrefix: 'plyr',
- iconUrl: 'https://cdn.plyr.io/2.0.18/plyr.svg',
- blankUrl: 'https://cdn.plyr.io/static/blank.mp4',
- clickToPlay: true,
- hideControls: true,
- showPosterOnEnd: false,
- disableContextMenu: true,
- keyboardShorcuts: {
- focused: true,
- global: false,
- },
- tooltips: {
- controls: false,
- seek: true,
- },
- selectors: {
- html5: 'video, audio',
- embed: '[data-type]',
- editable: 'input, textarea, select, [contenteditable]',
- container: '.plyr',
- controls: {
- container: null,
- wrapper: '.plyr__controls',
- },
- labels: '[data-plyr]',
- buttons: {
- seek: '[data-plyr="seek"]',
- play: '[data-plyr="play"]',
- pause: '[data-plyr="pause"]',
- restart: '[data-plyr="restart"]',
- rewind: '[data-plyr="rewind"]',
- forward: '[data-plyr="fast-forward"]',
- mute: '[data-plyr="mute"]',
- captions: '[data-plyr="captions"]',
- fullscreen: '[data-plyr="fullscreen"]',
- },
- volume: {
- input: '[data-plyr="volume"]',
- display: '.plyr__volume--display',
- },
- progress: {
- container: '.plyr__progress',
- buffer: '.plyr__progress--buffer',
- played: '.plyr__progress--played',
- },
- captions: '.plyr__captions',
- currentTime: '.plyr__time--current',
- duration: '.plyr__time--duration',
- },
- classes: {
- setup: 'plyr--setup',
- ready: 'plyr--ready',
- videoWrapper: 'plyr__video-wrapper',
- embedWrapper: 'plyr__video-embed',
- type: 'plyr--{0}',
- stopped: 'plyr--stopped',
- playing: 'plyr--playing',
- muted: 'plyr--muted',
- loading: 'plyr--loading',
- hover: 'plyr--hover',
- tooltip: 'plyr__tooltip',
- hidden: 'plyr__sr-only',
- hideControls: 'plyr--hide-controls',
- isIos: 'plyr--is-ios',
- isTouch: 'plyr--is-touch',
- captions: {
- enabled: 'plyr--captions-enabled',
- active: 'plyr--captions-active',
- },
- fullscreen: {
- enabled: 'plyr--fullscreen-enabled',
- fallback: 'plyr--fullscreen-fallback',
- active: 'plyr--fullscreen-active',
- },
- tabFocus: 'tab-focus',
- },
- captions: {
- defaultActive: false,
- },
- fullscreen: {
- enabled: true,
- fallback: true,
- allowAudio: false,
- },
- storage: {
- enabled: true,
- key: 'plyr',
- },
- controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'fullscreen'],
- i18n: {
- restart: 'Restart',
- rewind: 'Rewind {seektime} secs',
- play: 'Play',
- pause: 'Pause',
- forward: 'Forward {seektime} secs',
- played: 'played',
- buffered: 'buffered',
- currentTime: 'Current time',
- duration: 'Duration',
- volume: 'Volume',
- toggleMute: 'Toggle Mute',
- toggleCaptions: 'Toggle Captions',
- toggleFullscreen: 'Toggle Fullscreen',
- frameTitle: 'Player for {title}',
- },
- types: {
- embed: ['youtube', 'vimeo', 'soundcloud'],
- html5: ['video', 'audio'],
- },
- // URLs
- urls: {
- vimeo: {
- api: 'https://player.vimeo.com/api/player.js',
- },
- youtube: {
- api: 'https://www.youtube.com/iframe_api',
- },
- soundcloud: {
- api: 'https://w.soundcloud.com/player/api.js',
- },
- },
- // Custom control listeners
- listeners: {
- seek: null,
- play: null,
- pause: null,
- restart: null,
- rewind: null,
- forward: null,
- mute: null,
- volume: null,
- captions: null,
- fullscreen: null,
+import { providers, types } from './types';
+import defaults from './defaults';
+import support from './support';
+import utils from './utils';
+
+import Console from './console';
+import Fullscreen from './fullscreen';
+import Listeners from './listeners';
+import Storage from './storage';
+import Ads from './plugins/ads';
+
+import captions from './captions';
+import controls from './controls';
+import media from './media';
+import source from './source';
+import ui from './ui';
+
+// Private properties
+// TODO: Use a WeakMap for private globals
+// const globals = new WeakMap();
+
+// Plyr instance
+class Plyr {
+ constructor(target, options) {
+ this.timers = {};
+
+ // State
+ this.ready = false;
+ this.loading = false;
+ this.failed = false;
+
+ // Set the media element
+ this.media = target;
+
+ // String selector passed
+ if (utils.is.string(this.media)) {
+ this.media = document.querySelectorAll(this.media);
+ }
+
+ // jQuery, NodeList or Array passed, use first element
+ if ((window.jQuery && this.media instanceof jQuery) || utils.is.nodeList(this.media) || utils.is.array(this.media)) {
+ // eslint-disable-next-line
+ this.media = this.media[0];
+ }
+
+ // Set config
+ this.config = utils.extend(
+ {},
+ defaults,
+ options,
+ (() => {
+ try {
+ return JSON.parse(this.media.getAttribute('data-plyr-config'));
+ } catch (e) {
+ return {};
+ }
+ })(),
+ );
+
+ // Elements cache
+ this.elements = {
+ container: null,
+ buttons: {},
+ display: {},
+ progress: {},
+ inputs: {},
+ settings: {
+ menu: null,
+ panes: {},
+ tabs: {},
},
- // Events to watch on HTML5 media elements
- events: [
- 'ready',
- 'ended',
- 'progress',
- 'stalled',
- 'playing',
- 'waiting',
- 'canplay',
- 'canplaythrough',
- 'loadstart',
- 'loadeddata',
- 'loadedmetadata',
- 'timeupdate',
- 'volumechange',
- 'play',
- 'pause',
- 'error',
- 'seeking',
- 'seeked',
- 'emptied',
- ],
- // Logging
- logPrefix: '[Plyr]',
+ captions: null,
};
- // Credits: http://paypal.github.io/accessible-html5-video-player/
- // Unfortunately, due to mixed support, UA sniffing is required
- function _browserSniff() {
- var ua = navigator.userAgent,
- name = navigator.appName,
- fullVersion = '' + parseFloat(navigator.appVersion),
- majorVersion = parseInt(navigator.appVersion, 10),
- nameOffset,
- verOffset,
- ix,
- isIE = false,
- isFirefox = false,
- isChrome = false,
- isSafari = false;
-
- if (navigator.appVersion.indexOf('Windows NT') !== -1 && navigator.appVersion.indexOf('rv:11') !== -1) {
- // MSIE 11
- isIE = true;
- name = 'IE';
- fullVersion = '11';
- } else if ((verOffset = ua.indexOf('MSIE')) !== -1) {
- // MSIE
- isIE = true;
- name = 'IE';
- fullVersion = ua.substring(verOffset + 5);
- } else if ((verOffset = ua.indexOf('Chrome')) !== -1) {
- // Chrome
- isChrome = true;
- name = 'Chrome';
- fullVersion = ua.substring(verOffset + 7);
- } else if ((verOffset = ua.indexOf('Safari')) !== -1) {
- // Safari
- isSafari = true;
- name = 'Safari';
- fullVersion = ua.substring(verOffset + 7);
- if ((verOffset = ua.indexOf('Version')) !== -1) {
- fullVersion = ua.substring(verOffset + 8);
- }
- } else if ((verOffset = ua.indexOf('Firefox')) !== -1) {
- // Firefox
- isFirefox = true;
- name = 'Firefox';
- fullVersion = ua.substring(verOffset + 8);
- } else if ((nameOffset = ua.lastIndexOf(' ') + 1) < (verOffset = ua.lastIndexOf('/'))) {
- // In most other browsers, 'name/version' is at the end of userAgent
- name = ua.substring(nameOffset, verOffset);
- fullVersion = ua.substring(verOffset + 1);
-
- if (name.toLowerCase() === name.toUpperCase()) {
- name = navigator.appName;
- }
- }
-
- // Trim the fullVersion string at semicolon/space if present
- if ((ix = fullVersion.indexOf(';')) !== -1) {
- fullVersion = fullVersion.substring(0, ix);
- }
- if ((ix = fullVersion.indexOf(' ')) !== -1) {
- fullVersion = fullVersion.substring(0, ix);
- }
+ // Captions
+ this.captions = {
+ active: null,
+ currentTrack: null,
+ };
- // Get major version
- majorVersion = parseInt('' + fullVersion, 10);
- if (isNaN(majorVersion)) {
- fullVersion = '' + parseFloat(navigator.appVersion);
- majorVersion = parseInt(navigator.appVersion, 10);
- }
+ // Fullscreen
+ this.fullscreen = {
+ active: false,
+ };
- // Return data
- return {
- name: name,
- version: majorVersion,
- isIE: isIE,
- isFirefox: isFirefox,
- isChrome: isChrome,
- isSafari: isSafari,
- isIos: /(iPad|iPhone|iPod)/g.test(navigator.platform),
- isIphone: /(iPhone|iPod)/g.test(navigator.userAgent),
- isTouch: 'ontouchstart' in document.documentElement,
+ // Options
+ this.options = {
+ speed: [],
+ quality: [],
};
- }
- // Check for mime type support against a player instance
- // Credits: http://diveintohtml5.info/everything.html
- // Related: http://www.leanbackplyr.com/test/h5mt.html
- function _supportMime(plyr, mimeType) {
- var media = plyr.media;
-
- if (plyr.type === 'video') {
- // Check type
- switch (mimeType) {
- case 'video/webm':
- return !!(media.canPlayType && media.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, ''));
- case 'video/mp4':
- return !!(media.canPlayType && media.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, ''));
- case 'video/ogg':
- return !!(media.canPlayType && media.canPlayType('video/ogg; codecs="theora"').replace(/no/, ''));
- }
- } else if (plyr.type === 'audio') {
- // Check type
- switch (mimeType) {
- case 'audio/mpeg':
- return !!(media.canPlayType && media.canPlayType('audio/mpeg;').replace(/no/, ''));
- case 'audio/ogg':
- return !!(media.canPlayType && media.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, ''));
- case 'audio/wav':
- return !!(media.canPlayType && media.canPlayType('audio/wav; codecs="1"').replace(/no/, ''));
- }
- }
+ // Debugging
+ // TODO: move to globals
+ this.debug = new Console(this.config.debug);
- // If we got this far, we're stuffed
- return false;
- }
+ // Log config options and support
+ this.debug.log('Config', this.config);
+ this.debug.log('Support', support);
- // Inject a script
- function _injectScript(source) {
- if (document.querySelectorAll('script[src="' + source + '"]').length) {
+ // We need an element to setup
+ if (utils.is.nullOrUndefined(this.media) || !utils.is.element(this.media)) {
+ this.debug.error('Setup failed: no suitable element passed');
return;
}
- var tag = document.createElement('script');
- tag.src = source;
- var firstScriptTag = document.getElementsByTagName('script')[0];
- firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
- }
-
- // Element exists in an array
- function _inArray(haystack, needle) {
- return Array.prototype.indexOf && haystack.indexOf(needle) !== -1;
- }
-
- // Replace all
- function _replaceAll(string, find, replace) {
- return string.replace(new RegExp(find.replace(/([.*+?\^=!:${}()|\[\]\/\\])/g, '\\$1'), 'g'), replace);
- }
-
- // Wrap an element
- function _wrap(elements, wrapper) {
- // Convert `elements` to an array, if necessary.
- if (!elements.length) {
- elements = [elements];
- }
-
- // Loops backwards to prevent having to clone the wrapper on the
- // first element (see `child` below).
- for (var i = elements.length - 1; i >= 0; i--) {
- var child = i > 0 ? wrapper.cloneNode(true) : wrapper;
- var element = elements[i];
-
- // Cache the current parent and sibling.
- var parent = element.parentNode;
- var sibling = element.nextSibling;
-
- // Wrap the element (is automatically removed from its current
- // parent).
- child.appendChild(element);
-
- // If the element had a sibling, insert the wrapper before
- // the sibling to maintain the HTML structure; otherwise, just
- // append it to the parent.
- if (sibling) {
- parent.insertBefore(child, sibling);
- } else {
- parent.appendChild(child);
- }
-
- return child;
- }
- }
-
- // Unwrap an element
- // http://plainjs.com/javascript/manipulation/unwrap-a-dom-element-35/
- /*function _unwrap(wrapper) {
- // Get the element's parent node
- var parent = wrapper.parentNode;
-
- // Move all children out of the element
- while (wrapper.firstChild) {
- parent.insertBefore(wrapper.firstChild, wrapper);
+ // Bail if the element is initialized
+ if (this.media.plyr) {
+ this.debug.warn('Target already setup');
+ return;
}
- // Remove the empty element
- parent.removeChild(wrapper);
- }*/
-
- // Remove an element
- function _remove(element) {
- if (!element) {
+ // Bail if not enabled
+ if (!this.config.enabled) {
+ this.debug.error('Setup failed: disabled by config');
return;
}
- element.parentNode.removeChild(element);
- }
- // Prepend child
- function _prependChild(parent, element) {
- parent.insertBefore(element, parent.firstChild);
- }
-
- // Set attributes
- function _setAttributes(element, attributes) {
- for (var key in attributes) {
- element.setAttribute(key, _is.boolean(attributes[key]) && attributes[key] ? '' : attributes[key]);
+ // Bail if disabled or no basic support
+ // You may want to disable certain UAs etc
+ if (!support.check().api) {
+ this.debug.error('Setup failed: no support');
+ return;
}
- }
-
- // Insert a HTML element
- function _insertElement(type, parent, attributes) {
- // Create a new <element>
- var element = document.createElement(type);
-
- // Set all passed attributes
- _setAttributes(element, attributes);
- // Inject the new element
- _prependChild(parent, element);
- }
-
- // Get a classname from selector
- function _getClassname(selector) {
- return selector.replace('.', '');
- }
+ // Cache original element state for .destroy()
+ this.elements.original = this.media.cloneNode(true);
- // Toggle class on an element
- function _toggleClass(element, className, state) {
- if (element) {
- if (element.classList) {
- element.classList[state ? 'add' : 'remove'](className);
- } else {
- var name = (' ' + element.className + ' ').replace(/\s+/g, ' ').replace(' ' + className + ' ', '');
- element.className = name + (state ? ' ' + className : '');
- }
- }
- }
+ // Set media type based on tag or data attribute
+ // Supported: video, audio, vimeo, youtube
+ const type = this.media.tagName.toLowerCase();
- // Has class name
- function _hasClass(element, className) {
- if (element) {
- if (element.classList) {
- return element.classList.contains(className);
- } else {
- return new RegExp('(\\s|^)' + className + '(\\s|$)').test(element.className);
- }
- }
- return false;
- }
+ // Embed properties
+ let iframe = null;
+ let url = null;
+ let params = null;
- // Element matches selector
- function _matches(element, selector) {
- var p = Element.prototype;
+ // Different setup based on type
+ switch (type) {
+ case 'div':
+ // Find the frame
+ iframe = this.media.querySelector('iframe');
+
+ // <iframe> type
+ if (utils.is.element(iframe)) {
+ // Detect provider
+ url = iframe.getAttribute('src');
+ this.provider = utils.getProviderByUrl(url);
+
+ // Rework elements
+ this.elements.container = this.media;
+ this.media = iframe;
+
+ // Reset classname
+ this.elements.container.className = '';
+
+ // Get attributes from URL and set config
+ params = utils.getUrlParams(url);
+ if (!utils.is.empty(params)) {
+ const truthy = [
+ '1',
+ 'true',
+ ];
+
+ if (truthy.includes(params.autoplay)) {
+ this.config.autoplay = true;
+ }
+ if (truthy.includes(params.playsinline)) {
+ this.config.inline = true;
+ }
+ if (truthy.includes(params.loop)) {
+ this.config.loop.active = true;
+ }
+ }
+ } else {
+ // <div> with attributes
+ this.provider = this.media.getAttribute(this.config.attributes.embed.provider);
- var f =
- p.matches ||
- p.webkitMatchesSelector ||
- p.mozMatchesSelector ||
- p.msMatchesSelector ||
- function(s) {
- return [].indexOf.call(document.querySelectorAll(s), this) !== -1;
- };
+ // Remove attribute
+ this.media.removeAttribute(this.config.attributes.embed.provider);
+ }
- return f.call(element, selector);
- }
+ // Unsupported or missing provider
+ if (utils.is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) {
+ this.debug.error('Setup failed: Invalid provider');
+ return;
+ }
- // Bind along with custom handler
- function _proxyListener(element, eventName, userListener, defaultListener, useCapture) {
- if (userListener) {
- // Register this before defaultListener
- _on(
- element,
- eventName,
- function(event) {
- userListener.apply(element, [event]);
- },
- useCapture
- );
- }
- _on(
- element,
- eventName,
- function(event) {
- defaultListener.apply(element, [event]);
- },
- useCapture
- );
- }
+ // Audio will come later for external providers
+ this.type = types.video;
- // Toggle event listener
- function _toggleListener(element, events, callback, toggle, useCapture) {
- var eventList = events.split(' ');
+ break;
- // Whether the listener is a capturing listener or not
- // Default to false
- if (!_is.boolean(useCapture)) {
- useCapture = false;
- }
+ case 'video':
+ case 'audio':
+ this.type = type;
+ this.provider = providers.html5;
- // If a nodelist is passed, call itself on each node
- if (element instanceof NodeList) {
- for (var x = 0; x < element.length; x++) {
- if (element[x] instanceof Node) {
- _toggleListener(element[x], arguments[1], arguments[2], arguments[3]);
+ // Get config from attributes
+ if (this.media.hasAttribute('crossorigin')) {
+ this.config.crossorigin = true;
+ }
+ if (this.media.hasAttribute('autoplay')) {
+ this.config.autoplay = true;
+ }
+ if (this.media.hasAttribute('playsinline')) {
+ this.config.inline = true;
+ }
+ if (this.media.hasAttribute('muted')) {
+ this.config.muted = true;
+ }
+ if (this.media.hasAttribute('loop')) {
+ this.config.loop.active = true;
}
- }
- return;
- }
- // If a single node is passed, bind the event listener
- for (var i = 0; i < eventList.length; i++) {
- element[toggle ? 'addEventListener' : 'removeEventListener'](eventList[i], callback, useCapture);
- }
- }
+ break;
- // Bind event
- function _on(element, events, callback, useCapture) {
- if (element) {
- _toggleListener(element, events, callback, true, useCapture);
+ default:
+ this.debug.error('Setup failed: unsupported type');
+ return;
}
- }
- // Unbind event
- function _off(element, events, callback, useCapture) {
- if (element) {
- _toggleListener(element, events, callback, false, useCapture);
- }
- }
+ // Check for support again but with type
+ this.supported = support.check(this.type, this.provider, this.config.inline);
- // Trigger event
- function _event(element, type, bubbles, properties) {
- // Bail if no element
- if (!element || !type) {
+ // If no support for even API, bail
+ if (!this.supported.api) {
+ this.debug.error('Setup failed: no support');
return;
}
- // Default bubbles to false
- if (!_is.boolean(bubbles)) {
- bubbles = false;
- }
+ // Create listeners
+ this.listeners = new Listeners(this);
- // Create and dispatch the event
- var event = new CustomEvent(type, {
- bubbles: bubbles,
- detail: properties,
- });
+ // Setup local storage for user settings
+ this.storage = new Storage(this);
- // Dispatch the event
- element.dispatchEvent(event);
- }
+ // Store reference
+ this.media.plyr = this;
- // Toggle aria-pressed state on a toggle button
- // http://www.ssbbartgroup.com/blog/how-not-to-misuse-aria-states-properties-and-roles
- function _toggleState(target, state) {
- // Bail if no target
- if (!target) {
- return;
+ // Wrap media
+ if (!utils.is.element(this.elements.container)) {
+ this.elements.container = utils.createElement('div');
+ utils.wrap(this.media, this.elements.container);
}
- // Get state
- state = _is.boolean(state) ? state : !target.getAttribute('aria-pressed');
+ // Allow focus to be captured
+ this.elements.container.setAttribute('tabindex', 0);
- // Set the attribute on target
- target.setAttribute('aria-pressed', state);
+ // Add style hook
+ ui.addStyleHook.call(this);
- return state;
- }
-
- // Get percentage
- function _getPercentage(current, max) {
- if (current === 0 || max === 0 || isNaN(current) || isNaN(max)) {
- return 0;
- }
- return (current / max * 100).toFixed(2);
- }
-
- // Deep extend/merge destination object with N more objects
- // http://andrewdupont.net/2009/08/28/deep-extending-objects-in-javascript/
- // Removed call to arguments.callee (used explicit function name instead)
- function _extend() {
- // Get arguments
- var objects = arguments;
+ // Setup media
+ media.setup.call(this);
- // Bail if nothing to merge
- if (!objects.length) {
- return;
+ // Listen for events if debugging
+ if (this.config.debug) {
+ utils.on(this.elements.container, this.config.events.join(' '), event => {
+ this.debug.log(`event: ${event.type}`);
+ });
}
- // Return first if specified but nothing to merge
- if (objects.length === 1) {
- return objects[0];
+ // Setup interface
+ // If embed but not fully supported, build interface now to avoid flash of controls
+ if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) {
+ ui.build.call(this);
}
- // First object is the destination
- var destination = Array.prototype.shift.call(objects),
- length = objects.length;
+ // Container listeners
+ this.listeners.container();
- // Loop through all objects to merge
- for (var i = 0; i < length; i++) {
- var source = objects[i];
+ // Global listeners
+ this.listeners.global();
- for (var property in source) {
- if (source[property] && source[property].constructor && source[property].constructor === Object) {
- destination[property] = destination[property] || {};
- _extend(destination[property], source[property]);
- } else {
- destination[property] = source[property];
- }
- }
- }
+ // Setup fullscreen
+ this.fullscreen = new Fullscreen(this);
- return destination;
+ // Setup ads if provided
+ this.ads = new Ads(this);
}
- // Check variable types
- var _is = {
- object: function(input) {
- return input !== null && typeof input === 'object';
- },
- array: function(input) {
- return input !== null && (typeof input === 'object' && input.constructor === Array);
- },
- number: function(input) {
- return input !== null && ((typeof input === 'number' && !isNaN(input - 0)) || (typeof input === 'object' && input.constructor === Number));
- },
- string: function(input) {
- return input !== null && (typeof input === 'string' || (typeof input === 'object' && input.constructor === String));
- },
- boolean: function(input) {
- return input !== null && typeof input === 'boolean';
- },
- nodeList: function(input) {
- return input !== null && input instanceof NodeList;
- },
- htmlElement: function(input) {
- return input !== null && input instanceof HTMLElement;
- },
- function: function(input) {
- return input !== null && typeof input === 'function';
- },
- undefined: function(input) {
- return input !== null && typeof input === 'undefined';
- },
- };
-
- // Parse YouTube ID from url
- function _parseYouTubeId(url) {
- var regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
- return url.match(regex) ? RegExp.$2 : url;
- }
+ // ---------------------------------------
+ // API
+ // ---------------------------------------
- // Parse Vimeo ID from url
- function _parseVimeoId(url) {
- var regex = /^.*(vimeo.com\/|video\/)(\d+).*/;
- return url.match(regex) ? RegExp.$2 : url;
+ /**
+ * Types and provider helpers
+ */
+ get isHTML5() {
+ return Boolean(this.provider === providers.html5);
+ }
+ get isEmbed() {
+ return Boolean(this.isYouTube || this.isVimeo);
+ }
+ get isYouTube() {
+ return Boolean(this.provider === providers.youtube);
+ }
+ get isVimeo() {
+ return Boolean(this.provider === providers.vimeo);
+ }
+ get isVideo() {
+ return Boolean(this.type === types.video);
+ }
+ get isAudio() {
+ return Boolean(this.type === types.audio);
}
- // Fullscreen API
- function _fullscreen() {
- var fullscreen = {
- supportsFullScreen: false,
- isFullScreen: function() {
- return false;
- },
- requestFullScreen: function() {},
- cancelFullScreen: function() {},
- fullScreenEventName: '',
- element: null,
- prefix: '',
- },
- browserPrefixes = 'webkit o moz ms khtml'.split(' ');
-
- // Check for native support
- if (!_is.undefined(document.cancelFullScreen)) {
- fullscreen.supportsFullScreen = true;
- } else {
- // Check for fullscreen support by vendor prefix
- for (var i = 0, il = browserPrefixes.length; i < il; i++) {
- fullscreen.prefix = browserPrefixes[i];
-
- if (!_is.undefined(document[fullscreen.prefix + 'CancelFullScreen'])) {
- fullscreen.supportsFullScreen = true;
- break;
- } else if (!_is.undefined(document.msExitFullscreen) && document.msFullscreenEnabled) {
- // Special case for MS (when isn't it?)
- fullscreen.prefix = 'ms';
- fullscreen.supportsFullScreen = true;
- break;
- }
- }
+ /**
+ * Play the media, or play the advertisement (if they are not blocked)
+ */
+ play() {
+ // If ads are enabled, wait for them first
+ if (this.ads.enabled && !this.ads.initialized) {
+ return this.ads.managerPromise.then(() => this.ads.play()).catch(() => this.media.play());
}
- // Update methods to do something useful
- if (fullscreen.supportsFullScreen) {
- // Yet again Microsoft awesomeness,
- // Sometimes the prefix is 'ms', sometimes 'MS' to keep you on your toes
- fullscreen.fullScreenEventName = fullscreen.prefix === 'ms' ? 'MSFullscreenChange' : fullscreen.prefix + 'fullscreenchange';
+ // Return the promise (for HTML5)
+ return this.media.play();
+ }
- fullscreen.isFullScreen = function(element) {
- if (_is.undefined(element)) {
- element = document.body;
- }
- switch (this.prefix) {
- case '':
- return document.fullscreenElement === element;
- case 'moz':
- return document.mozFullScreenElement === element;
- default:
- return document[this.prefix + 'FullscreenElement'] === element;
- }
- };
- fullscreen.requestFullScreen = function(element) {
- if (_is.undefined(element)) {
- element = document.body;
- }
- return this.prefix === ''
- ? element.requestFullScreen()
- : element[this.prefix + (this.prefix === 'ms' ? 'RequestFullscreen' : 'RequestFullScreen')]();
- };
- fullscreen.cancelFullScreen = function() {
- return this.prefix === ''
- ? document.cancelFullScreen()
- : document[this.prefix + (this.prefix === 'ms' ? 'ExitFullscreen' : 'CancelFullScreen')]();
- };
- fullscreen.element = function() {
- return this.prefix === '' ? document.fullscreenElement : document[this.prefix + 'FullscreenElement'];
- };
+ /**
+ * Pause the media
+ */
+ pause() {
+ if (!this.playing) {
+ return;
}
- return fullscreen;
+ this.media.pause();
}
- // Local storage
- var _storage = {
- supported: (function() {
- // Try to use it (it might be disabled, e.g. user is in private/porn mode)
- // see: https://github.com/sampotts/plyr/issues/131
- try {
- // Add test item
- window.localStorage.setItem('___test', 'OK');
-
- // Get the test item
- var result = window.localStorage.getItem('___test');
-
- // Clean up
- window.localStorage.removeItem('___test');
-
- // Check if value matches
- return result === 'OK';
- } catch (e) {
- return false;
- }
+ /**
+ * Get paused state
+ */
+ get paused() {
+ return Boolean(this.media.paused);
+ }
- return false;
- })(),
- };
-
- // Player instance
- function Plyr(media, config) {
- var plyr = this,
- timers = {},
- api;
-
- // Set media
- plyr.media = media;
- var original = media.cloneNode(true);
-
- // Trigger events, with plyr instance passed
- function _triggerEvent(element, type, bubbles, properties) {
- _event(
- element,
- type,
- bubbles,
- _extend({}, properties, {
- plyr: api,
- })
- );
- }
+ /**
+ * Get playing state
+ */
+ get playing() {
+ return Boolean(!this.paused && !this.ended && (this.isHTML5 ? this.media.readyState > 2 : true));
+ }
- // Debugging
- function _console(type, args) {
- if (config.debug && window.console) {
- args = Array.prototype.slice.call(args);
+ /**
+ * Get ended state
+ */
+ get ended() {
+ return Boolean(this.media.ended);
+ }
- if (_is.string(config.logPrefix) && config.logPrefix.length) {
- args.unshift(config.logPrefix);
- }
+ /**
+ * Toggle playback based on current status
+ * @param {boolean} input
+ */
+ togglePlay(input) {
+ // Toggle based on current state if nothing passed
+ const toggle = utils.is.boolean(input) ? input : !this.playing;
- console[type].apply(console, args);
- }
- }
- var _log = function() {
- _console('log', arguments);
- },
- _warn = function() {
- _console('warn', arguments);
- };
-
- // Log config options
- _log('Config', config);
-
- // Get icon URL
- function _getIconUrl() {
- return {
- url: config.iconUrl,
- // If you're using svg4everybody you don't need absolute paths
- absolute: config.iconUrl.indexOf('http') === 0 || (plyr.browser.isIE && !window.svg4everybody),
- };
+ if (toggle) {
+ this.play();
+ } else {
+ this.pause();
}
+ }
- // Build the default HTML
- function _buildControls() {
- // Create html array
- var html = [],
- iconUrl = _getIconUrl(),
- iconPath = (!iconUrl.absolute ? iconUrl.url : '') + '#' + config.iconPrefix;
-
- // Larger overlaid play button
- if (_inArray(config.controls, 'play-large')) {
- html.push(
- '<button type="button" data-plyr="play" class="plyr__play-large">',
- '<svg><use xlink:href="' + iconPath + '-play" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.play + '</span>',
- '</button>'
- );
- }
-
- html.push('<div class="plyr__controls">');
-
- // Restart button
- if (_inArray(config.controls, 'restart')) {
- html.push(
- '<button type="button" data-plyr="restart">',
- '<svg><use xlink:href="' + iconPath + '-restart" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.restart + '</span>',
- '</button>'
- );
- }
-
- // Rewind button
- if (_inArray(config.controls, 'rewind')) {
- html.push(
- '<button type="button" data-plyr="rewind">',
- '<svg><use xlink:href="' + iconPath + '-rewind" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.rewind + '</span>',
- '</button>'
- );
- }
-
- // Play Pause button
- // TODO: This should be a toggle button really?
- if (_inArray(config.controls, 'play')) {
- html.push(
- '<button type="button" data-plyr="play">',
- '<svg><use xlink:href="' + iconPath + '-play" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.play + '</span>',
- '</button>',
- '<button type="button" data-plyr="pause">',
- '<svg><use xlink:href="' + iconPath + '-pause" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.pause + '</span>',
- '</button>'
- );
- }
-
- // Fast forward button
- if (_inArray(config.controls, 'fast-forward')) {
- html.push(
- '<button type="button" data-plyr="fast-forward">',
- '<svg><use xlink:href="' + iconPath + '-fast-forward" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.forward + '</span>',
- '</button>'
- );
- }
-
- // Progress
- if (_inArray(config.controls, 'progress')) {
- // Create progress
- html.push(
- '<span class="plyr__progress">',
- '<label for="seek{id}" class="plyr__sr-only">Seek</label>',
- '<input id="seek{id}" class="plyr__progress--seek" type="range" min="0" max="100" step="0.1" value="0" data-plyr="seek">',
- '<progress class="plyr__progress--played" max="100" value="0" role="presentation"></progress>',
- '<progress class="plyr__progress--buffer" max="100" value="0">',
- '<span>0</span>% ' + config.i18n.buffered,
- '</progress>'
- );
-
- // Seek tooltip
- if (config.tooltips.seek) {
- html.push('<span class="plyr__tooltip">00:00</span>');
- }
-
- // Close
- html.push('</span>');
- }
-
- // Media current time display
- if (_inArray(config.controls, 'current-time')) {
- html.push(
- '<span class="plyr__time">',
- '<span class="plyr__sr-only">' + config.i18n.currentTime + '</span>',
- '<span class="plyr__time--current">00:00</span>',
- '</span>'
- );
- }
-
- // Media duration display
- if (_inArray(config.controls, 'duration')) {
- html.push(
- '<span class="plyr__time">',
- '<span class="plyr__sr-only">' + config.i18n.duration + '</span>',
- '<span class="plyr__time--duration">00:00</span>',
- '</span>'
- );
- }
-
- // Toggle mute button
- if (_inArray(config.controls, 'mute')) {
- html.push(
- '<button type="button" data-plyr="mute">',
- '<svg class="icon--muted"><use xlink:href="' + iconPath + '-muted" /></svg>',
- '<svg><use xlink:href="' + iconPath + '-volume" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.toggleMute + '</span>',
- '</button>'
- );
- }
+ /**
+ * Stop playback
+ */
+ stop() {
+ this.restart();
+ this.pause();
+ }
- // Volume range control
- if (_inArray(config.controls, 'volume')) {
- html.push(
- '<span class="plyr__volume">',
- '<label for="volume{id}" class="plyr__sr-only">' + config.i18n.volume + '</label>',
- '<input id="volume{id}" class="plyr__volume--input" type="range" min="' +
- config.volumeMin +
- '" max="' +
- config.volumeMax +
- '" value="' +
- config.volume +
- '" data-plyr="volume">',
- '<progress class="plyr__volume--display" max="' + config.volumeMax + '" value="' + config.volumeMin + '" role="presentation"></progress>',
- '</span>'
- );
- }
+ /**
+ * Restart playback
+ */
+ restart() {
+ this.currentTime = 0;
+ }
- // Toggle captions button
- if (_inArray(config.controls, 'captions')) {
- html.push(
- '<button type="button" data-plyr="captions">',
- '<svg class="icon--captions-on"><use xlink:href="' + iconPath + '-captions-on" /></svg>',
- '<svg><use xlink:href="' + iconPath + '-captions-off" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.toggleCaptions + '</span>',
- '</button>'
- );
- }
+ /**
+ * Rewind
+ * @param {number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime
+ */
+ rewind(seekTime) {
+ this.currentTime = this.currentTime - (utils.is.number(seekTime) ? seekTime : this.config.seekTime);
+ }
- // Toggle fullscreen button
- if (_inArray(config.controls, 'fullscreen')) {
- html.push(
- '<button type="button" data-plyr="fullscreen">',
- '<svg class="icon--exit-fullscreen"><use xlink:href="' + iconPath + '-exit-fullscreen" /></svg>',
- '<svg><use xlink:href="' + iconPath + '-enter-fullscreen" /></svg>',
- '<span class="plyr__sr-only">' + config.i18n.toggleFullscreen + '</span>',
- '</button>'
- );
- }
+ /**
+ * Fast forward
+ * @param {number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime
+ */
+ forward(seekTime) {
+ this.currentTime = this.currentTime + (utils.is.number(seekTime) ? seekTime : this.config.seekTime);
+ }
- // Close everything
- html.push('</div>');
+ /**
+ * Seek to a time
+ * @param {number} input - where to seek to in seconds. Defaults to 0 (the start)
+ */
+ set currentTime(input) {
+ let targetTime = 0;
- return html.join('');
+ if (utils.is.number(input)) {
+ targetTime = input;
}
- // Setup fullscreen
- function _setupFullscreen() {
- if (!plyr.supported.full) {
- return;
- }
-
- if ((plyr.type !== 'audio' || config.fullscreen.allowAudio) && config.fullscreen.enabled) {
- // Check for native support
- var nativeSupport = fullscreen.supportsFullScreen;
-
- if (nativeSupport || (config.fullscreen.fallback && !_inFrame())) {
- _log((nativeSupport ? 'Native' : 'Fallback') + ' fullscreen enabled');
-
- // Add styling hook
- if (!nativeSupport) {
- _toggleClass(plyr.container, config.classes.fullscreen.fallback, true);
- }
-
- // Add styling hook
- _toggleClass(plyr.container, config.classes.fullscreen.enabled, true);
- } else {
- _log('Fullscreen not supported and fallback disabled');
- }
-
- // Toggle state
- if (plyr.buttons && plyr.buttons.fullscreen) {
- _toggleState(plyr.buttons.fullscreen, false);
- }
-
- // Setup focus trap
- _focusTrap();
- }
+ // Normalise targetTime
+ if (targetTime < 0) {
+ targetTime = 0;
+ } else if (targetTime > this.duration) {
+ targetTime = this.duration;
}
- // Setup captions
- function _setupCaptions() {
- // Bail if not HTML5 video
- if (plyr.type !== 'video') {
- return;
- }
-
- // Inject the container
- if (!_getElement(config.selectors.captions)) {
- plyr.videoContainer.insertAdjacentHTML('afterbegin', '<div class="' + _getClassname(config.selectors.captions) + '"></div>');
- }
-
- // Determine if HTML5 textTracks is supported
- plyr.usingTextTracks = false;
- if (plyr.media.textTracks) {
- plyr.usingTextTracks = true;
- }
-
- // Get URL of caption file if exists
- var captionSrc = '',
- kind,
- children = plyr.media.childNodes;
-
- for (var i = 0; i < children.length; i++) {
- if (children[i].nodeName.toLowerCase() === 'track') {
- kind = children[i].kind;
- if (kind === 'captions' || kind === 'subtitles') {
- captionSrc = children[i].getAttribute('src');
- }
- }
- }
+ // Set
+ this.media.currentTime = targetTime.toFixed(4);
- // Record if caption file exists or not
- plyr.captionExists = true;
- if (captionSrc === '') {
- plyr.captionExists = false;
- _log('No caption track found');
- } else {
- _log('Caption track found; URI: ' + captionSrc);
- }
-
- // If no caption file exists, hide container for caption text
- if (!plyr.captionExists) {
- _toggleClass(plyr.container, config.classes.captions.enabled);
- } else {
- // Turn off native caption rendering to avoid double captions
- // This doesn't seem to work in Safari 7+, so the <track> elements are removed from the dom below
- var tracks = plyr.media.textTracks;
- for (var x = 0; x < tracks.length; x++) {
- tracks[x].mode = 'hidden';
- }
+ // Logging
+ this.debug.log(`Seeking to ${this.currentTime} seconds`);
+ }
- // Enable UI
- _showCaptions(plyr);
+ /**
+ * Get current time
+ */
+ get currentTime() {
+ return Number(this.media.currentTime);
+ }
- // Disable unsupported browsers than report false positive
- // Firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1033144
- if ((plyr.browser.isIE && plyr.browser.version >= 10) || (plyr.browser.isFirefox && plyr.browser.version >= 31)) {
- // Debugging
- _log('Detected browser with known TextTrack issues - using manual fallback');
+ /**
+ * Get buffered
+ */
+ get buffered() {
+ const { buffered } = this.media;
- // Set to false so skips to 'manual' captioning
- plyr.usingTextTracks = false;
- }
-
- // Rendering caption tracks
- // Native support required - http://caniuse.com/webvtt
- if (plyr.usingTextTracks) {
- _log('TextTracks supported');
-
- for (var y = 0; y < tracks.length; y++) {
- var track = tracks[y];
-
- if (track.kind === 'captions' || track.kind === 'subtitles') {
- _on(track, 'cuechange', function() {
- // Display a cue, if there is one
- if (this.activeCues[0] && 'text' in this.activeCues[0]) {
- _setCaption(this.activeCues[0].getCueAsHTML());
- } else {
- _setCaption();
- }
- });
- }
- }
- } else {
- // Caption tracks not natively supported
- _log('TextTracks not supported so rendering captions manually');
-
- // Render captions from array at appropriate time
- plyr.currentCaption = '';
- plyr.captions = [];
-
- if (captionSrc !== '') {
- // Create XMLHttpRequest Object
- var xhr = new XMLHttpRequest();
-
- xhr.onreadystatechange = function() {
- if (xhr.readyState === 4) {
- if (xhr.status === 200) {
- var captions = [],
- caption,
- req = xhr.responseText;
-
- //According to webvtt spec, line terminator consists of one of the following
- // CRLF (U+000D U+000A), LF (U+000A) or CR (U+000D)
- var lineSeparator = '\r\n';
- if (req.indexOf(lineSeparator + lineSeparator) === -1) {
- if (req.indexOf('\r\r') !== -1) {
- lineSeparator = '\r';
- } else {
- lineSeparator = '\n';
- }
- }
-
- captions = req.split(lineSeparator + lineSeparator);
-
- for (var r = 0; r < captions.length; r++) {
- caption = captions[r];
- plyr.captions[r] = [];
-
- // Get the parts of the captions
- var parts = caption.split(lineSeparator),
- index = 0;
-
- // Incase caption numbers are added
- if (parts[index].indexOf(':') === -1) {
- index = 1;
- }
-
- plyr.captions[r] = [parts[index], parts[index + 1]];
- }
-
- // Remove first element ('VTT')
- plyr.captions.shift();
-
- _log('Successfully loaded the caption file via AJAX');
- } else {
- _warn(config.logPrefix + 'There was a problem loading the caption file via AJAX');
- }
- }
- };
-
- xhr.open('get', captionSrc, true);
-
- xhr.send();
- }
- }
- }
+ // YouTube / Vimeo return a float between 0-1
+ if (utils.is.number(buffered)) {
+ return buffered;
}
- // Set the current caption
- function _setCaption(caption) {
- /* jshint unused:false */
- var container = _getElement(config.selectors.captions),
- content = document.createElement('span');
-
- // Empty the container
- container.innerHTML = '';
-
- // Default to empty
- if (_is.undefined(caption)) {
- caption = '';
- }
-
- // Set the span content
- if (_is.string(caption)) {
- content.innerHTML = caption.trim();
- } else {
- content.appendChild(caption);
- }
-
- // Set new caption text
- container.appendChild(content);
-
- // Force redraw (for Safari)
- var redraw = container.offsetHeight;
+ // HTML5
+ // TODO: Handle buffered chunks of the media
+ // (i.e. seek to another section buffers only that section)
+ if (buffered && buffered.length && this.duration > 0) {
+ return buffered.end(0) / this.duration;
}
- // Captions functions
- // Seek the manual caption time and update UI
- function _seekManualCaptions(time) {
- // Utilities for caption time codes
- function _timecodeCommon(tc, pos) {
- var tcpair = [];
- tcpair = tc.split(' --> ');
- for (var i = 0; i < tcpair.length; i++) {
- // WebVTT allows for extra meta data after the timestamp line
- // So get rid of this if it exists
- tcpair[i] = tcpair[i].replace(/(\d+:\d+:\d+\.\d+).*/, '$1');
- }
- return _subTcSecs(tcpair[pos]);
- }
- function _timecodeMin(tc) {
- return _timecodeCommon(tc, 0);
- }
- function _timecodeMax(tc) {
- return _timecodeCommon(tc, 1);
- }
- function _subTcSecs(tc) {
- if (tc === null || tc === undefined) {
- return 0;
- } else {
- var tc1 = [],
- tc2 = [],
- seconds;
- tc1 = tc.split(',');
- tc2 = tc1[0].split(':');
- seconds = Math.floor(tc2[0] * 60 * 60) + Math.floor(tc2[1] * 60) + Math.floor(tc2[2]);
- return seconds;
- }
- }
-
- // If it's not video, or we're using textTracks, bail.
- if (plyr.usingTextTracks || plyr.type !== 'video' || !plyr.supported.full) {
- return;
- }
+ return 0;
+ }
- // Reset subcount
- plyr.subcount = 0;
+ /**
+ * Get seeking status
+ */
+ get seeking() {
+ return Boolean(this.media.seeking);
+ }
- // Check time is a number, if not use currentTime
- // IE has a bug where currentTime doesn't go to 0
- // https://twitter.com/Sam_Potts/status/573715746506731521
- time = _is.number(time) ? time : plyr.media.currentTime;
+ /**
+ * Get the duration of the current media
+ */
+ get duration() {
+ // Faux duration set via config
+ const fauxDuration = parseInt(this.config.duration, 10);
- // If there's no subs available, bail
- if (!plyr.captions[plyr.subcount]) {
- return;
- }
+ // True duration
+ const realDuration = Number(this.media.duration);
- while (_timecodeMax(plyr.captions[plyr.subcount][0]) < time.toFixed(1)) {
- plyr.subcount++;
- if (plyr.subcount > plyr.captions.length - 1) {
- plyr.subcount = plyr.captions.length - 1;
- break;
- }
- }
+ // If custom duration is funky, use regular duration
+ return !Number.isNaN(fauxDuration) ? fauxDuration : realDuration;
+ }
- // Check if the next caption is in the current time range
- if (
- plyr.media.currentTime.toFixed(1) >= _timecodeMin(plyr.captions[plyr.subcount][0]) &&
- plyr.media.currentTime.toFixed(1) <= _timecodeMax(plyr.captions[plyr.subcount][0])
- ) {
- plyr.currentCaption = plyr.captions[plyr.subcount][1];
+ /**
+ * Set the player volume
+ * @param {number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage
+ */
+ set volume(value) {
+ let volume = value;
+ const max = 1;
+ const min = 0;
- // Render the caption
- _setCaption(plyr.currentCaption);
- } else {
- _setCaption();
- }
+ if (utils.is.string(volume)) {
+ volume = Number(volume);
}
- // Display captions container and button (for initialization)
- function _showCaptions() {
- // If there's no caption toggle, bail
- if (!plyr.buttons.captions) {
- return;
- }
-
- _toggleClass(plyr.container, config.classes.captions.enabled, true);
-
- // Try to load the value from storage
- var active = plyr.storage.captionsEnabled;
-
- // Otherwise fall back to the default config
- if (!_is.boolean(active)) {
- active = config.captions.defaultActive;
- }
-
- if (active) {
- _toggleClass(plyr.container, config.classes.captions.active, true);
- _toggleState(plyr.buttons.captions, true);
- }
+ // Load volume from storage if no value specified
+ if (!utils.is.number(volume)) {
+ volume = this.storage.get('volume');
}
- // Find all elements
- function _getElements(selector) {
- return plyr.container.querySelectorAll(selector);
+ // Use config if all else fails
+ if (!utils.is.number(volume)) {
+ ({ volume } = this.config);
}
- // Find a single element
- function _getElement(selector) {
- return _getElements(selector)[0];
+ // Maximum is volumeMax
+ if (volume > max) {
+ volume = max;
}
-
- // Determine if we're in an iframe
- function _inFrame() {
- try {
- return window.self !== window.top;
- } catch (e) {
- return true;
- }
+ // Minimum is volumeMin
+ if (volume < min) {
+ volume = min;
}
- // Trap focus inside container
- function _focusTrap() {
- var tabbables = _getElements('input:not([disabled]), button:not([disabled])'),
- first = tabbables[0],
- last = tabbables[tabbables.length - 1];
-
- function _checkFocus(event) {
- // If it is TAB
- if (event.which === 9 && plyr.isFullscreen) {
- if (event.target === last && !event.shiftKey) {
- // Move focus to first element that can be tabbed if Shift isn't used
- event.preventDefault();
- first.focus();
- } else if (event.target === first && event.shiftKey) {
- // Move focus to last element that can be tabbed if Shift is used
- event.preventDefault();
- last.focus();
- }
- }
- }
+ // Update config
+ this.config.volume = volume;
- // Bind the handler
- _on(plyr.container, 'keydown', _checkFocus);
- }
+ // Set the player volume
+ this.media.volume = volume;
- // Add elements to HTML5 media (source, tracks, etc)
- function _insertChildElements(type, attributes) {
- if (_is.string(attributes)) {
- _insertElement(type, plyr.media, { src: attributes });
- } else if (attributes.constructor === Array) {
- for (var i = attributes.length - 1; i >= 0; i--) {
- _insertElement(type, plyr.media, attributes[i]);
- }
- }
+ // If muted, and we're increasing volume, reset muted state
+ if (this.muted && volume > 0) {
+ this.muted = false;
}
+ }
- // Insert controls
- function _injectControls() {
- // Sprite
- if (config.loadSprite) {
- var iconUrl = _getIconUrl();
-
- // Only load external sprite using AJAX
- if (iconUrl.absolute) {
- _log('AJAX loading absolute SVG sprite' + (plyr.browser.isIE ? ' (due to IE)' : ''));
- loadSprite(iconUrl.url, 'sprite-plyr');
- } else {
- _log('Sprite will be used as external resource directly');
- }
- }
-
- // Make a copy of the html
- var html = config.html;
-
- // Insert custom video controls
- _log('Injecting custom controls');
-
- // If no controls are specified, create default
- if (!html) {
- html = _buildControls();
- }
-
- // Replace seek time instances
- html = _replaceAll(html, '{seektime}', config.seekTime);
-
- // Replace all id references with random numbers
- html = _replaceAll(html, '{id}', Math.floor(Math.random() * 10000));
-
- // Replace Title, if it exists
- if (config.title) {
- html = _replaceAll(html, '{title}', config.title);
- }
-
- // Controls container
- var target;
-
- // Inject to custom location
- if (_is.string(config.selectors.controls.container)) {
- target = document.querySelector(config.selectors.controls.container);
- }
-
- // Inject into the container by default
- if (!_is.htmlElement(target)) {
- target = plyr.container;
- }
-
- // Inject controls HTML
- target.insertAdjacentHTML('beforeend', html);
-
- // Setup tooltips
- if (config.tooltips.controls) {
- var labels = _getElements([config.selectors.controls.wrapper, ' ', config.selectors.labels, ' .', config.classes.hidden].join(''));
-
- for (var i = labels.length - 1; i >= 0; i--) {
- var label = labels[i];
+ /**
+ * Get the current player volume
+ */
+ get volume() {
+ return Number(this.media.volume);
+ }
- _toggleClass(label, config.classes.hidden, false);
- _toggleClass(label, config.classes.tooltip, true);
- }
- }
- }
+ /**
+ * Increase volume
+ * @param {boolean} step - How much to decrease by (between 0 and 1)
+ */
+ increaseVolume(step) {
+ const volume = this.media.muted ? 0 : this.volume;
+ this.volume = volume + (utils.is.number(step) ? step : 1);
+ }
- // Find the UI controls and store references
- function _findElements() {
- try {
- plyr.controls = _getElement(config.selectors.controls.wrapper);
-
- // Buttons
- plyr.buttons = {};
- plyr.buttons.seek = _getElement(config.selectors.buttons.seek);
- plyr.buttons.play = _getElements(config.selectors.buttons.play);
- plyr.buttons.pause = _getElement(config.selectors.buttons.pause);
- plyr.buttons.restart = _getElement(config.selectors.buttons.restart);
- plyr.buttons.rewind = _getElement(config.selectors.buttons.rewind);
- plyr.buttons.forward = _getElement(config.selectors.buttons.forward);
- plyr.buttons.fullscreen = _getElement(config.selectors.buttons.fullscreen);
-
- // Inputs
- plyr.buttons.mute = _getElement(config.selectors.buttons.mute);
- plyr.buttons.captions = _getElement(config.selectors.buttons.captions);
-
- // Progress
- plyr.progress = {};
- plyr.progress.container = _getElement(config.selectors.progress.container);
-
- // Progress - Buffering
- plyr.progress.buffer = {};
- plyr.progress.buffer.bar = _getElement(config.selectors.progress.buffer);
- plyr.progress.buffer.text = plyr.progress.buffer.bar && plyr.progress.buffer.bar.getElementsByTagName('span')[0];
-
- // Progress - Played
- plyr.progress.played = _getElement(config.selectors.progress.played);
-
- // Seek tooltip
- plyr.progress.tooltip = plyr.progress.container && plyr.progress.container.querySelector('.' + config.classes.tooltip);
-
- // Volume
- plyr.volume = {};
- plyr.volume.input = _getElement(config.selectors.volume.input);
- plyr.volume.display = _getElement(config.selectors.volume.display);
-
- // Timing
- plyr.duration = _getElement(config.selectors.duration);
- plyr.currentTime = _getElement(config.selectors.currentTime);
- plyr.seekTime = _getElements(config.selectors.seekTime);
-
- return true;
- } catch (e) {
- _warn('It looks like there is a problem with your controls HTML');
+ /**
+ * Decrease volume
+ * @param {boolean} step - How much to decrease by (between 0 and 1)
+ */
+ decreaseVolume(step) {
+ const volume = this.media.muted ? 0 : this.volume;
+ this.volume = volume - (utils.is.number(step) ? step : 1);
+ }
- // Restore native video controls
- _toggleNativeControls(true);
+ /**
+ * Set muted state
+ * @param {boolean} mute
+ */
+ set muted(mute) {
+ let toggle = mute;
- return false;
- }
+ // Load muted state from storage
+ if (!utils.is.boolean(toggle)) {
+ toggle = this.storage.get('muted');
}
- // Toggle style hook
- function _toggleStyleHook() {
- _toggleClass(plyr.container, config.selectors.container.replace('.', ''), plyr.supported.full);
+ // Use config if all else fails
+ if (!utils.is.boolean(toggle)) {
+ toggle = this.config.muted;
}
- // Toggle native controls
- function _toggleNativeControls(toggle) {
- if (toggle && _inArray(config.types.html5, plyr.type)) {
- plyr.media.setAttribute('controls', '');
- } else {
- plyr.media.removeAttribute('controls');
- }
- }
+ // Update config
+ this.config.muted = toggle;
- // Setup aria attribute for play and iframe title
- function _setTitle(iframe) {
- // Find the current text
- var label = config.i18n.play;
-
- // If there's a media title set, use that for the label
- if (_is.string(config.title) && config.title.length) {
- label += ', ' + config.title;
-
- // Set container label
- plyr.container.setAttribute('aria-label', config.title);
- }
+ // Set mute on the player
+ this.media.muted = toggle;
+ }
- // If there's a play button, set label
- if (plyr.supported.full && plyr.buttons.play) {
- for (var i = plyr.buttons.play.length - 1; i >= 0; i--) {
- plyr.buttons.play[i].setAttribute('aria-label', label);
- }
- }
+ /**
+ * Get current muted state
+ */
+ get muted() {
+ return Boolean(this.media.muted);
+ }
- // Set iframe title
- // https://github.com/sampotts/plyr/issues/124
- if (_is.htmlElement(iframe)) {
- iframe.setAttribute('title', config.i18n.frameTitle.replace('{title}', config.title));
- }
+ /**
+ * Check if the media has audio
+ */
+ get hasAudio() {
+ // Assume yes for all non HTML5 (as we can't tell...)
+ if (!this.isHTML5) {
+ return true;
}
- // Setup localStorage
- function _setupStorage() {
- var value = null;
- plyr.storage = {};
-
- // Bail if we don't have localStorage support or it's disabled
- if (!_storage.supported || !config.storage.enabled) {
- return;
- }
-
- // Clean up old volume
- // https://github.com/sampotts/plyr/issues/171
- window.localStorage.removeItem('plyr-volume');
-
- // load value from the current key
- value = window.localStorage.getItem(config.storage.key);
-
- if (!value) {
- // Key wasn't set (or had been cleared), move along
- return;
- } else if (/^\d+(\.\d+)?$/.test(value)) {
- // If value is a number, it's probably volume from an older
- // version of plyr. See: https://github.com/sampotts/plyr/pull/313
- // Update the key to be JSON
- _updateStorage({ volume: parseFloat(value) });
- } else {
- // Assume it's JSON from this or a later version of plyr
- plyr.storage = JSON.parse(value);
- }
+ if (this.isAudio) {
+ return true;
}
- // Save a value back to local storage
- function _updateStorage(value) {
- // Bail if we don't have localStorage support or it's disabled
- if (!_storage.supported || !config.storage.enabled) {
- return;
- }
+ // Get audio tracks
+ return (
+ Boolean(this.media.mozHasAudio) ||
+ Boolean(this.media.webkitAudioDecodedByteCount) ||
+ Boolean(this.media.audioTracks && this.media.audioTracks.length)
+ );
+ }
- // Update the working copy of the values
- _extend(plyr.storage, value);
+ /**
+ * Set playback speed
+ * @param {number} speed - the speed of playback (0.5-2.0)
+ */
+ set speed(input) {
+ let speed = null;
- // Update storage
- window.localStorage.setItem(config.storage.key, JSON.stringify(plyr.storage));
+ if (utils.is.number(input)) {
+ speed = input;
}
- // Setup media
- function _setupMedia() {
- // If there's no media, bail
- if (!plyr.media) {
- _warn('No media element found!');
- return;
- }
-
- if (plyr.supported.full) {
- // Add type class
- _toggleClass(plyr.container, config.classes.type.replace('{0}', plyr.type), true);
-
- // Add video class for embeds
- // This will require changes if audio embeds are added
- if (_inArray(config.types.embed, plyr.type)) {
- _toggleClass(plyr.container, config.classes.type.replace('{0}', 'video'), true);
- }
-
- // If there's no autoplay attribute, assume the video is stopped and add state class
- _toggleClass(plyr.container, config.classes.stopped, config.autoplay);
-
- // Add iOS class
- _toggleClass(plyr.container, config.classes.isIos, plyr.browser.isIos);
-
- // Add touch class
- _toggleClass(plyr.container, config.classes.isTouch, plyr.browser.isTouch);
-
- // Inject the player wrapper
- if (plyr.type === 'video') {
- // Create the wrapper div
- var wrapper = document.createElement('div');
- wrapper.setAttribute('class', config.classes.videoWrapper);
-
- // Wrap the video in a container
- _wrap(plyr.media, wrapper);
-
- // Cache the container
- plyr.videoContainer = wrapper;
- }
- }
-
- // Embeds
- if (_inArray(config.types.embed, plyr.type)) {
- _setupEmbed();
- }
+ if (!utils.is.number(speed)) {
+ speed = this.storage.get('speed');
}
- // Setup YouTube/Vimeo
- function _setupEmbed() {
- var container = document.createElement('div'),
- mediaId,
- mediaUrl,
- id = plyr.type + '-' + Math.floor(Math.random() * 10000);
-
- // Parse IDs from URLs if supplied
- switch (plyr.type) {
- case 'youtube':
- mediaId = _parseYouTubeId(plyr.embedId);
- break;
-
- case 'vimeo':
- mediaId = _parseVimeoId(plyr.embedId);
- break;
-
- default:
- mediaId = plyr.embedId;
- }
-
- // Remove old containers
- var containers = _getElements('[id^="' + plyr.type + '-"]');
- for (var i = containers.length - 1; i >= 0; i--) {
- _remove(containers[i]);
- }
-
- // Add embed class for responsive
- _toggleClass(plyr.media, config.classes.videoWrapper, true);
- _toggleClass(plyr.media, config.classes.embedWrapper, true);
-
- if (plyr.type === 'youtube') {
- // Create the YouTube container
- plyr.media.appendChild(container);
-
- // Set ID
- container.setAttribute('id', id);
-
- // Setup API
- if (_is.object(window.YT)) {
- _youTubeReady(mediaId, container);
- } else {
- // Load the API
- _injectScript(config.urls.youtube.api);
-
- // Setup callback for the API
- window.onYouTubeReadyCallbacks = window.onYouTubeReadyCallbacks || [];
-
- // Add to queue
- window.onYouTubeReadyCallbacks.push(function() {
- _youTubeReady(mediaId, container);
- });
-
- // Set callback to process queue
- window.onYouTubeIframeAPIReady = function() {
- window.onYouTubeReadyCallbacks.forEach(function(callback) {
- callback();
- });
- };
- }
- } else if (plyr.type === 'vimeo') {
- // Vimeo needs an extra div to hide controls on desktop (which has full support)
- if (plyr.supported.full) {
- plyr.media.appendChild(container);
- } else {
- container = plyr.media;
- }
-
- // Set ID
- container.setAttribute('id', id);
-
- // Load the API if not already
- if (!_is.object(window.Vimeo)) {
- _injectScript(config.urls.vimeo.api);
-
- // Wait for fragaloop load
- var vimeoTimer = window.setInterval(function() {
- if (_is.object(window.Vimeo)) {
- window.clearInterval(vimeoTimer);
- _vimeoReady(mediaId, container);
- }
- }, 50);
- } else {
- _vimeoReady(mediaId, container);
- }
- } else if (plyr.type === 'soundcloud') {
- // TODO: Currently unsupported and undocumented
- // Inject the iframe
- var soundCloud = document.createElement('iframe');
-
- // Watch for iframe load
- soundCloud.loaded = false;
- _on(soundCloud, 'load', function() {
- soundCloud.loaded = true;
- });
-
- _setAttributes(soundCloud, {
- src: 'https://w.soundcloud.com/player/?url=https://api.soundcloud.com/tracks/' + mediaId,
- id: id,
- });
-
- container.appendChild(soundCloud);
- plyr.media.appendChild(container);
-
- // Load the API if not already
- if (!window.SC) {
- _injectScript(config.urls.soundcloud.api);
- }
-
- // Wait for SC load
- var soundCloudTimer = window.setInterval(function() {
- if (window.SC && soundCloud.loaded) {
- window.clearInterval(soundCloudTimer);
- _soundcloudReady.call(soundCloud);
- }
- }, 50);
- }
+ if (!utils.is.number(speed)) {
+ speed = this.config.speed.selected;
}
- // When embeds are ready
- function _embedReady() {
- // Setup the UI and call ready if full support
- if (plyr.supported.full) {
- _setupInterface();
- _ready();
- }
-
- // Set title
- _setTitle(_getElement('iframe'));
+ // Set min/max
+ if (speed < 0.1) {
+ speed = 0.1;
}
-
- // Handle YouTube API ready
- function _youTubeReady(videoId, container) {
- // Setup instance
- // https://developers.google.com/youtube/iframe_api_reference
- plyr.embed = new window.YT.Player(container.id, {
- videoId: videoId,
- playerVars: {
- autoplay: config.autoplay ? 1 : 0,
- controls: plyr.supported.full ? 0 : 1,
- rel: 0,
- showinfo: 0,
- iv_load_policy: 3,
- cc_load_policy: config.captions.defaultActive ? 1 : 0,
- cc_lang_pref: 'en',
- wmode: 'transparent',
- modestbranding: 1,
- disablekb: 1,
- origin: '*', // https://code.google.com/p/gdata-issues/issues/detail?id=5788#c45
- },
- events: {
- onError: function(event) {
- _triggerEvent(plyr.container, 'error', true, {
- code: event.data,
- embed: event.target,
- });
- },
- onReady: function(event) {
- // Get the instance
- var instance = event.target;
-
- // Create a faux HTML5 API using the YouTube API
- plyr.media.play = function() {
- instance.playVideo();
- plyr.media.paused = false;
- };
- plyr.media.pause = function() {
- instance.pauseVideo();
- plyr.media.paused = true;
- };
- plyr.media.stop = function() {
- instance.stopVideo();
- plyr.media.paused = true;
- };
- plyr.media.duration = instance.getDuration();
- plyr.media.paused = true;
- plyr.media.currentTime = 0;
- plyr.media.muted = instance.isMuted();
-
- // Set title if possible
- if (typeof instance.getVideoData === 'function') {
- config.title = instance.getVideoData().title;
- }
-
- // Set the tabindex
- if (plyr.supported.full) {
- plyr.media.querySelector('iframe').setAttribute('tabindex', '-1');
- }
-
- // Update UI
- _embedReady();
-
- // Trigger timeupdate
- _triggerEvent(plyr.media, 'timeupdate');
-
- // Trigger timeupdate
- _triggerEvent(plyr.media, 'durationchange');
-
- // Reset timer
- window.clearInterval(timers.buffering);
-
- // Setup buffering
- timers.buffering = window.setInterval(function() {
- // Get loaded % from YouTube
- plyr.media.buffered = instance.getVideoLoadedFraction();
-
- // Trigger progress only when we actually buffer something
- if (plyr.media.lastBuffered === null || plyr.media.lastBuffered < plyr.media.buffered) {
- _triggerEvent(plyr.media, 'progress');
- }
-
- // Set last buffer point
- plyr.media.lastBuffered = plyr.media.buffered;
-
- // Bail if we're at 100%
- if (plyr.media.buffered === 1) {
- window.clearInterval(timers.buffering);
-
- // Trigger event
- _triggerEvent(plyr.media, 'canplaythrough');
- }
- }, 200);
- },
- onStateChange: function(event) {
- // Get the instance
- var instance = event.target;
-
- // Reset timer
- window.clearInterval(timers.playing);
-
- // Handle events
- // -1 Unstarted
- // 0 Ended
- // 1 Playing
- // 2 Paused
- // 3 Buffering
- // 5 Video cued
- switch (event.data) {
- case 0:
- plyr.media.paused = true;
- _triggerEvent(plyr.media, 'ended');
- break;
-
- case 1:
- plyr.media.paused = false;
-
- // If we were seeking, fire seeked event
- if (plyr.media.seeking) {
- _triggerEvent(plyr.media, 'seeked');
- }
-
- plyr.media.seeking = false;
- _triggerEvent(plyr.media, 'play');
- _triggerEvent(plyr.media, 'playing');
-
- // Poll to get playback progress
- timers.playing = window.setInterval(function() {
- // Set the current time
- plyr.media.currentTime = instance.getCurrentTime();
-
- // Trigger timeupdate
- _triggerEvent(plyr.media, 'timeupdate');
- }, 100);
-
- // Check duration again due to YouTube bug
- // https://github.com/sampotts/plyr/issues/374
- // https://code.google.com/p/gdata-issues/issues/detail?id=8690
- if (plyr.media.duration !== instance.getDuration()) {
- plyr.media.duration = instance.getDuration();
- _triggerEvent(plyr.media, 'durationchange');
- }
-
- break;
-
- case 2:
- plyr.media.paused = true;
- _triggerEvent(plyr.media, 'pause');
- break;
- }
-
- _triggerEvent(plyr.container, 'statechange', false, {
- code: event.data,
- });
- },
- },
- });
+ if (speed > 2.0) {
+ speed = 2.0;
}
- // Vimeo ready
- function _vimeoReady(mediaId, container) {
- // Setup instance
- // https://github.com/vimeo/player.js
-
- var options = {
- loop: config.loop,
- autoplay: config.autoplay,
- byline: false,
- portrait: false,
- title: false,
- speed: true,
- transparent: 0,
- };
-
- // Convert options into URL params for iframe
- function buildUrlParameters(options) {
- return Object.keys(options)
- .map(function(key) {
- return encodeURIComponent(key) + '=' + encodeURIComponent(options[key]);
- })
- .join('&');
- }
-
- // Get Vimeo params for the iframe
- var params = buildUrlParameters(options);
-
- // Build an iframe
- var iframe = document.createElement('iframe');
- var src = 'https://player.vimeo.com/video/' + mediaId + '?' + params;
- iframe.setAttribute('src', src);
- iframe.setAttribute('allowfullscreen', '');
- container.appendChild(iframe);
-
- plyr.embed = new window.Vimeo.Player(iframe);
-
- // Create a faux HTML5 API using the Vimeo API
- plyr.media.play = function() {
- plyr.embed.play();
- plyr.media.paused = false;
- };
- plyr.media.pause = function() {
- plyr.embed.pause();
- plyr.media.paused = true;
- };
- plyr.media.stop = function() {
- plyr.embed.stop();
- plyr.media.paused = true;
- };
-
- plyr.media.paused = true;
- plyr.media.currentTime = 0;
-
- // Update UI
- _embedReady();
-
- plyr.embed.getCurrentTime().then(function(value) {
- plyr.media.currentTime = value;
-
- // Trigger timeupdate
- _triggerEvent(plyr.media, 'timeupdate');
- });
-
- plyr.embed.getDuration().then(function(value) {
- plyr.media.duration = value;
-
- // Trigger timeupdate
- _triggerEvent(plyr.media, 'durationchange');
- });
-
- // TODO: Captions
- /*if (config.captions.defaultActive) {
- plyr.embed.enableTextTrack('en');
- }*/
-
- plyr.embed.on('loaded', function() {
- // Fix keyboard focus issues
- // https://github.com/sampotts/plyr/issues/317
- if (_is.htmlElement(plyr.embed.element) && plyr.supported.full) {
- plyr.embed.element.setAttribute('tabindex', '-1');
- }
- });
-
- plyr.embed.on('play', function() {
- plyr.media.paused = false;
- _triggerEvent(plyr.media, 'play');
- _triggerEvent(plyr.media, 'playing');
- });
-
- plyr.embed.on('pause', function() {
- plyr.media.paused = true;
- _triggerEvent(plyr.media, 'pause');
- });
+ if (!this.config.speed.options.includes(speed)) {
+ this.debug.warn(`Unsupported speed (${speed})`);
+ return;
+ }
- plyr.embed.on('timeupdate', function(data) {
- plyr.media.seeking = false;
- plyr.media.currentTime = data.seconds;
- _triggerEvent(plyr.media, 'timeupdate');
- });
+ // Update config
+ this.config.speed.selected = speed;
- plyr.embed.on('progress', function(data) {
- plyr.media.buffered = data.percent;
- _triggerEvent(plyr.media, 'progress');
+ // Set media speed
+ this.media.playbackRate = speed;
+ }
- if (parseInt(data.percent) === 1) {
- // Trigger event
- _triggerEvent(plyr.media, 'canplaythrough');
- }
- });
+ /**
+ * Get current playback speed
+ */
+ get speed() {
+ return Number(this.media.playbackRate);
+ }
- plyr.embed.on('seeked', function() {
- plyr.media.seeking = false;
- _triggerEvent(plyr.media, 'seeked');
- _triggerEvent(plyr.media, 'play');
- });
+ /**
+ * Set playback quality
+ * Currently YouTube only
+ * @param {string} input - Quality level
+ */
+ set quality(input) {
+ let quality = null;
- plyr.embed.on('ended', function() {
- plyr.media.paused = true;
- _triggerEvent(plyr.media, 'ended');
- });
+ if (utils.is.string(input)) {
+ quality = input;
}
- // Soundcloud ready
- function _soundcloudReady() {
- /* jshint validthis: true */
- plyr.embed = window.SC.Widget(this);
-
- // Setup on ready
- plyr.embed.bind(window.SC.Widget.Events.READY, function() {
- // Create a faux HTML5 API using the Soundcloud API
- plyr.media.play = function() {
- plyr.embed.play();
- plyr.media.paused = false;
- };
- plyr.media.pause = function() {
- plyr.embed.pause();
- plyr.media.paused = true;
- };
- plyr.media.stop = function() {
- plyr.embed.seekTo(0);
- plyr.embed.pause();
- plyr.media.paused = true;
- };
-
- plyr.media.paused = true;
- plyr.media.currentTime = 0;
-
- plyr.embed.getDuration(function(value) {
- plyr.media.duration = value / 1000;
-
- // Update UI
- _embedReady();
- });
-
- plyr.embed.getPosition(function(value) {
- plyr.media.currentTime = value;
-
- // Trigger timeupdate
- _triggerEvent(plyr.media, 'timeupdate');
- });
-
- plyr.embed.bind(window.SC.Widget.Events.PLAY, function() {
- plyr.media.paused = false;
- _triggerEvent(plyr.media, 'play');
- _triggerEvent(plyr.media, 'playing');
- });
-
- plyr.embed.bind(window.SC.Widget.Events.PAUSE, function() {
- plyr.media.paused = true;
- _triggerEvent(plyr.media, 'pause');
- });
-
- plyr.embed.bind(window.SC.Widget.Events.PLAY_PROGRESS, function(data) {
- plyr.media.seeking = false;
- plyr.media.currentTime = data.currentPosition / 1000;
- _triggerEvent(plyr.media, 'timeupdate');
- });
-
- plyr.embed.bind(window.SC.Widget.Events.LOAD_PROGRESS, function(data) {
- plyr.media.buffered = data.loadProgress;
- _triggerEvent(plyr.media, 'progress');
-
- if (parseInt(data.loadProgress) === 1) {
- // Trigger event
- _triggerEvent(plyr.media, 'canplaythrough');
- }
- });
-
- plyr.embed.bind(window.SC.Widget.Events.FINISH, function() {
- plyr.media.paused = true;
- _triggerEvent(plyr.media, 'ended');
- });
- });
+ if (!utils.is.string(quality)) {
+ quality = this.storage.get('quality');
}
- // Play media
- function _play() {
- if ('play' in plyr.media) {
- plyr.media.play();
- }
+ if (!utils.is.string(quality)) {
+ quality = this.config.quality.selected;
}
- // Pause media
- function _pause() {
- if ('pause' in plyr.media) {
- plyr.media.pause();
- }
+ if (!this.options.quality.includes(quality)) {
+ this.debug.warn(`Unsupported quality option (${quality})`);
+ return;
}
- // Toggle playback
- function _togglePlay(toggle) {
- // True toggle
- if (!_is.boolean(toggle)) {
- toggle = plyr.media.paused;
- }
-
- if (toggle) {
- _play();
- } else {
- _pause();
- }
+ // Update config
+ this.config.quality.selected = quality;
- return toggle;
- }
-
- // Rewind
- function _rewind(seekTime) {
- // Use default if needed
- if (!_is.number(seekTime)) {
- seekTime = config.seekTime;
- }
- _seek(plyr.media.currentTime - seekTime);
- }
+ // Set quality
+ this.media.quality = quality;
+ }
- // Fast forward
- function _forward(seekTime) {
- // Use default if needed
- if (!_is.number(seekTime)) {
- seekTime = config.seekTime;
- }
- _seek(plyr.media.currentTime + seekTime);
- }
+ /**
+ * Get current quality level
+ */
+ get quality() {
+ return this.media.quality;
+ }
- // Seek to time
- // The input parameter can be an event or a number
- function _seek(input) {
- var targetTime = 0,
- paused = plyr.media.paused,
- duration = _getDuration();
-
- if (_is.number(input)) {
- targetTime = input;
- } else if (_is.object(input) && _inArray(['input', 'change'], input.type)) {
- // It's the seek slider
- // Seek to the selected time
- targetTime = input.target.value / input.target.max * duration;
- }
+ /**
+ * Toggle loop
+ * TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config
+ * @param {boolean} input - Whether to loop or not
+ */
+ set loop(input) {
+ const toggle = utils.is.boolean(input) ? input : this.config.loop.active;
+ this.config.loop.active = toggle;
+ this.media.loop = toggle;
- // Normalise targetTime
- if (targetTime < 0) {
- targetTime = 0;
- } else if (targetTime > duration) {
- targetTime = duration;
- }
+ // Set default to be a true toggle
+ /* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle';
- // Update seek range and progress
- _updateSeekDisplay(targetTime);
-
- // Set the current time
- // Try/catch incase the media isn't set and we're calling seek() from source() and IE moans
- try {
- plyr.media.currentTime = targetTime.toFixed(4);
- } catch (e) {}
-
- // Embeds
- if (_inArray(config.types.embed, plyr.type)) {
- switch (plyr.type) {
- case 'youtube':
- plyr.embed.seekTo(targetTime);
- break;
-
- case 'vimeo':
- // Round to nearest second for vimeo
- plyr.embed.setCurrentTime(targetTime.toFixed(0));
- break;
-
- case 'soundcloud':
- plyr.embed.seekTo(targetTime * 1000);
- break;
+ switch (type) {
+ case 'start':
+ if (this.config.loop.end && this.config.loop.end <= this.currentTime) {
+ this.config.loop.end = null;
}
+ this.config.loop.start = this.currentTime;
+ // this.config.loop.indicator.start = this.elements.display.played.value;
+ break;
- if (paused) {
- _pause();
+ case 'end':
+ if (this.config.loop.start >= this.currentTime) {
+ return this;
}
+ this.config.loop.end = this.currentTime;
+ // this.config.loop.indicator.end = this.elements.display.played.value;
+ break;
- // Trigger timeupdate
- _triggerEvent(plyr.media, 'timeupdate');
-
- // Set seeking flag
- plyr.media.seeking = true;
-
- // Trigger seeking
- _triggerEvent(plyr.media, 'seeking');
- }
-
- // Logging
- _log('Seeking to ' + plyr.media.currentTime + ' seconds');
-
- // Special handling for 'manual' captions
- _seekManualCaptions(targetTime);
- }
-
- // Get the duration (or custom if set)
- function _getDuration() {
- // It should be a number, but parse it just incase
- var duration = parseInt(config.duration),
- // True duration
- mediaDuration = 0;
-
- // Only if duration available
- if (plyr.media.duration !== null && !isNaN(plyr.media.duration)) {
- mediaDuration = plyr.media.duration;
- }
-
- // If custom duration is funky, use regular duration
- return isNaN(duration) ? mediaDuration : duration;
- }
-
- // Check playing state
- function _checkPlaying() {
- _toggleClass(plyr.container, config.classes.playing, !plyr.media.paused);
-
- _toggleClass(plyr.container, config.classes.stopped, plyr.media.paused);
-
- _toggleControls(plyr.media.paused);
- }
-
- // Save scroll position
- function _saveScrollPosition() {
- scroll = {
- x: window.pageXOffset || 0,
- y: window.pageYOffset || 0,
- };
- }
-
- // Restore scroll position
- function _restoreScrollPosition() {
- window.scrollTo(scroll.x, scroll.y);
- }
-
- // Toggle fullscreen
- function _toggleFullscreen(event) {
- // We don't allow fullscreen on audio player
- if (plyr.type === 'audio') {
- return
- }
-
- // Check for native support
- var nativeSupport = fullscreen.supportsFullScreen;
+ case 'all':
+ this.config.loop.start = 0;
+ this.config.loop.end = this.duration - 2;
+ this.config.loop.indicator.start = 0;
+ this.config.loop.indicator.end = 100;
+ break;
- if (nativeSupport) {
- // If it's a fullscreen change event, update the UI
- if (event && event.type === fullscreen.fullScreenEventName) {
- plyr.isFullscreen = fullscreen.isFullScreen(plyr.container);
+ case 'toggle':
+ if (this.config.loop.active) {
+ this.config.loop.start = 0;
+ this.config.loop.end = null;
} else {
- // Else it's a user request to enter or exit
- if (!fullscreen.isFullScreen(plyr.container)) {
- // Save scroll position
- _saveScrollPosition();
-
- // Request full screen
- fullscreen.requestFullScreen(plyr.container);
- } else {
- // Bail from fullscreen
- fullscreen.cancelFullScreen();
- }
-
- // Check if we're actually full screen (it could fail)
- plyr.isFullscreen = fullscreen.isFullScreen(plyr.container);
-
- return;
+ this.config.loop.start = 0;
+ this.config.loop.end = this.duration - 2;
}
- } else {
- // Otherwise, it's a simple toggle
- plyr.isFullscreen = !plyr.isFullscreen;
-
- // Bind/unbind escape key
- document.body.style.overflow = plyr.isFullscreen ? 'hidden' : '';
- }
+ break;
- // Set class hook
- _toggleClass(plyr.container, config.classes.fullscreen.active, plyr.isFullscreen);
+ default:
+ this.config.loop.start = 0;
+ this.config.loop.end = null;
+ break;
+ } */
+ }
- // Trap focus
- _focusTrap(plyr.isFullscreen);
+ /**
+ * Get current loop state
+ */
+ get loop() {
+ return Boolean(this.media.loop);
+ }
- // Set button state
- if (plyr.buttons && plyr.buttons.fullscreen) {
- _toggleState(plyr.buttons.fullscreen, plyr.isFullscreen);
- }
+ /**
+ * Set new media source
+ * @param {object} input - The new source object (see docs)
+ */
+ set source(input) {
+ source.change.call(this, input);
+ }
- // Trigger an event
- _triggerEvent(plyr.container, plyr.isFullscreen ? 'enterfullscreen' : 'exitfullscreen', true);
+ /**
+ * Get current source
+ */
+ get source() {
+ return this.media.currentSrc;
+ }
- // Restore scroll position
- if (!plyr.isFullscreen && nativeSupport) {
- _restoreScrollPosition();
- }
+ /**
+ * Set the poster image for a HTML5 video
+ * @param {input} - the URL for the new poster image
+ */
+ set poster(input) {
+ if (!this.isHTML5 || !this.isVideo) {
+ this.debug.warn('Poster can only be set on HTML5 video');
+ return;
}
- // Mute
- function _toggleMute(muted) {
- // If the method is called without parameter, toggle based on current value
- if (!_is.boolean(muted)) {
- muted = !plyr.media.muted;
- }
-
- // Set button state
- _toggleState(plyr.buttons.mute, muted);
-
- // Set mute on the player
- plyr.media.muted = muted;
-
- // If volume is 0 after unmuting, set to default
- if (plyr.media.volume === 0) {
- _setVolume(config.volume);
- }
-
- // Embeds
- if (_inArray(config.types.embed, plyr.type)) {
- // YouTube
- switch (plyr.type) {
- case 'youtube':
- plyr.embed[plyr.media.muted ? 'mute' : 'unMute']();
- break;
-
- case 'vimeo':
- case 'soundcloud':
- plyr.embed.setVolume(plyr.media.muted ? 0 : parseFloat(config.volume / config.volumeMax));
- break;
- }
-
- // Trigger volumechange for embeds
- _triggerEvent(plyr.media, 'volumechange');
- }
+ if (utils.is.string(input)) {
+ this.media.setAttribute('poster', input);
}
+ }
- // Set volume
- function _setVolume(volume) {
- var max = config.volumeMax,
- min = config.volumeMin;
-
- // Load volume from storage if no value specified
- if (_is.undefined(volume)) {
- volume = plyr.storage.volume;
- }
-
- // Use config if all else fails
- if (volume === null || isNaN(volume)) {
- volume = config.volume;
- }
-
- // Maximum is volumeMax
- if (volume > max) {
- volume = max;
- }
- // Minimum is volumeMin
- if (volume < min) {
- volume = min;
- }
-
- // Set the player volume
- plyr.media.volume = parseFloat(volume / max);
-
- // Set the display
- if (plyr.volume.display) {
- plyr.volume.display.value = volume;
- }
-
- // Embeds
- if (_inArray(config.types.embed, plyr.type)) {
- switch (plyr.type) {
- case 'youtube':
- plyr.embed.setVolume(plyr.media.volume * 100);
- break;
-
- case 'vimeo':
- case 'soundcloud':
- plyr.embed.setVolume(plyr.media.volume);
- break;
- }
-
- // Trigger volumechange for embeds
- _triggerEvent(plyr.media, 'volumechange');
- }
-
- // Toggle muted state
- if (volume === 0) {
- plyr.media.muted = true;
- } else if (plyr.media.muted && volume > 0) {
- _toggleMute();
- }
+ /**
+ * Get the current poster image
+ */
+ get poster() {
+ if (!this.isHTML5 || !this.isVideo) {
+ return null;
}
- // Increase volume
- function _increaseVolume(step) {
- var volume = plyr.media.muted ? 0 : plyr.media.volume * config.volumeMax;
-
- if (!_is.number(step)) {
- step = config.volumeStep;
- }
-
- _setVolume(volume + step);
- }
+ return this.media.getAttribute('poster');
+ }
- // Decrease volume
- function _decreaseVolume(step) {
- var volume = plyr.media.muted ? 0 : plyr.media.volume * config.volumeMax;
+ /**
+ * Set the autoplay state
+ * @param {boolean} input - Whether to autoplay or not
+ */
+ set autoplay(input) {
+ const toggle = utils.is.boolean(input) ? input : this.config.autoplay;
+ this.config.autoplay = toggle;
+ }
- if (!_is.number(step)) {
- step = config.volumeStep;
- }
+ /**
+ * Get the current autoplay state
+ */
+ get autoplay() {
+ return Boolean(this.config.autoplay);
+ }
- _setVolume(volume - step);
+ /**
+ * Toggle captions
+ * @param {boolean} input - Whether to enable captions
+ */
+ toggleCaptions(input) {
+ // If there's no full support, or there's no caption toggle
+ if (!this.supported.ui || !utils.is.element(this.elements.buttons.captions)) {
+ return;
}
- // Update volume UI and storage
- function _updateVolume() {
- // Get the current volume
- var volume = plyr.media.muted ? 0 : plyr.media.volume * config.volumeMax;
-
- // Update the <input type="range"> if present
- if (plyr.supported.full) {
- if (plyr.volume.input) {
- plyr.volume.input.value = volume;
- }
- if (plyr.volume.display) {
- plyr.volume.display.value = volume;
- }
- }
-
- // Update the volume in storage
- _updateStorage({ volume: volume });
-
- // Toggle class if muted
- _toggleClass(plyr.container, config.classes.muted, volume === 0);
+ // If the method is called without parameter, toggle based on current value
+ const show = utils.is.boolean(input) ? input : this.elements.container.className.indexOf(this.config.classNames.captions.active) === -1;
- // Update checkbox for mute state
- if (plyr.supported.full && plyr.buttons.mute) {
- _toggleState(plyr.buttons.mute, volume === 0);
- }
+ // Nothing to change...
+ if (this.captions.active === show) {
+ return;
}
- // Toggle captions
- function _toggleCaptions(show) {
- // If there's no full support, or there's no caption toggle
- if (!plyr.supported.full || !plyr.buttons.captions) {
- return;
- }
-
- // If the method is called without parameter, toggle based on current value
- if (!_is.boolean(show)) {
- show = plyr.container.className.indexOf(config.classes.captions.active) === -1;
- }
-
- // Set global
- plyr.captionsEnabled = show;
+ // Set global
+ this.captions.active = show;
- // Toggle state
- _toggleState(plyr.buttons.captions, plyr.captionsEnabled);
+ // Toggle state
+ utils.toggleState(this.elements.buttons.captions, this.captions.active);
- // Add class hook
- _toggleClass(plyr.container, config.classes.captions.active, plyr.captionsEnabled);
+ // Add class hook
+ utils.toggleClass(this.elements.container, this.config.classNames.captions.active, this.captions.active);
- // Trigger an event
- _triggerEvent(plyr.container, plyr.captionsEnabled ? 'captionsenabled' : 'captionsdisabled', true);
+ // Trigger an event
+ utils.dispatchEvent.call(this, this.media, this.captions.active ? 'captionsenabled' : 'captionsdisabled');
+ }
- // Save captions state to localStorage
- _updateStorage({ captionsEnabled: plyr.captionsEnabled });
+ /**
+ * Set the captions language
+ * @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc)
+ */
+ set language(input) {
+ // Nothing specified
+ if (!utils.is.string(input)) {
+ return;
}
- // Check if media is loading
- function _checkLoading(event) {
- var loading = event.type === 'waiting';
+ // Toggle captions based on input
+ this.toggleCaptions(!utils.is.empty(input));
- // Clear timer
- clearTimeout(timers.loading);
-
- // Timer to prevent flicker when seeking
- timers.loading = setTimeout(function() {
- // Toggle container class hook
- _toggleClass(plyr.container, config.classes.loading, loading);
-
- // Show controls if loading, hide if done
- _toggleControls(loading);
- }, loading ? 250 : 0);
+ // If empty string is passed, assume disable captions
+ if (utils.is.empty(input)) {
+ return;
}
- // Update <progress> elements
- function _updateProgress(event) {
- if (!plyr.supported.full) {
- return;
- }
-
- var progress = plyr.progress.played,
- value = 0,
- duration = _getDuration();
-
- if (event) {
- switch (event.type) {
- // Video playing
- case 'timeupdate':
- case 'seeking':
- if (plyr.controls.pressed) {
- return;
- }
-
- value = _getPercentage(plyr.media.currentTime, duration);
-
- // Set seek range value only if it's a 'natural' time event
- if (event.type === 'timeupdate' && plyr.buttons.seek) {
- plyr.buttons.seek.value = value;
- }
-
- break;
+ // Normalize
+ const language = input.toLowerCase();
- // Check buffer status
- case 'playing':
- case 'progress':
- progress = plyr.progress.buffer;
- value = (function() {
- var buffered = plyr.media.buffered;
-
- if (buffered && buffered.length) {
- // HTML5
- return _getPercentage(buffered.end(0), duration);
- } else if (_is.number(buffered)) {
- // YouTube returns between 0 and 1
- return buffered * 100;
- }
-
- return 0;
- })();
-
- break;
- }
- }
-
- // Set values
- _setProgress(progress, value);
+ // If nothing to change, bail
+ if (this.language === language) {
+ return;
}
- // Set <progress> value
- function _setProgress(progress, value) {
- if (!plyr.supported.full) {
- return;
- }
+ // Update config
+ this.captions.language = language;
- // Default to 0
- if (_is.undefined(value)) {
- value = 0;
- }
- // Default to buffer or bail
- if (_is.undefined(progress)) {
- if (plyr.progress && plyr.progress.buffer) {
- progress = plyr.progress.buffer;
- } else {
- return;
- }
- }
+ // Clear caption
+ captions.setText.call(this, null);
- // One progress element passed
- if (_is.htmlElement(progress)) {
- progress.value = value;
- } else if (progress) {
- // Object of progress + text element
- if (progress.bar) {
- progress.bar.value = value;
- }
- if (progress.text) {
- progress.text.innerHTML = value;
- }
- }
- }
+ // Update captions
+ captions.setLanguage.call(this);
- // Update the displayed time
- function _updateTimeDisplay(time, element) {
- // Bail if there's no duration display
- if (!element) {
- return;
- }
-
- // Fallback to 0
- if (isNaN(time)) {
- time = 0;
- }
-
- plyr.secs = parseInt(time % 60);
- plyr.mins = parseInt((time / 60) % 60);
- plyr.hours = parseInt((time / 60 / 60) % 60);
+ // Trigger an event
+ utils.dispatchEvent.call(this, this.media, 'languagechange');
+ }
- // Do we need to display hours?
- var displayHours = parseInt((_getDuration() / 60 / 60) % 60) > 0;
+ /**
+ * Get the current captions language
+ */
+ get language() {
+ return this.captions.language;
+ }
- // Ensure it's two digits. For example, 03 rather than 3.
- plyr.secs = ('0' + plyr.secs).slice(-2);
- plyr.mins = ('0' + plyr.mins).slice(-2);
+ /**
+ * Toggle picture-in-picture playback on WebKit/MacOS
+ * TODO: update player with state, support, enabled
+ * TODO: detect outside changes
+ */
+ set pip(input) {
+ const states = {
+ pip: 'picture-in-picture',
+ inline: 'inline',
+ };
- // Render
- element.innerHTML = (displayHours ? plyr.hours + ':' : '') + plyr.mins + ':' + plyr.secs;
+ // Bail if no support
+ if (!support.pip) {
+ return;
}
- // Show the duration on metadataloaded
- function _displayDuration() {
- if (!plyr.supported.full) {
- return;
- }
+ // Toggle based on current state if not passed
+ const toggle = utils.is.boolean(input) ? input : this.pip === states.inline;
- // Determine duration
- var duration = _getDuration() || 0;
-
- // If there's only one time display, display duration there
- if (!plyr.duration && config.displayDuration && plyr.media.paused) {
- _updateTimeDisplay(duration, plyr.currentTime);
- }
-
- // If there's a duration element, update content
- if (plyr.duration) {
- _updateTimeDisplay(duration, plyr.duration);
- }
+ // Toggle based on current state
+ this.media.webkitSetPresentationMode(toggle ? states.pip : states.inline);
+ }
- // Update the tooltip (if visible)
- _updateSeekTooltip();
+ /**
+ * Get the current picture-in-picture state
+ */
+ get pip() {
+ if (!support.pip) {
+ return null;
}
- // Handle time change event
- function _timeUpdate(event) {
- // Duration
- _updateTimeDisplay(plyr.media.currentTime, plyr.currentTime);
-
- // Ignore updates while seeking
- if (event && event.type === 'timeupdate' && plyr.media.seeking) {
- return;
- }
+ return this.media.webkitPresentationMode;
+ }
- // Playing progress
- _updateProgress(event);
+ /**
+ * Trigger the airplay dialog
+ * TODO: update player with state, support, enabled
+ */
+ airplay() {
+ // Show dialog if supported
+ if (support.airplay) {
+ this.media.webkitShowPlaybackTargetPicker();
}
+ }
- // Update seek range and progress
- function _updateSeekDisplay(time) {
- // Default to 0
- if (!_is.number(time)) {
- time = 0;
- }
-
- var duration = _getDuration(),
- value = _getPercentage(time, duration);
-
- // Update progress
- if (plyr.progress && plyr.progress.played) {
- plyr.progress.played.value = value;
- }
-
- // Update seek range input
- if (plyr.buttons && plyr.buttons.seek) {
- plyr.buttons.seek.value = value;
- }
+ /**
+ * Toggle the player controls
+ * @param {boolean} toggle - Whether to show the controls
+ */
+ toggleControls(toggle) {
+ // We need controls of course...
+ if (!utils.is.element(this.elements.controls)) {
+ return;
}
- // Update hover tooltip for seeking
- function _updateSeekTooltip(event) {
- var duration = _getDuration();
-
- // Bail if setting not true
- if (!config.tooltips.seek || !plyr.progress.container || duration === 0) {
- return;
- }
-
- // Calculate percentage
- var clientRect = plyr.progress.container.getBoundingClientRect(),
- percent = 0,
- visible = config.classes.tooltip + '--visible';
-
- // Determine percentage, if already visible
- if (!event) {
- if (_hasClass(plyr.progress.tooltip, visible)) {
- percent = plyr.progress.tooltip.style.left.replace('%', '');
- } else {
- return;
- }
- } else {
- percent = 100 / clientRect.width * (event.pageX - clientRect.left);
- }
-
- // Set bounds
- if (percent < 0) {
- percent = 0;
- } else if (percent > 100) {
- percent = 100;
- }
-
- // Display the time a click would seek to
- _updateTimeDisplay(duration / 100 * percent, plyr.progress.tooltip);
-
- // Set position
- plyr.progress.tooltip.style.left = percent + '%';
-
- // Show/hide the tooltip
- // If the event is a moues in/out and percentage is inside bounds
- if (event && _inArray(['mouseenter', 'mouseleave'], event.type)) {
- _toggleClass(plyr.progress.tooltip, visible, event.type === 'mouseenter');
- }
+ // Don't hide if no UI support or it's audio
+ if (!this.supported.ui || this.isAudio) {
+ return;
}
- // Show the player controls in fullscreen mode
- function _toggleControls(toggle) {
- // Don't hide if config says not to, it's audio, or not ready or loading
- if (!config.hideControls || plyr.type === 'audio') {
- return;
- }
-
- var delay = 0,
- isEnterFullscreen = false,
- show = toggle,
- loading = _hasClass(plyr.container, config.classes.loading);
-
- // Default to false if no boolean
- if (!_is.boolean(toggle)) {
- if (toggle && toggle.type) {
- // Is the enter fullscreen event
- isEnterFullscreen = toggle.type === 'enterfullscreen';
-
- // Whether to show controls
- show = _inArray(['mousemove', 'touchstart', 'mouseenter', 'focus'], toggle.type);
-
- // Delay hiding on move events
- if (_inArray(['mousemove', 'touchmove'], toggle.type)) {
- delay = 2000;
- }
-
- // Delay a little more for keyboard users
- if (toggle.type === 'focus') {
- delay = 3000;
- }
- } else {
- show = _hasClass(plyr.container, config.classes.hideControls);
- }
- }
-
- // Clear timer every movement
- window.clearTimeout(timers.hover);
-
- // If the mouse is not over the controls, set a timeout to hide them
- if (show || plyr.media.paused || loading) {
- _toggleClass(plyr.container, config.classes.hideControls, false);
-
- // Always show controls when paused or if touch
- if (plyr.media.paused || loading) {
- return;
- }
-
- // Delay for hiding on touch
- if (plyr.browser.isTouch) {
+ let delay = 0;
+ let show = toggle;
+ let isEnterFullscreen = false;
+
+ // Get toggle state if not set
+ if (!utils.is.boolean(toggle)) {
+ if (utils.is.event(toggle)) {
+ // Is the enter fullscreen event
+ isEnterFullscreen = toggle.type === 'enterfullscreen';
+
+ // Whether to show controls
+ show = [
+ 'mouseenter',
+ 'mousemove',
+ 'touchstart',
+ 'touchmove',
+ 'focusin',
+ ].includes(toggle.type);
+
+ // Delay hiding on move events
+ if ([
+ 'mousemove',
+ 'touchmove',
+ 'touchend',
+ ].includes(toggle.type)) {
+ delay = 2000;
+ }
+
+ // Delay a little more for keyboard users
+ if (toggle.type === 'focusin') {
delay = 3000;
+ utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, true);
}
- }
-
- // If toggle is false or if we're playing (regardless of toggle),
- // then set the timer to hide the controls
- if (!show || !plyr.media.paused) {
- timers.hover = window.setTimeout(function() {
- // If the mouse is over the controls (and not entering fullscreen), bail
- if ((plyr.controls.pressed || plyr.controls.hover) && !isEnterFullscreen) {
- return;
- }
-
- _toggleClass(plyr.container, config.classes.hideControls, true);
- }, delay);
+ } else {
+ show = utils.hasClass(this.elements.container, this.config.classNames.hideControls);
}
}
- // Add common function to retrieve media source
- function _source(source) {
- // If not null or undefined, parse it
- if (!_is.undefined(source)) {
- _updateSource(source);
- return;
- }
+ // Clear timer on every call
+ clearTimeout(this.timers.controls);
- // Return the current source
- var url;
- switch (plyr.type) {
- case 'youtube':
- url = plyr.embed.getVideoUrl();
- break;
-
- case 'vimeo':
- plyr.embed.getVideoUrl.then(function(value) {
- url = value;
- });
- break;
-
- case 'soundcloud':
- plyr.embed.getCurrentSound(function(object) {
- url = object.permalink_url;
- });
- break;
-
- default:
- url = plyr.media.currentSrc;
- break;
- }
+ // If the mouse is not over the controls, set a timeout to hide them
+ if (show || this.paused || this.loading) {
+ // Check if controls toggled
+ const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, false);
- return url || '';
- }
-
- // Update source
- // Sources are not checked for support so be careful
- function _updateSource(source) {
- if (!_is.object(source) || !('sources' in source) || !source.sources.length) {
- _warn('Invalid source format');
- return;
+ // Trigger event
+ if (toggled) {
+ utils.dispatchEvent.call(this, this.media, 'controlsshown');
}
- // Remove ready class hook
- _toggleClass(plyr.container, config.classes.ready, false);
-
- // Pause playback
- _pause();
-
- // Update seek range and progress
- _updateSeekDisplay();
-
- // Reset buffer progress
- _setProgress();
-
- // Cancel current network requests
- _cancelRequests();
-
- // Setup new source
- function setup() {
- // Remove embed object
- plyr.embed = null;
-
- // Remove the old media
- _remove(plyr.media);
-
- // Remove video container
- if (plyr.type === 'video' && plyr.videoContainer) {
- _remove(plyr.videoContainer);
- }
-
- // Reset class name
- if (plyr.container) {
- plyr.container.removeAttribute('class');
- }
-
- // Set the type
- if ('type' in source) {
- plyr.type = source.type;
-
- // Get child type for video (it might be an embed)
- if (plyr.type === 'video') {
- var firstSource = source.sources[0];
-
- if ('type' in firstSource && _inArray(config.types.embed, firstSource.type)) {
- plyr.type = firstSource.type;
- }
- }
- }
-
- // Check for support
- plyr.supported = supported(plyr.type);
-
- // Create new markup
- switch (plyr.type) {
- case 'video':
- plyr.media = document.createElement('video');
- break;
-
- case 'audio':
- plyr.media = document.createElement('audio');
- break;
-
- case 'youtube':
- case 'vimeo':
- case 'soundcloud':
- plyr.media = document.createElement('div');
- plyr.embedId = source.sources[0].src;
- break;
- }
-
- // Inject the new element
- _prependChild(plyr.container, plyr.media);
-
- // Autoplay the new source?
- if (_is.boolean(source.autoplay)) {
- config.autoplay = source.autoplay;
- }
-
- // Set attributes for audio and video
- if (_inArray(config.types.html5, plyr.type)) {
- if (config.crossorigin) {
- plyr.media.setAttribute('crossorigin', '');
- }
- if (config.autoplay) {
- plyr.media.setAttribute('autoplay', '');
- }
- if ('poster' in source) {
- plyr.media.setAttribute('poster', source.poster);
- }
- if (config.loop) {
- plyr.media.setAttribute('loop', '');
- }
- }
-
- // Restore class hooks
- _toggleClass(plyr.container, config.classes.fullscreen.active, plyr.isFullscreen);
- _toggleClass(plyr.container, config.classes.captions.active, plyr.captionsEnabled);
- _toggleStyleHook();
-
- // Set new sources for html5
- if (_inArray(config.types.html5, plyr.type)) {
- _insertChildElements('source', source.sources);
- }
-
- // Set up from scratch
- _setupMedia();
-
- // HTML5 stuff
- if (_inArray(config.types.html5, plyr.type)) {
- // Setup captions
- if ('tracks' in source) {
- _insertChildElements('track', source.tracks);
- }
-
- // Load HTML5 sources
- plyr.media.load();
- }
-
- // If HTML5 or embed but not fully supported, setupInterface and call ready now
- if (_inArray(config.types.html5, plyr.type) || (_inArray(config.types.embed, plyr.type) && !plyr.supported.full)) {
- // Setup interface
- _setupInterface();
-
- // Call ready
- _ready();
- }
-
- // Set aria title and iframe title
- config.title = source.title;
- _setTitle();
+ // Always show controls when paused or if touch
+ if (this.paused || this.loading) {
+ return;
}
- // Destroy instance adn wait for callback
- // Vimeo throws a wobbly if you don't wait
- _destroy(setup, false);
- }
-
- // Update poster
- function _updatePoster(source) {
- if (plyr.type === 'video') {
- plyr.media.setAttribute('poster', source);
+ // Delay for hiding on touch
+ if (support.touch) {
+ delay = 3000;
}
}
- function onBodyClick() {
- _toggleClass(_getElement('.' + config.classes.tabFocus), config.classes.tabFocus, false);
- }
-
- // Listen for control events
- function _controlListeners() {
- // IE doesn't support input event, so we fallback to change
- var inputEvent = plyr.browser.isIE ? 'change' : 'input';
-
- // Click play/pause helper
- function togglePlay() {
- var play = _togglePlay();
-
- // Determine which buttons
- var trigger = plyr.buttons[play ? 'play' : 'pause'],
- target = plyr.buttons[play ? 'pause' : 'play'];
-
- // Get the last play button to account for the large play button
- if (target) {
- if (target.length > 1) {
- target = target[target.length - 1];
- } else {
- target = target[0];
- }
- }
-
- // Setup focus and tab focus
- if (target) {
- var hadTabFocus = _hasClass(trigger, config.classes.tabFocus);
-
- setTimeout(function() {
- target.focus();
-
- if (hadTabFocus) {
- _toggleClass(trigger, config.classes.tabFocus, false);
- _toggleClass(target, config.classes.tabFocus, true);
- }
- }, 100);
- }
- }
-
- // Get the focused element
- function getFocusElement() {
- var focused = document.activeElement;
-
- if (!focused || focused === document.body) {
- focused = null;
- } else {
- focused = document.querySelector(':focus');
- }
-
- return focused;
- }
-
- // Get the key code for an event
- function getKeyCode(event) {
- return event.keyCode ? event.keyCode : event.which;
- }
-
- // Detect tab focus
- function checkTabFocus(focused) {
- for (var button in plyr.buttons) {
- var element = plyr.buttons[button];
-
- if (_is.nodeList(element)) {
- for (var i = 0; i < element.length; i++) {
- _toggleClass(element[i], config.classes.tabFocus, element[i] === focused);
- }
- } else {
- _toggleClass(element, config.classes.tabFocus, element === focused);
- }
- }
- }
-
- // Keyboard shortcuts
- if (config.keyboardShorcuts.focused) {
- var last = null;
-
- // Handle global presses
- if (config.keyboardShorcuts.global) {
- _on(window, 'keydown keyup', function(event) {
- var code = getKeyCode(event),
- focused = getFocusElement(),
- allowed = [48, 49, 50, 51, 52, 53, 54, 56, 57, 75, 77, 70, 67],
- count = get().length;
-
- // Only handle global key press if there's only one player
- // and the key is in the allowed keys
- // and if the focused element is not editable (e.g. text input)
- // and any that accept key input http://webaim.org/techniques/keyboard/
- if (count === 1 && _inArray(allowed, code) && (!_is.htmlElement(focused) || !_matches(focused, config.selectors.editable))) {
- handleKey(event);
- }
- });
- }
-
- // Handle presses on focused
- _on(plyr.container, 'keydown keyup', handleKey);
- }
-
- function handleKey(event) {
- var code = getKeyCode(event),
- pressed = event.type === 'keydown',
- held = pressed && code === last;
-
- // If the event is bubbled from the media element
- // Firefox doesn't get the keycode for whatever reason
- if (!_is.number(code)) {
+ // If toggle is false or if we're playing (regardless of toggle),
+ // then set the timer to hide the controls
+ if (!show || this.playing) {
+ this.timers.controls = setTimeout(() => {
+ // If the mouse is over the controls (and not entering fullscreen), bail
+ if ((this.elements.controls.pressed || this.elements.controls.hover) && !isEnterFullscreen) {
return;
}
- // Seek by the number keys
- function seekByKey() {
- // Get current duration
- var duration = plyr.media.duration;
-
- // Bail if we have no duration set
- if (!_is.number(duration)) {
- return;
- }
-
- // Divide the max duration into 10th's and times by the number value
- _seek(duration / 10 * (code - 48));
- }
-
- // Handle the key on keydown
- // Reset on keyup
- if (pressed) {
- // Which keycodes should we prevent default
- var preventDefault = [48, 49, 50, 51, 52, 53, 54, 56, 57, 32, 75, 38, 40, 77, 39, 37, 70, 67];
-
- // If the code is found prevent default (e.g. prevent scrolling for arrows)
- if (_inArray(preventDefault, code)) {
- event.preventDefault();
- event.stopPropagation();
- }
-
- switch (code) {
- // 0-9
- case 48:
- case 49:
- case 50:
- case 51:
- case 52:
- case 53:
- case 54:
- case 55:
- case 56:
- case 57:
- if (!held) {
- seekByKey();
- }
- break;
- // Space and K key
- case 32:
- case 75:
- if (!held) {
- _togglePlay();
- }
- break;
- // Arrow up
- case 38:
- _increaseVolume();
- break;
- // Arrow down
- case 40:
- _decreaseVolume();
- break;
- // M key
- case 77:
- if (!held) {
- _toggleMute();
- }
- break;
- // Arrow forward
- case 39:
- _forward();
- break;
- // Arrow back
- case 37:
- _rewind();
- break;
- // F key
- case 70:
- _toggleFullscreen();
- break;
- // C key
- case 67:
- if (!held) {
- _toggleCaptions();
- }
- break;
- }
-
- // Escape is handle natively when in full screen
- // So we only need to worry about non native
- if (!fullscreen.supportsFullScreen && plyr.isFullscreen && code === 27) {
- _toggleFullscreen();
- }
-
- // Store last code for next cycle
- last = code;
- } else {
- last = null;
- }
- }
-
- // Focus/tab management
- _on(window, 'keyup', function(event) {
- var code = getKeyCode(event),
- focused = getFocusElement();
-
- if (code === 9) {
- checkTabFocus(focused);
- }
- });
- _on(document.body, 'click', onBodyClick);
- for (var button in plyr.buttons) {
- var element = plyr.buttons[button];
-
- _on(element, 'blur', function() {
- _toggleClass(element, 'tab-focus', false);
- });
- }
-
- // Play
- _proxyListener(plyr.buttons.play, 'click', config.listeners.play, togglePlay);
-
- // Pause
- _proxyListener(plyr.buttons.pause, 'click', config.listeners.pause, togglePlay);
-
- // Restart
- _proxyListener(plyr.buttons.restart, 'click', config.listeners.restart, _seek);
-
- // Rewind
- _proxyListener(plyr.buttons.rewind, 'click', config.listeners.rewind, _rewind);
-
- // Fast forward
- _proxyListener(plyr.buttons.forward, 'click', config.listeners.forward, _forward);
-
- // Seek
- _proxyListener(plyr.buttons.seek, inputEvent, config.listeners.seek, _seek);
-
- // Set volume
- _proxyListener(plyr.volume.input, inputEvent, config.listeners.volume, function() {
- _setVolume(plyr.volume.input.value);
- });
-
- // Mute
- _proxyListener(plyr.buttons.mute, 'click', config.listeners.mute, _toggleMute);
-
- // Fullscreen
- _proxyListener(plyr.buttons.fullscreen, 'click', config.listeners.fullscreen, _toggleFullscreen);
-
- // Toggle fullscreen when user double clicks on video wrapper
- _proxyListener(plyr.container, 'dblclick', config.listeners.fullscreen, _toggleFullscreen);
-
- // Handle user exiting fullscreen by escaping etc
- if (fullscreen.supportsFullScreen) {
- _on(document, fullscreen.fullScreenEventName, _toggleFullscreen);
- }
-
- // Captions
- _proxyListener(plyr.buttons.captions, 'click', config.listeners.captions, _toggleCaptions);
-
- // Seek tooltip
- _on(plyr.progress.container, 'mouseenter mouseleave mousemove', _updateSeekTooltip);
-
- // Toggle controls visibility based on mouse movement
- if (config.hideControls) {
- // Toggle controls on mouse events and entering fullscreen
- _on(plyr.container, 'mouseenter mouseleave mousemove touchstart touchend touchcancel touchmove enterfullscreen', _toggleControls);
-
- // Watch for cursor over controls so they don't hide when trying to interact
- _on(plyr.controls, 'mouseenter mouseleave', function(event) {
- plyr.controls.hover = event.type === 'mouseenter';
- });
-
- // Watch for cursor over controls so they don't hide when trying to interact
- _on(plyr.controls, 'mousedown mouseup touchstart touchend touchcancel', function(event) {
- plyr.controls.pressed = _inArray(['mousedown', 'touchstart'], event.type);
- });
-
- // Focus in/out on controls
- _on(plyr.controls, 'focus blur', _toggleControls, true);
- }
-
- // Adjust volume on scroll
- _on(plyr.volume.input, 'wheel', function(event) {
- event.preventDefault();
-
- // Detect "natural" scroll - suppored on OS X Safari only
- // Other browsers on OS X will be inverted until support improves
- var inverted = event.webkitDirectionInvertedFromDevice,
- step = config.volumeStep / 5;
-
- // Scroll down (or up on natural) to decrease
- if (event.deltaY < 0 || event.deltaX > 0) {
- if (inverted) {
- _decreaseVolume(step);
- } else {
- _increaseVolume(step);
- }
- }
-
- // Scroll up (or down on natural) to increase
- if (event.deltaY > 0 || event.deltaX < 0) {
- if (inverted) {
- _increaseVolume(step);
- } else {
- _decreaseVolume(step);
- }
+ // Restore transition behaviour
+ if (!utils.hasClass(this.elements.container, this.config.classNames.hideControls)) {
+ utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, false);
}
- });
- }
-
- // Listen for media events
- function _mediaListeners() {
- // Time change on media
- _on(plyr.media, 'timeupdate seeking', _timeUpdate);
- // Update manual captions
- _on(plyr.media, 'timeupdate', _seekManualCaptions);
+ // Check if controls toggled
+ const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, true);
- // Display duration
- _on(plyr.media, 'durationchange loadedmetadata', _displayDuration);
+ // Trigger event and close menu
+ if (toggled) {
+ utils.dispatchEvent.call(this, this.media, 'controlshidden');
- // Handle the media finishing
- _on(plyr.media, 'ended', function() {
- // Show poster on end
- if (plyr.type === 'video' && config.showPosterOnEnd) {
- // Clear
- if (plyr.type === 'video') {
- _setCaption();
+ if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) {
+ controls.toggleMenu.call(this, false);
}
-
- // Restart
- _seek();
-
- // Re-load media
- plyr.media.load();
}
- });
-
- // Check for buffer progress
- _on(plyr.media, 'progress playing', _updateProgress);
-
- // Handle native mute
- _on(plyr.media, 'volumechange', _updateVolume);
-
- // Handle native play/pause
- _on(plyr.media, 'play pause ended', _checkPlaying);
-
- // Loading
- _on(plyr.media, 'waiting canplay seeked', _checkLoading);
-
- // Click video
- if (config.clickToPlay && plyr.type !== 'audio') {
- // Re-fetch the wrapper
- var wrapper = _getElement('.' + config.classes.videoWrapper);
-
- // Bail if there's no wrapper (this should never happen)
- if (!wrapper) {
- return;
- }
-
- // Set cursor
- wrapper.style.cursor = 'pointer';
-
- // On click play, pause ore restart
- _on(wrapper, 'click', function() {
- // Touch devices will just show controls (if we're hiding controls)
- if (config.hideControls && plyr.browser.isTouch && !plyr.media.paused) {
- return;
- }
-
- if (plyr.media.paused) {
- _play();
- } else if (plyr.media.ended) {
- _seek();
- _play();
- } else {
- _pause();
- }
- });
- }
-
- // Disable right click
- if (config.disableContextMenu) {
- _on(plyr.media, 'contextmenu', function(event) {
- event.preventDefault();
- });
- }
-
- // Proxy events to container
- // Bubble up key events for Edge
- _on(plyr.media, config.events.concat(['keyup', 'keydown']).join(' '), function(event) {
- _triggerEvent(plyr.container, event.type, true);
- });
+ }, delay);
}
+ }
- // Cancel current network requests
- // See https://github.com/sampotts/plyr/issues/174
- function _cancelRequests() {
- if (!_inArray(config.types.html5, plyr.type)) {
- return;
- }
-
- // Remove child sources
- var sources = plyr.media.querySelectorAll('source');
- for (var i = 0; i < sources.length; i++) {
- _remove(sources[i]);
- }
-
- // Set blank video src attribute
- // This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error
- // Info: http://stackoverflow.com/questions/32231579/how-to-properly-dispose-of-an-html5-video-and-close-socket-or-connection
- plyr.media.setAttribute('src', config.blankUrl);
+ /**
+ * Add event listeners
+ * @param {string} event - Event type
+ * @param {function} callback - Callback for when event occurs
+ */
+ on(event, callback) {
+ utils.on(this.elements.container, event, callback);
+ }
- // Load the new empty source
- // This will cancel existing requests
- // See https://github.com/sampotts/plyr/issues/174
- plyr.media.load();
+ /**
+ * Remove event listeners
+ * @param {string} event - Event type
+ * @param {function} callback - Callback for when event occurs
+ */
+ off(event, callback) {
+ utils.off(this.elements.container, event, callback);
+ }
- // Debugging
- _log('Cancelled network requests');
+ /**
+ * Destroy an instance
+ * Event listeners are removed when elements are removed
+ * http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory
+ * @param {function} callback - Callback for when destroy is complete
+ * @param {boolean} soft - Whether it's a soft destroy (for source changes etc)
+ */
+ destroy(callback, soft = false) {
+ if (!this.ready) {
+ return;
}
- // Destroy an instance
- // Event listeners are removed when elements are removed
- // http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory
- function _destroy(callback, restore) {
- // Bail if the element is not initialized
- if (!plyr.init) {
- return null;
- }
-
- // Type specific stuff
- switch (plyr.type) {
- case 'youtube':
- // Clear timers
- window.clearInterval(timers.buffering);
- window.clearInterval(timers.playing);
-
- // Destroy YouTube API
- plyr.embed.destroy();
-
- // Clean up
- cleanUp();
-
- break;
-
- case 'vimeo':
- // Destroy Vimeo API
- // then clean up (wait, to prevent postmessage errors)
- plyr.embed.unload().then(cleanUp);
-
- // Vimeo does not always return
- timers.cleanUp = window.setTimeout(cleanUp, 200);
+ const done = () => {
+ // Reset overflow (incase destroyed while in fullscreen)
+ document.body.style.overflow = '';
- break;
+ // GC for embed
+ this.embed = null;
- case 'video':
- case 'audio':
- // Restore native video controls
- _toggleNativeControls(true);
+ // If it's a soft destroy, make minimal changes
+ if (soft) {
+ if (Object.keys(this.elements).length) {
+ // Remove elements
+ utils.removeElement(this.elements.buttons.play);
+ utils.removeElement(this.elements.captions);
+ utils.removeElement(this.elements.controls);
+ utils.removeElement(this.elements.wrapper);
- // Clean up
- cleanUp();
-
- break;
- }
-
- function cleanUp() {
- clearTimeout(timers.cleanUp);
-
- // Default to restore original element
- if (!_is.boolean(restore)) {
- restore = true;
+ // Clear for GC
+ this.elements.buttons.play = null;
+ this.elements.captions = null;
+ this.elements.controls = null;
+ this.elements.wrapper = null;
}
// Callback
- if (_is.function(callback)) {
- callback.call(original);
- }
-
- // Bail if we don't need to restore the original element
- if (!restore) {
- return;
+ if (utils.is.function(callback)) {
+ callback();
}
-
- // Remove init flag
- plyr.init = false;
+ } else {
+ // Unbind listeners
+ this.listeners.clear();
// Replace the container with the original element provided
- plyr.container.parentNode.replaceChild(original, plyr.container);
-
- // Free container in order for GC to remove it and prevent memory leaks due to added events
- plyr.container = null;
-
- // Allow overflow (set on fullscreen)
- document.body.style.overflow = '';
-
- //remove events
- _off(document.body, 'click', onBodyClick);
+ utils.replaceElement(this.elements.original, this.elements.container);
// Event
- _triggerEvent(original, 'destroyed', true);
- }
- }
-
- // Setup a player
- function _init() {
- // Bail if the element is initialized
- if (plyr.init) {
- return null;
- }
-
- // Setup the fullscreen api
- fullscreen = _fullscreen();
-
- // Sniff out the browser
- plyr.browser = _browserSniff();
-
- // Bail if nothing to setup
- if (!_is.htmlElement(plyr.media)) {
- return;
- }
-
- // Load saved settings from localStorage
- _setupStorage();
-
- // Set media type based on tag or data attribute
- // Supported: video, audio, vimeo, youtube
- var tagName = media.tagName.toLowerCase();
- if (tagName === 'div') {
- plyr.type = media.getAttribute('data-type');
- plyr.embedId = media.getAttribute('data-video-id');
-
- // Clean up
- media.removeAttribute('data-type');
- media.removeAttribute('data-video-id');
- } else {
- plyr.type = tagName;
- config.crossorigin = media.getAttribute('crossorigin') !== null;
- config.autoplay = config.autoplay || media.getAttribute('autoplay') !== null;
- config.loop = config.loop || media.getAttribute('loop') !== null;
- }
-
- // Check for support
- plyr.supported = supported(plyr.type);
-
- // If no native support, bail
- if (!plyr.supported.basic) {
- return;
- }
-
- // Wrap media
- plyr.container = _wrap(media, document.createElement('div'));
-
- // Allow focus to be captured
- plyr.container.setAttribute('tabindex', 0);
-
- // Add style hook
- _toggleStyleHook();
-
- // Debug info
- _log('' + plyr.browser.name + ' ' + plyr.browser.version);
-
- // Setup media
- _setupMedia();
-
- // Setup interface
- // If embed but not fully supported, setupInterface (to avoid flash of controls) and call ready now
- if (_inArray(config.types.html5, plyr.type) || (_inArray(config.types.embed, plyr.type) && !plyr.supported.full)) {
- // Setup UI
- _setupInterface();
-
- // Call ready
- _ready();
-
- // Set title on button and frame
- _setTitle();
- }
-
- // Successful setup
- plyr.init = true;
- }
-
- // Setup the UI
- function _setupInterface() {
- // Don't setup interface if no support
- if (!plyr.supported.full) {
- _warn('Basic support only', plyr.type);
-
- // Remove controls
- _remove(_getElement(config.selectors.controls.wrapper));
-
- // Remove large play
- _remove(_getElement(config.selectors.buttons.play));
-
- // Restore native controls
- _toggleNativeControls(true);
-
- // Bail
- return;
- }
+ utils.dispatchEvent.call(this, this.elements.original, 'destroyed', true);
- // Inject custom controls if not present
- var controlsMissing = !_getElements(config.selectors.controls.wrapper).length;
- if (controlsMissing) {
- // Inject custom controls
- _injectControls();
- }
+ // Callback
+ if (utils.is.function(callback)) {
+ callback.call(this.elements.original);
+ }
- // Find the elements
- if (!_findElements()) {
- return;
- }
+ // Reset state
+ this.ready = false;
- // If the controls are injected, re-bind listeners for controls
- if (controlsMissing) {
- _controlListeners();
+ // Clear for garbage collection
+ setTimeout(() => {
+ this.elements = null;
+ this.media = null;
+ }, 200);
}
-
- // Media element listeners
- _mediaListeners();
-
- // Remove native controls
- _toggleNativeControls();
-
- // Setup fullscreen
- _setupFullscreen();
-
- // Captions
- _setupCaptions();
-
- // Set volume
- _setVolume();
- _updateVolume();
-
- // Reset time display
- _timeUpdate();
-
- // Update the UI
- _checkPlaying();
-
- // Display duration
- _displayDuration();
- }
-
- api = {
- getOriginal: function() {
- return original;
- },
- getContainer: function() {
- return plyr.container;
- },
- getEmbed: function() {
- return plyr.embed;
- },
- getMedia: function() {
- return plyr.media;
- },
- getType: function() {
- return plyr.type;
- },
- getDuration: _getDuration,
- getCurrentTime: function() {
- return plyr.media.currentTime;
- },
- getVolume: function() {
- return plyr.media.volume;
- },
- isMuted: function() {
- return plyr.media.muted;
- },
- isReady: function() {
- return _hasClass(plyr.container, config.classes.ready);
- },
- isLoading: function() {
- return _hasClass(plyr.container, config.classes.loading);
- },
- isPaused: function() {
- return plyr.media.paused;
- },
- on: function(event, callback) {
- _on(plyr.container, event, callback);
- return this;
- },
- play: _play,
- pause: _pause,
- stop: function() {
- _pause();
- _seek();
- },
- restart: _seek,
- rewind: _rewind,
- forward: _forward,
- seek: _seek,
- source: _source,
- poster: _updatePoster,
- setVolume: _setVolume,
- togglePlay: _togglePlay,
- toggleMute: _toggleMute,
- toggleCaptions: _toggleCaptions,
- toggleFullscreen: _toggleFullscreen,
- toggleControls: _toggleControls,
- isFullscreen: function() {
- return plyr.isFullscreen || false;
- },
- support: function(mimeType) {
- return _supportMime(plyr, mimeType);
- },
- destroy: _destroy,
};
- // Everything done
- function _ready() {
- // Ready event at end of execution stack
- window.setTimeout(function() {
- _triggerEvent(plyr.media, 'ready');
- }, 0);
-
- // Set class hook on media element
- _toggleClass(plyr.media, defaults.classes.setup, true);
-
- // Set container class for ready
- _toggleClass(plyr.container, config.classes.ready, true);
-
- // Store a refernce to instance
- plyr.media.plyr = api;
+ // Stop playback
+ this.stop();
- // Autoplay
- if (config.autoplay) {
- _play();
- }
- }
+ // Type specific stuff
+ switch (`${this.provider}:${this.type}`) {
+ case 'html5:video':
+ case 'html5:audio':
+ // Clear timeout
+ clearTimeout(this.timers.loading);
- // Initialize instance
- _init();
-
- // If init failed, return null
- if (!plyr.init) {
- return null;
- }
-
- return api;
- }
-
- // Load a sprite
- function loadSprite(url, id) {
- var x = new XMLHttpRequest();
-
- // If the id is set and sprite exists, bail
- if (_is.string(id) && _is.htmlElement(document.querySelector('#' + id))) {
- return;
- }
-
- // Create placeholder (to prevent loading twice)
- var container = document.createElement('div');
- container.setAttribute('hidden', '');
- if (_is.string(id)) {
- container.setAttribute('id', id);
- }
- document.body.insertBefore(container, document.body.childNodes[0]);
-
- // Check for CORS support
- if ('withCredentials' in x) {
- x.open('GET', url, true);
- } else {
- return;
- }
+ // Restore native video controls
+ ui.toggleNativeControls.call(this, true);
- // Inject hidden div with sprite on load
- x.onload = function() {
- container.innerHTML = x.responseText;
- };
+ // Clean up
+ done();
- x.send();
- }
+ break;
- // Check for support
- function supported(type) {
- var browser = _browserSniff(),
- isOldIE = browser.isIE && browser.version <= 9,
- isIos = browser.isIos,
- isIphone = browser.isIphone,
- audioSupport = !!document.createElement('audio').canPlayType,
- videoSupport = !!document.createElement('video').canPlayType,
- basic = false,
- full = false;
+ case 'youtube:video':
+ // Clear timers
+ clearInterval(this.timers.buffering);
+ clearInterval(this.timers.playing);
- switch (type) {
- case 'video':
- basic = videoSupport;
- full = basic && (!isOldIE && !isIphone);
- break;
+ // Destroy YouTube API
+ if (this.embed !== null) {
+ this.embed.destroy();
+ }
- case 'audio':
- basic = audioSupport;
- full = basic && !isOldIE;
- break;
+ // Clean up
+ done();
- // Vimeo does not seem to be supported on iOS via API
- // Issue raised https://github.com/vimeo/player.js/issues/87
- case 'vimeo':
- basic = true;
- full = !isOldIE && !isIos;
break;
- case 'youtube':
- basic = true;
- full = !isOldIE && !isIos;
-
- // YouTube seems to work on iOS 10+ on iPad
- if (isIos && !isIphone && browser.version >= 10) {
- full = true;
+ case 'vimeo:video':
+ // Destroy Vimeo API
+ // then clean up (wait, to prevent postmessage errors)
+ if (this.embed !== null) {
+ this.embed.unload().then(done);
}
- break;
+ // Vimeo does not always return
+ setTimeout(done, 200);
- case 'soundcloud':
- basic = true;
- full = !isOldIE && !isIphone;
break;
default:
- basic = audioSupport && videoSupport;
- full = basic && !isOldIE;
+ break;
}
-
- return {
- basic: basic,
- full: full,
- };
}
- // Setup function
- function setup(targets, options) {
- // Get the players
- var players = [],
- instances = [],
- selector = [defaults.selectors.html5, defaults.selectors.embed].join(',');
-
- // Select the elements
- if (_is.string(targets)) {
- // String selector passed
- targets = document.querySelectorAll(targets);
- } else if (_is.htmlElement(targets)) {
- // Single HTMLElement passed
- targets = [targets];
- } else if (!_is.nodeList(targets) && !_is.array(targets) && !_is.string(targets)) {
- // No selector passed, possibly options as first argument
- // If options are the first argument
- if (_is.undefined(options) && _is.object(targets)) {
- options = targets;
- }
-
- // Use default selector
- targets = document.querySelectorAll(selector);
- }
-
- // Convert NodeList to array
- if (_is.nodeList(targets)) {
- targets = Array.prototype.slice.call(targets);
- }
-
- // Bail if disabled or no basic support
- // You may want to disable certain UAs etc
- if (!supported().basic || !targets.length) {
- return false;
- }
-
- // Add to container list
- function add(target, media) {
- if (!_hasClass(media, defaults.classes.hook)) {
- players.push({
- // Always wrap in a <div> for styling
- //container: _wrap(media, document.createElement('div')),
- // Could be a container or the media itself
- target: target,
- // This should be the <video>, <audio> or <div> (YouTube/Vimeo)
- media: media,
- });
- }
- }
-
- // Check if the targets have multiple media elements
- for (var i = 0; i < targets.length; i++) {
- var target = targets[i];
-
- // Get children
- var children = target.querySelectorAll(selector);
-
- // If there's more than one media element child, wrap them
- if (children.length) {
- for (var x = 0; x < children.length; x++) {
- add(target, children[x]);
- }
- } else if (_matches(target, selector)) {
- // Target is media element
- add(target, target);
- }
- }
-
- // Create a player instance for each element
- players.forEach(function(player) {
- var element = player.target,
- media = player.media,
- match = false;
-
- // The target element can also be the media element
- if (media === element) {
- match = true;
- }
-
- // Setup a player instance and add to the element
- // Create instance-specific config
- var data = {};
-
- // Try parsing data attribute config
- try {
- data = JSON.parse(element.getAttribute('data-plyr'));
- } catch (e) {}
-
- var config = _extend({}, defaults, options, data);
-
- // Bail if not enabled
- if (!config.enabled) {
- return null;
- }
-
- // Create new instance
- var instance = new Plyr(media, config);
-
- // Go to next if setup failed
- if (!_is.object(instance)) {
- return;
- }
-
- // Listen for events if debugging
- if (config.debug) {
- var events = config.events.concat(['setup', 'statechange', 'enterfullscreen', 'exitfullscreen', 'captionsenabled', 'captionsdisabled']);
-
- _on(instance.getContainer(), events.join(' '), function(event) {
- console.log([config.logPrefix, 'event:', event.type].join(' '), event.detail.plyr);
- });
- }
-
- // Callback
- _event(instance.getContainer(), 'setup', true, {
- plyr: instance,
- });
-
- // Add to return array even if it's already setup
- instances.push(instance);
- });
-
- return instances;
- }
-
- // Get all instances within a provided container
- function get(container) {
- if (_is.string(container)) {
- // Get selector if string passed
- container = document.querySelector(container);
- } else if (_is.undefined(container)) {
- // Use body by default to get all on page
- container = document.body;
- }
-
- // If we have a HTML element
- if (_is.htmlElement(container)) {
- var elements = container.querySelectorAll('.' + defaults.classes.setup),
- instances = [];
-
- Array.prototype.slice.call(elements).forEach(function(element) {
- if (_is.object(element.plyr)) {
- instances.push(element.plyr);
- }
- });
-
- return instances;
- }
-
- return [];
+ /**
+ * Check for support for a mime type (HTML5 only)
+ * @param {string} type - Mime type
+ */
+ supports(type) {
+ return support.mime.call(this, type);
}
- return {
- setup: setup,
- supported: supported,
- loadSprite: loadSprite,
- get: get,
- };
-});
-
-// Custom event polyfill
-// https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent
-(function() {
- if (typeof window.CustomEvent === 'function') {
- return;
+ /**
+ * Check for support
+ * @param {string} type - Player type (audio/video)
+ * @param {string} provider - Provider (html5/youtube/vimeo)
+ * @param {bool} inline - Where player has `playsinline` sttribute
+ */
+ static supported(type, provider, inline) {
+ return support.check(type, provider, inline);
}
- function CustomEvent(event, params) {
- params = params || { bubbles: false, cancelable: false, detail: undefined };
- var evt = document.createEvent('CustomEvent');
- evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
- return evt;
+ /**
+ * Load an SVG sprite into the page
+ * @param {string} url - URL for the SVG sprite
+ * @param {string} [id] - Unique ID
+ */
+ static loadSprite(url, id) {
+ return utils.loadSprite(url, id);
}
+}
- CustomEvent.prototype = window.Event.prototype;
-
- window.CustomEvent = CustomEvent;
-})();
+export default Plyr;
diff --git a/src/js/plyr.polyfilled.js b/src/js/plyr.polyfilled.js
new file mode 100644
index 00000000..9dfec20f
--- /dev/null
+++ b/src/js/plyr.polyfilled.js
@@ -0,0 +1,14 @@
+// ==========================================================================
+// Plyr Polyfilled Build
+// plyr.js v3.0.0-beta.20
+// https://github.com/sampotts/plyr
+// License: The MIT License (MIT)
+// ==========================================================================
+
+import 'babel-polyfill';
+
+import 'custom-event-polyfill';
+
+import Plyr from './plyr';
+
+export default Plyr;
diff --git a/src/js/source.js b/src/js/source.js
new file mode 100644
index 00000000..d252ba6b
--- /dev/null
+++ b/src/js/source.js
@@ -0,0 +1,148 @@
+// ==========================================================================
+// Plyr source update
+// ==========================================================================
+
+import { providers } from './types';
+import utils from './utils';
+import media from './media';
+import ui from './ui';
+import support from './support';
+
+const source = {
+ // Add elements to HTML5 media (source, tracks, etc)
+ insertElements(type, attributes) {
+ if (utils.is.string(attributes)) {
+ utils.insertElement(type, this.media, {
+ src: attributes,
+ });
+ } else if (utils.is.array(attributes)) {
+ attributes.forEach(attribute => {
+ utils.insertElement(type, this.media, attribute);
+ });
+ }
+ },
+
+ // Update source
+ // Sources are not checked for support so be careful
+ change(input) {
+ if (!utils.is.object(input) || !('sources' in input) || !input.sources.length) {
+ this.debug.warn('Invalid source format');
+ return;
+ }
+
+ // Cancel current network requests
+ media.cancelRequests.call(this);
+
+ // Destroy instance and re-setup
+ this.destroy.call(
+ this,
+ () => {
+ // TODO: Reset menus here
+
+ // Remove elements
+ utils.removeElement(this.media);
+ this.media = null;
+
+ // Reset class name
+ if (utils.is.element(this.elements.container)) {
+ this.elements.container.removeAttribute('class');
+ }
+
+ // Set the type and provider
+ this.type = input.type;
+ this.provider = !utils.is.empty(input.sources[0].provider) ? input.sources[0].provider : providers.html5;
+
+ // Check for support
+ this.supported = support.check(this.type, this.provider, this.config.inline);
+
+ // Create new markup
+ switch (`${this.provider}:${this.type}`) {
+ case 'html5:video':
+ this.media = utils.createElement('video');
+ break;
+
+ case 'html5:audio':
+ this.media = utils.createElement('audio');
+ break;
+
+ case 'youtube:video':
+ case 'vimeo:video':
+ this.media = utils.createElement('div', {
+ src: input.sources[0].src,
+ });
+ break;
+
+ default:
+ break;
+ }
+
+ // Inject the new element
+ this.elements.container.appendChild(this.media);
+
+ // Autoplay the new source?
+ if (utils.is.boolean(input.autoplay)) {
+ this.config.autoplay = input.autoplay;
+ }
+
+ // Set attributes for audio and video
+ if (this.isHTML5) {
+ if (this.config.crossorigin) {
+ this.media.setAttribute('crossorigin', '');
+ }
+ if (this.config.autoplay) {
+ this.media.setAttribute('autoplay', '');
+ }
+ if ('poster' in input) {
+ this.media.setAttribute('poster', input.poster);
+ }
+ if (this.config.loop.active) {
+ this.media.setAttribute('loop', '');
+ }
+ if (this.config.muted) {
+ this.media.setAttribute('muted', '');
+ }
+ if (this.config.inline) {
+ this.media.setAttribute('playsinline', '');
+ }
+ }
+
+ // Restore class hook
+ ui.addStyleHook.call(this);
+
+ // Set new sources for html5
+ if (this.isHTML5) {
+ source.insertElements.call(this, 'source', input.sources);
+ }
+
+ // Set video title
+ this.config.title = input.title;
+
+ // Set up from scratch
+ media.setup.call(this);
+
+ // HTML5 stuff
+ if (this.isHTML5) {
+ // Setup captions
+ if ('tracks' in input) {
+ source.insertElements.call(this, 'track', input.tracks);
+ }
+
+ // Load HTML5 sources
+ this.media.load();
+ }
+
+ // If HTML5 or embed but not fully supported, setupInterface and call ready now
+ if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) {
+ // Setup interface
+ ui.build.call(this);
+ }
+
+ // Update the fullscreen support
+ this.fullscreen.update();
+ },
+ true,
+ );
+ },
+};
+
+export default source;
diff --git a/src/js/storage.js b/src/js/storage.js
new file mode 100644
index 00000000..5f663ab5
--- /dev/null
+++ b/src/js/storage.js
@@ -0,0 +1,71 @@
+// ==========================================================================
+// Plyr storage
+// ==========================================================================
+
+import utils from './utils';
+
+class Storage {
+ constructor(player) {
+ this.enabled = player.config.storage.enabled;
+ this.key = player.config.storage.key;
+ }
+
+ // Check for actual support (see if we can use it)
+ static get supported() {
+ if (!('localStorage' in window)) {
+ return false;
+ }
+
+ const test = '___test';
+
+ // Try to use it (it might be disabled, e.g. user is in private mode)
+ // see: https://github.com/sampotts/plyr/issues/131
+ try {
+ window.localStorage.setItem(test, test);
+ window.localStorage.removeItem(test);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ get(key) {
+ const store = window.localStorage.getItem(this.key);
+
+ if (!Storage.supported || utils.is.empty(store)) {
+ return null;
+ }
+
+ const json = JSON.parse(store);
+
+ return utils.is.string(key) && key.length ? json[key] : json;
+ }
+
+ set(object) {
+ // Bail if we don't have localStorage support or it's disabled
+ if (!Storage.supported || !this.enabled) {
+ return;
+ }
+
+ // Can only store objectst
+ if (!utils.is.object(object)) {
+ return;
+ }
+
+ // Get current storage
+ let storage = this.get();
+
+ // Default to empty object
+ if (utils.is.empty(storage)) {
+ storage = {};
+ }
+
+ // Update the working copy of the values
+ utils.extend(storage, object);
+
+ // Update storage
+ window.localStorage.setItem(this.key, JSON.stringify(storage));
+ }
+}
+
+export default Storage;
diff --git a/src/js/support.js b/src/js/support.js
new file mode 100644
index 00000000..7f9a88b1
--- /dev/null
+++ b/src/js/support.js
@@ -0,0 +1,157 @@
+// ==========================================================================
+// Plyr support checks
+// ==========================================================================
+
+import utils from './utils';
+
+// Check for feature support
+const support = {
+ // Basic support
+ audio: 'canPlayType' in document.createElement('audio'),
+ video: 'canPlayType' in document.createElement('video'),
+
+ // Check for support
+ // Basic functionality vs full UI
+ check(type, provider, inline) {
+ let api = false;
+ let ui = false;
+ const browser = utils.getBrowser();
+ const playsInline = browser.isIPhone && inline && support.inline;
+
+ switch (`${provider}:${type}`) {
+ case 'html5:video':
+ api = support.video;
+ ui = api && support.rangeInput && (!browser.isIPhone || playsInline);
+ break;
+
+ case 'html5:audio':
+ api = support.audio;
+ ui = api && support.rangeInput;
+ break;
+
+ case 'youtube:video':
+ case 'vimeo:video':
+ api = true;
+ ui = support.rangeInput && (!browser.isIPhone || playsInline);
+ break;
+
+ default:
+ api = support.audio && support.video;
+ ui = api && support.rangeInput;
+ }
+
+ return {
+ api,
+ ui,
+ };
+ },
+
+ // Picture-in-picture support
+ // Safari only currently
+ pip: (() => {
+ const browser = utils.getBrowser();
+ return !browser.isIPhone && utils.is.function(utils.createElement('video').webkitSetPresentationMode);
+ })(),
+
+ // Airplay support
+ // Safari only currently
+ airplay: utils.is.function(window.WebKitPlaybackTargetAvailabilityEvent),
+
+ // Inline playback support
+ // https://webkit.org/blog/6784/new-video-policies-for-ios/
+ inline: 'playsInline' in document.createElement('video'),
+
+ // Check for mime type support against a player instance
+ // Credits: http://diveintohtml5.info/everything.html
+ // Related: http://www.leanbackplayer.com/test/h5mt.html
+ mime(type) {
+ const { media } = this;
+
+ try {
+ // Bail if no checking function
+ if (!this.isHTML5 || !utils.is.function(media.canPlayType)) {
+ return false;
+ }
+
+ // Type specific checks
+ if (this.isVideo) {
+ switch (type) {
+ case 'video/webm':
+ return media.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, '');
+
+ case 'video/mp4':
+ return media.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, '');
+
+ case 'video/ogg':
+ return media.canPlayType('video/ogg; codecs="theora"').replace(/no/, '');
+
+ default:
+ return false;
+ }
+ } else if (this.isAudio) {
+ switch (type) {
+ case 'audio/mpeg':
+ return media.canPlayType('audio/mpeg;').replace(/no/, '');
+
+ case 'audio/ogg':
+ return media.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, '');
+
+ case 'audio/wav':
+ return media.canPlayType('audio/wav; codecs="1"').replace(/no/, '');
+
+ default:
+ return false;
+ }
+ }
+ } catch (e) {
+ return false;
+ }
+
+ // If we got this far, we're stuffed
+ return false;
+ },
+
+ // Check for textTracks support
+ textTracks: 'textTracks' in document.createElement('video'),
+
+ // Check for passive event listener support
+ // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
+ // https://www.youtube.com/watch?v=NPM6172J22g
+ passiveListeners: (() => {
+ // Test via a getter in the options object to see if the passive property is accessed
+ let supported = false;
+ try {
+ const options = Object.defineProperty({}, 'passive', {
+ get() {
+ supported = true;
+ return null;
+ },
+ });
+ window.addEventListener('test', null, options);
+ } catch (e) {
+ // Do nothing
+ }
+
+ return supported;
+ })(),
+
+ // <input type="range"> Sliders
+ rangeInput: (() => {
+ const range = document.createElement('input');
+ range.type = 'range';
+ return range.type === 'range';
+ })(),
+
+ // Touch
+ // Remember a device can be moust + touch enabled
+ touch: 'ontouchstart' in document.documentElement,
+
+ // Detect transitions support
+ transitions: utils.transitionEndEvent !== false,
+
+ // Reduced motion iOS & MacOS setting
+ // https://webkit.org/blog/7551/responsive-design-for-motion/
+ reducedMotion: 'matchMedia' in window && window.matchMedia('(prefers-reduced-motion)').matches,
+};
+
+export default support;
diff --git a/src/js/types.js b/src/js/types.js
new file mode 100644
index 00000000..35716c3c
--- /dev/null
+++ b/src/js/types.js
@@ -0,0 +1,16 @@
+// ==========================================================================
+// Plyr supported types and providers
+// ==========================================================================
+
+export const providers = {
+ html5: 'html5',
+ youtube: 'youtube',
+ vimeo: 'vimeo',
+};
+
+export const types = {
+ audio: 'audio',
+ video: 'video',
+};
+
+export default { providers, types };
diff --git a/src/js/ui.js b/src/js/ui.js
new file mode 100644
index 00000000..a4f22413
--- /dev/null
+++ b/src/js/ui.js
@@ -0,0 +1,323 @@
+// ==========================================================================
+// Plyr UI
+// ==========================================================================
+
+import utils from './utils';
+import captions from './captions';
+import controls from './controls';
+
+const ui = {
+ addStyleHook() {
+ utils.toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);
+ utils.toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);
+ },
+
+ // Toggle native HTML5 media controls
+ toggleNativeControls(toggle = false) {
+ if (toggle && this.isHTML5) {
+ this.media.setAttribute('controls', '');
+ } else {
+ this.media.removeAttribute('controls');
+ }
+ },
+
+ // Setup the UI
+ build() {
+ // Re-attach media element listeners
+ // TODO: Use event bubbling?
+ this.listeners.media();
+
+ // Don't setup interface if no support
+ if (!this.supported.ui) {
+ this.debug.warn(`Basic support only for ${this.provider} ${this.type}`);
+
+ // Restore native controls
+ ui.toggleNativeControls.call(this, true);
+
+ // Bail
+ return;
+ }
+
+ // Inject custom controls if not present
+ if (!utils.is.element(this.elements.controls)) {
+ // Inject custom controls
+ controls.inject.call(this);
+
+ // Re-attach control listeners
+ this.listeners.controls();
+ }
+
+ // If there's no controls, bail
+ if (!utils.is.element(this.elements.controls)) {
+ return;
+ }
+
+ // Remove native controls
+ ui.toggleNativeControls.call(this);
+
+ // Captions
+ captions.setup.call(this);
+
+ // Reset volume
+ this.volume = null;
+
+ // Reset mute state
+ this.muted = null;
+
+ // Reset speed
+ this.speed = null;
+
+ // Reset loop state
+ this.loop = null;
+
+ // Reset quality options
+ this.options.quality = [];
+
+ // Reset time display
+ ui.timeUpdate.call(this);
+
+ // Update the UI
+ ui.checkPlaying.call(this);
+
+ // Ready for API calls
+ this.ready = true;
+
+ // Ready event at end of execution stack
+ setTimeout(() => {
+ utils.dispatchEvent.call(this, this.media, 'ready');
+ }, 0);
+
+ // Set the title
+ ui.setTitle.call(this);
+ },
+
+ // Setup aria attribute for play and iframe title
+ setTitle() {
+ // Find the current text
+ let label = this.config.i18n.play;
+
+ // If there's a media title set, use that for the label
+ if (utils.is.string(this.config.title) && !utils.is.empty(this.config.title)) {
+ label += `, ${this.config.title}`;
+
+ // Set container label
+ this.elements.container.setAttribute('aria-label', this.config.title);
+ }
+
+ // If there's a play button, set label
+ if (utils.is.nodeList(this.elements.buttons.play)) {
+ Array.from(this.elements.buttons.play).forEach(button => {
+ button.setAttribute('aria-label', label);
+ });
+ }
+
+ // Set iframe title
+ // https://github.com/sampotts/plyr/issues/124
+ if (this.isEmbed) {
+ const iframe = utils.getElement.call(this, 'iframe');
+
+ if (!utils.is.element(iframe)) {
+ return;
+ }
+
+ // Default to media type
+ const title = !utils.is.empty(this.config.title) ? this.config.title : 'video';
+
+ iframe.setAttribute('title', this.config.i18n.frameTitle.replace('{title}', title));
+ }
+ },
+
+ // Check playing state
+ checkPlaying() {
+ // Class hooks
+ utils.toggleClass(this.elements.container, this.config.classNames.playing, this.playing);
+ utils.toggleClass(this.elements.container, this.config.classNames.stopped, this.paused);
+
+ // Set ARIA state
+ utils.toggleState(this.elements.buttons.play, this.playing);
+
+ // Toggle controls
+ this.toggleControls(!this.playing);
+ },
+
+ // Check if media is loading
+ checkLoading(event) {
+ this.loading = [
+ 'stalled',
+ 'waiting',
+ ].includes(event.type);
+
+ // Clear timer
+ clearTimeout(this.timers.loading);
+
+ // Timer to prevent flicker when seeking
+ this.timers.loading = setTimeout(() => {
+ // Toggle container class hook
+ utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
+
+ // Show controls if loading, hide if done
+ this.toggleControls(this.loading);
+ }, this.loading ? 250 : 0);
+ },
+
+ // Check if media failed to load
+ checkFailed() {
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/networkState
+ this.failed = this.media.networkState === 3;
+
+ if (this.failed) {
+ utils.toggleClass(this.elements.container, this.config.classNames.loading, false);
+ utils.toggleClass(this.elements.container, this.config.classNames.error, true);
+ }
+
+ // Clear timer
+ clearTimeout(this.timers.failed);
+
+ // Timer to prevent flicker when seeking
+ this.timers.loading = setTimeout(() => {
+ // Toggle container class hook
+ utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
+
+ // Show controls if loading, hide if done
+ this.toggleControls(this.loading);
+ }, this.loading ? 250 : 0);
+ },
+
+ // Update volume UI and storage
+ updateVolume() {
+ if (!this.supported.ui) {
+ return;
+ }
+
+ // Update range
+ if (utils.is.element(this.elements.inputs.volume)) {
+ ui.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
+ }
+
+ // Update mute state
+ if (utils.is.element(this.elements.buttons.mute)) {
+ utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0);
+ }
+ },
+
+ // Update seek value and lower fill
+ setRange(target, value = 0) {
+ if (!utils.is.element(target)) {
+ return;
+ }
+
+ // eslint-disable-next-line
+ target.value = value;
+
+ // Webkit range fill
+ controls.updateRangeFill.call(this, target);
+ },
+
+ // Set <progress> value
+ setProgress(target, input) {
+ const value = utils.is.number(input) ? input : 0;
+ const progress = utils.is.element(target) ? target : this.elements.display.buffer;
+
+ // Update value and label
+ if (utils.is.element(progress)) {
+ progress.value = value;
+
+ // Update text label inside
+ const label = progress.getElementsByTagName('span')[0];
+ if (utils.is.element(label)) {
+ label.childNodes[0].nodeValue = value;
+ }
+ }
+ },
+
+ // Update <progress> elements
+ updateProgress(event) {
+ if (!this.supported.ui || !utils.is.event(event)) {
+ return;
+ }
+
+ let value = 0;
+
+ if (event) {
+ switch (event.type) {
+ // Video playing
+ case 'timeupdate':
+ case 'seeking':
+ value = utils.getPercentage(this.currentTime, this.duration);
+
+ // Set seek range value only if it's a 'natural' time event
+ if (event.type === 'timeupdate') {
+ ui.setRange.call(this, this.elements.inputs.seek, value);
+ }
+
+ break;
+
+ // Check buffer status
+ case 'playing':
+ case 'progress':
+ ui.setProgress.call(this, this.elements.display.buffer, this.buffered * 100);
+
+ break;
+
+ default:
+ break;
+ }
+ }
+ },
+
+ // 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 (!utils.is.element(target) || !utils.is.number(time)) {
+ return;
+ }
+
+ // Always display hours if duration is over an hour
+ const displayHours = utils.getHours(this.duration) > 0;
+
+ // eslint-disable-next-line no-param-reassign
+ target.textContent = utils.formatTime(time, displayHours, inverted);
+ },
+
+ // Handle time change event
+ timeUpdate(event) {
+ // Only invert if only one time element is displayed and used for both duration and currentTime
+ const invert = !utils.is.element(this.elements.display.duration) && this.config.invertTime;
+
+ // Duration
+ ui.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
+ ui.updateProgress.call(this, event);
+ },
+
+ // Show the duration on metadataloaded
+ durationUpdate() {
+ if (!this.supported.ui) {
+ return;
+ }
+
+ // If there's a spot to display duration
+ const hasDuration = utils.is.element(this.elements.display.duration);
+
+ // If there's only one time display, display duration there
+ if (!hasDuration && this.config.displayDuration && this.paused) {
+ ui.updateTimeDisplay.call(this, this.elements.display.currentTime, this.duration);
+ }
+
+ // If there's a duration element, update content
+ if (hasDuration) {
+ ui.updateTimeDisplay.call(this, this.elements.display.duration, this.duration);
+ }
+
+ // Update the tooltip (if visible)
+ controls.updateSeekTooltip.call(this);
+ },
+};
+
+export default ui;
diff --git a/src/js/utils.js b/src/js/utils.js
new file mode 100644
index 00000000..37dd6461
--- /dev/null
+++ b/src/js/utils.js
@@ -0,0 +1,866 @@
+// ==========================================================================
+// Plyr utils
+// ==========================================================================
+
+import support from './support';
+import { providers } from './types';
+
+const utils = {
+ // Check variable types
+ is: {
+ plyr(input) {
+ return this.instanceof(input, window.Plyr);
+ },
+ object(input) {
+ return this.getConstructor(input) === Object;
+ },
+ number(input) {
+ return this.getConstructor(input) === Number && !Number.isNaN(input);
+ },
+ string(input) {
+ return this.getConstructor(input) === String;
+ },
+ boolean(input) {
+ return this.getConstructor(input) === Boolean;
+ },
+ function(input) {
+ return this.getConstructor(input) === Function;
+ },
+ array(input) {
+ return !this.nullOrUndefined(input) && Array.isArray(input);
+ },
+ weakMap(input) {
+ return this.instanceof(input, window.WeakMap);
+ },
+ nodeList(input) {
+ return this.instanceof(input, window.NodeList);
+ },
+ element(input) {
+ return this.instanceof(input, window.Element);
+ },
+ textNode(input) {
+ return this.getConstructor(input) === Text;
+ },
+ event(input) {
+ return this.instanceof(input, window.Event);
+ },
+ cue(input) {
+ return this.instanceof(input, window.TextTrackCue) || this.instanceof(input, window.VTTCue);
+ },
+ track(input) {
+ return this.instanceof(input, TextTrack) || (!this.nullOrUndefined(input) && this.string(input.kind));
+ },
+ url(input) {
+ return !this.nullOrUndefined(input) && /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input);
+ },
+ nullOrUndefined(input) {
+ return input === null || typeof input === 'undefined';
+ },
+ empty(input) {
+ return (
+ this.nullOrUndefined(input) ||
+ ((this.string(input) || this.array(input) || this.nodeList(input)) && !input.length) ||
+ (this.object(input) && !Object.keys(input).length)
+ );
+ },
+ instanceof(input, constructor) {
+ return Boolean(input && constructor && input instanceof constructor);
+ },
+ getConstructor(input) {
+ return !this.nullOrUndefined(input) ? input.constructor : null;
+ },
+ },
+
+ // Unfortunately, due to mixed support, UA sniffing is required
+ getBrowser() {
+ return {
+ isIE: /* @cc_on!@ */ false || !!document.documentMode,
+ isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent),
+ isIPhone: /(iPhone|iPod)/gi.test(navigator.platform),
+ isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform),
+ };
+ },
+
+ // Fetch wrapper
+ // Using XHR to avoid issues with older browsers
+ fetch(url, responseType = 'text') {
+ return new Promise((resolve, reject) => {
+ try {
+ const request = new XMLHttpRequest();
+
+ // Check for CORS support
+ if (!('withCredentials' in request)) {
+ return;
+ }
+
+ request.addEventListener('load', () => {
+ if (responseType === 'text') {
+ try {
+ resolve(JSON.parse(request.responseText));
+ } catch(e) {
+ resolve(request.responseText);
+ }
+ }
+ else {
+ resolve(request.response);
+ }
+ });
+
+ request.addEventListener('error', () => {
+ throw new Error(request.statusText);
+ });
+
+ request.open('GET', url, true);
+
+ // Set the required response type
+ request.responseType = responseType;
+
+ request.send();
+ } catch (e) {
+ reject(e);
+ }
+ });
+ },
+
+ // Load an external script
+ loadScript(url) {
+ return new Promise((resolve, reject) => {
+ const current = document.querySelector(`script[src="${url}"]`);
+
+ // Check script is not already referenced, if so wait for load
+ if (current !== null) {
+ current.callbacks = current.callbacks || [];
+ current.callbacks.push(resolve);
+ return;
+ }
+
+ // Build the element
+ const element = document.createElement('script');
+
+ // Callback queue
+ element.callbacks = element.callbacks || [];
+ element.callbacks.push(resolve);
+
+ // Error queue
+ element.errors = element.errors || [];
+ element.errors.push(reject);
+
+ // Bind callback
+ element.addEventListener(
+ 'load',
+ event => {
+ element.callbacks.forEach(cb => cb.call(null, event));
+ element.callbacks = null;
+ },
+ false,
+ );
+
+ // Bind error handling
+ element.addEventListener(
+ 'error',
+ event => {
+ element.errors.forEach(err => err.call(null, event));
+ element.errors = null;
+ },
+ false,
+ );
+
+ // Set the URL after binding callback
+ element.src = url;
+
+ // Inject
+ const first = document.getElementsByTagName('script')[0];
+ first.parentNode.insertBefore(element, first);
+ });
+ },
+
+ // Load an external SVG sprite
+ loadSprite(url, id) {
+ if (!utils.is.string(url)) {
+ return;
+ }
+
+ const prefix = 'cache-';
+ const hasId = utils.is.string(id);
+ let isCached = false;
+
+ function updateSprite(data) {
+ // Inject content
+ this.innerHTML = data;
+
+ // Inject the SVG to the body
+ document.body.insertBefore(this, document.body.childNodes[0]);
+ }
+
+ // Only load once
+ if (!hasId || !document.querySelectorAll(`#${id}`).length) {
+ // Create container
+ const container = document.createElement('div');
+ utils.toggleHidden(container, true);
+
+ if (hasId) {
+ container.setAttribute('id', id);
+ }
+
+ // Check in cache
+ if (support.storage) {
+ const cached = window.localStorage.getItem(prefix + id);
+ isCached = cached !== null;
+
+ if (isCached) {
+ const data = JSON.parse(cached);
+ updateSprite.call(container, data.content);
+ return;
+ }
+ }
+
+ // Get the sprite
+ utils
+ .fetch(url)
+ .then(result => {
+ if (utils.is.empty(result)) {
+ return;
+ }
+
+ if (support.storage) {
+ window.localStorage.setItem(
+ prefix + id,
+ JSON.stringify({
+ content: result,
+ }),
+ );
+ }
+
+ updateSprite.call(container, result);
+ })
+ .catch(() => {});
+ }
+ },
+
+ // Generate a random ID
+ generateId(prefix) {
+ return `${prefix}-${Math.floor(Math.random() * 10000)}`;
+ },
+
+ // Determine if we're in an iframe
+ inFrame() {
+ try {
+ return window.self !== window.top;
+ } catch (e) {
+ return true;
+ }
+ },
+
+ // Wrap an element
+ wrap(elements, wrapper) {
+ // Convert `elements` to an array, if necessary.
+ const targets = elements.length ? elements : [elements];
+
+ // Loops backwards to prevent having to clone the wrapper on the
+ // first element (see `child` below).
+ Array.from(targets)
+ .reverse()
+ .forEach((element, index) => {
+ const child = index > 0 ? wrapper.cloneNode(true) : wrapper;
+
+ // Cache the current parent and sibling.
+ const parent = element.parentNode;
+ const sibling = element.nextSibling;
+
+ // Wrap the element (is automatically removed from its current
+ // parent).
+ child.appendChild(element);
+
+ // If the element had a sibling, insert the wrapper before
+ // the sibling to maintain the HTML structure; otherwise, just
+ // append it to the parent.
+ if (sibling) {
+ parent.insertBefore(child, sibling);
+ } else {
+ parent.appendChild(child);
+ }
+ });
+ },
+
+ // Create a DocumentFragment
+ createElement(type, attributes, text) {
+ // Create a new <element>
+ const element = document.createElement(type);
+
+ // Set all passed attributes
+ if (utils.is.object(attributes)) {
+ utils.setAttributes(element, attributes);
+ }
+
+ // Add text node
+ if (utils.is.string(text)) {
+ element.textContent = text;
+ }
+
+ // Return built element
+ return element;
+ },
+
+ // Inaert an element after another
+ insertAfter(element, target) {
+ target.parentNode.insertBefore(element, target.nextSibling);
+ },
+
+ // Insert a DocumentFragment
+ insertElement(type, parent, attributes, text) {
+ // Inject the new <element>
+ parent.appendChild(utils.createElement(type, attributes, text));
+ },
+
+ // Remove an element
+ removeElement(element) {
+ if (!utils.is.element(element) || !utils.is.element(element.parentNode)) {
+ return;
+ }
+
+ if (utils.is.nodeList(element) || utils.is.array(element)) {
+ Array.from(element).forEach(utils.removeElement);
+ return;
+ }
+
+ element.parentNode.removeChild(element);
+ },
+
+ // Remove all child elements
+ emptyElement(element) {
+ let { length } = element.childNodes;
+
+ while (length > 0) {
+ element.removeChild(element.lastChild);
+ length -= 1;
+ }
+ },
+
+ // Replace element
+ replaceElement(newChild, oldChild) {
+ if (!utils.is.element(oldChild) || !utils.is.element(oldChild.parentNode) || !utils.is.element(newChild)) {
+ return null;
+ }
+
+ oldChild.parentNode.replaceChild(newChild, oldChild);
+
+ return newChild;
+ },
+
+ // Set attributes
+ setAttributes(element, attributes) {
+ if (!utils.is.element(element) || utils.is.empty(attributes)) {
+ return;
+ }
+
+ Object.keys(attributes).forEach(key => {
+ element.setAttribute(key, attributes[key]);
+ });
+ },
+
+ // Get an attribute object from a string selector
+ getAttributesFromSelector(sel, existingAttributes) {
+ // For example:
+ // '.test' to { class: 'test' }
+ // '#test' to { id: 'test' }
+ // '[data-test="test"]' to { 'data-test': 'test' }
+
+ if (!utils.is.string(sel) || utils.is.empty(sel)) {
+ return {};
+ }
+
+ const attributes = {};
+ const existing = existingAttributes;
+
+ sel.split(',').forEach(s => {
+ // Remove whitespace
+ const selector = s.trim();
+ const className = selector.replace('.', '');
+ const stripped = selector.replace(/[[\]]/g, '');
+
+ // Get the parts and value
+ const parts = stripped.split('=');
+ const key = parts[0];
+ const value = parts.length > 1 ? parts[1].replace(/["']/g, '') : '';
+
+ // Get the first character
+ const start = selector.charAt(0);
+
+ switch (start) {
+ case '.':
+ // Add to existing classname
+ if (utils.is.object(existing) && utils.is.string(existing.class)) {
+ existing.class += ` ${className}`;
+ }
+
+ attributes.class = className;
+ break;
+
+ case '#':
+ // ID selector
+ attributes.id = selector.replace('#', '');
+ break;
+
+ case '[':
+ // Attribute selector
+ attributes[key] = value;
+
+ break;
+
+ default:
+ break;
+ }
+ });
+
+ return attributes;
+ },
+
+ // Toggle class on an element
+ toggleClass(element, className, toggle) {
+ if (utils.is.element(element)) {
+ const contains = element.classList.contains(className);
+
+ element.classList[toggle ? 'add' : 'remove'](className);
+
+ return (toggle && !contains) || (!toggle && contains);
+ }
+
+ return null;
+ },
+
+ // Has class name
+ hasClass(element, className) {
+ return utils.is.element(element) && element.classList.contains(className);
+ },
+
+ // Toggle hidden attribute on an element
+ toggleHidden(element, toggle) {
+ if (!utils.is.element(element)) {
+ return;
+ }
+
+ if (toggle) {
+ element.setAttribute('hidden', '');
+ } else {
+ element.removeAttribute('hidden');
+ }
+ },
+
+ // Element matches selector
+ matches(element, selector) {
+ const prototype = { Element };
+
+ function match() {
+ return Array.from(document.querySelectorAll(selector)).includes(this);
+ }
+
+ const matches = prototype.matches || prototype.webkitMatchesSelector || prototype.mozMatchesSelector || prototype.msMatchesSelector || match;
+
+ return matches.call(element, selector);
+ },
+
+ // Find all elements
+ getElements(selector) {
+ return this.elements.container.querySelectorAll(selector);
+ },
+
+ // Find a single element
+ getElement(selector) {
+ return this.elements.container.querySelector(selector);
+ },
+
+ // Find the UI controls and store references in custom controls
+ // TODO: Allow settings menus with custom controls
+ findElements() {
+ try {
+ this.elements.controls = utils.getElement.call(this, this.config.selectors.controls.wrapper);
+
+ // Buttons
+ this.elements.buttons = {
+ play: utils.getElements.call(this, this.config.selectors.buttons.play),
+ pause: utils.getElement.call(this, this.config.selectors.buttons.pause),
+ restart: utils.getElement.call(this, this.config.selectors.buttons.restart),
+ rewind: utils.getElement.call(this, this.config.selectors.buttons.rewind),
+ forward: utils.getElement.call(this, this.config.selectors.buttons.forward),
+ mute: utils.getElement.call(this, this.config.selectors.buttons.mute),
+ pip: utils.getElement.call(this, this.config.selectors.buttons.pip),
+ airplay: utils.getElement.call(this, this.config.selectors.buttons.airplay),
+ settings: utils.getElement.call(this, this.config.selectors.buttons.settings),
+ captions: utils.getElement.call(this, this.config.selectors.buttons.captions),
+ fullscreen: utils.getElement.call(this, this.config.selectors.buttons.fullscreen),
+ };
+
+ // Progress
+ this.elements.progress = utils.getElement.call(this, this.config.selectors.progress);
+
+ // Inputs
+ this.elements.inputs = {
+ seek: utils.getElement.call(this, this.config.selectors.inputs.seek),
+ volume: utils.getElement.call(this, this.config.selectors.inputs.volume),
+ };
+
+ // Display
+ this.elements.display = {
+ buffer: utils.getElement.call(this, this.config.selectors.display.buffer),
+ duration: utils.getElement.call(this, this.config.selectors.display.duration),
+ currentTime: utils.getElement.call(this, this.config.selectors.display.currentTime),
+ };
+
+ // Seek tooltip
+ if (utils.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;
+ }
+ },
+
+ // Get the focused element
+ getFocusElement() {
+ let focused = document.activeElement;
+
+ if (!focused || focused === document.body) {
+ focused = null;
+ } else {
+ focused = document.querySelector(':focus');
+ }
+
+ return focused;
+ },
+
+ // Trap focus inside container
+ trapFocus(element = null, toggle = false) {
+ if (!utils.is.element(element)) {
+ return;
+ }
+
+ const focusable = utils.getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ const trap = event => {
+ // Bail if not tab key or not fullscreen
+ if (event.key !== 'Tab' || event.keyCode !== 9) {
+ return;
+ }
+
+ // Get the current focused element
+ const focused = utils.getFocusElement();
+
+ if (focused === last && !event.shiftKey) {
+ // Move focus to first element that can be tabbed if Shift isn't used
+ first.focus();
+ event.preventDefault();
+ } else if (focused === first && event.shiftKey) {
+ // Move focus to last element that can be tabbed if Shift is used
+ last.focus();
+ event.preventDefault();
+ }
+ };
+
+ if (toggle) {
+ utils.on(this.elements.container, 'keydown', trap, false);
+ } else {
+ utils.off(this.elements.container, 'keydown', trap, false);
+ }
+ },
+
+ // Toggle event listener
+ toggleListener(elements, event, callback, toggle, passive, capture) {
+ // Bail if no elemetns, event, or callback
+ if (utils.is.empty(elements) || utils.is.empty(event) || !utils.is.function(callback)) {
+ return;
+ }
+
+ // If a nodelist is passed, call itself on each node
+ if (utils.is.nodeList(elements) || utils.is.array(elements)) {
+ // Create listener for each node
+ Array.from(elements).forEach(element => {
+ if (element instanceof Node) {
+ utils.toggleListener.call(null, element, event, callback, toggle, passive, capture);
+ }
+ });
+
+ return;
+ }
+
+ // Allow multiple events
+ const events = event.split(' ');
+
+ // Build options
+ // Default to just capture boolean
+ let options = utils.is.boolean(capture) ? capture : false;
+
+ // If passive events listeners are supported
+ if (support.passiveListeners) {
+ options = {
+ // Whether the listener can be passive (i.e. default never prevented)
+ passive: utils.is.boolean(passive) ? passive : true,
+ // Whether the listener is a capturing listener or not
+ capture: utils.is.boolean(capture) ? capture : false,
+ };
+ }
+
+ // If a single node is passed, bind the event listener
+ events.forEach(type => {
+ elements[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options);
+ });
+ },
+
+ // Bind event handler
+ on(element, events, callback, passive, capture) {
+ utils.toggleListener(element, events, callback, true, passive, capture);
+ },
+
+ // Unbind event handler
+ off(element, events, callback, passive, capture) {
+ utils.toggleListener(element, events, callback, false, passive, capture);
+ },
+
+ // Trigger event
+ dispatchEvent(element, type, bubbles, detail) {
+ // Bail if no element
+ if (!utils.is.element(element) || !utils.is.string(type)) {
+ return;
+ }
+
+ // Create and dispatch the event
+ const event = new CustomEvent(type, {
+ bubbles: utils.is.boolean(bubbles) ? bubbles : false,
+ detail: Object.assign({}, detail, {
+ plyr: utils.is.plyr(this) ? this : null,
+ }),
+ });
+
+ // Dispatch the event
+ element.dispatchEvent(event);
+ },
+
+ // Toggle aria-pressed state on a toggle button
+ // http://www.ssbbartgroup.com/blog/how-not-to-misuse-aria-states-properties-and-roles
+ toggleState(element, input) {
+ // If multiple elements passed
+ if (utils.is.array(element) || utils.is.nodeList(element)) {
+ Array.from(element).forEach(target => utils.toggleState(target, input));
+ return;
+ }
+
+ // Bail if no target
+ if (!utils.is.element(element)) {
+ return;
+ }
+
+ // Get state
+ const pressed = element.getAttribute('aria-pressed') === 'true';
+ const state = utils.is.boolean(input) ? input : !pressed;
+
+ // Set the attribute on target
+ element.setAttribute('aria-pressed', state);
+ },
+
+ // Get percentage
+ getPercentage(current, max) {
+ if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {
+ return 0;
+ }
+
+ return (current / max * 100).toFixed(2);
+ },
+
+ // Time helpers
+ getHours(value) {
+ return parseInt((value / 60 / 60) % 60, 10);
+ },
+ getMinutes(value) {
+ return parseInt((value / 60) % 60, 10);
+ },
+ getSeconds(value) {
+ return parseInt(value % 60, 10);
+ },
+
+ // Format time to UI friendly string
+ formatTime(time = 0, displayHours = false, inverted = false) {
+ // Bail if the value isn't a number
+ if (!utils.is.number(time)) {
+ return this.formatTime(null, displayHours, inverted);
+ }
+
+ // Format time component to add leading zero
+ const format = value => `0${value}`.slice(-2);
+
+ // Breakdown to hours, mins, secs
+ let hours = this.getHours(time);
+ const mins = this.getMinutes(time);
+ const secs = this.getSeconds(time);
+
+ // Do we need to display hours?
+ if (displayHours || hours > 0) {
+ hours = `${hours}:`;
+ } else {
+ hours = '';
+ }
+
+ // Render
+ return `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;
+ },
+
+ // Deep extend destination object with N more objects
+ extend(target = {}, ...sources) {
+ if (!sources.length) {
+ return target;
+ }
+
+ const source = sources.shift();
+
+ if (!utils.is.object(source)) {
+ return target;
+ }
+
+ Object.keys(source).forEach(key => {
+ if (utils.is.object(source[key])) {
+ if (!Object.keys(target).includes(key)) {
+ Object.assign(target, { [key]: {} });
+ }
+
+ utils.extend(target[key], source[key]);
+ } else {
+ Object.assign(target, { [key]: source[key] });
+ }
+ });
+
+ return utils.extend(target, ...sources);
+ },
+
+ // Get the provider for a given URL
+ getProviderByUrl(url) {
+ // YouTube
+ if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/.test(url)) {
+ return providers.youtube;
+ }
+
+ // Vimeo
+ if (/^https?:\/\/player.vimeo.com\/video\/\d{8,}(?=\b|\/)/.test(url)) {
+ return providers.vimeo;
+ }
+
+ return null;
+ },
+
+ // Parse YouTube ID from URL
+ parseYouTubeId(url) {
+ if (utils.is.empty(url)) {
+ return null;
+ }
+
+ const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
+ return url.match(regex) ? RegExp.$2 : url;
+ },
+
+ // Parse Vimeo ID from URL
+ parseVimeoId(url) {
+ if (utils.is.empty(url)) {
+ return null;
+ }
+
+ if (utils.is.number(Number(url))) {
+ return url;
+ }
+
+ const regex = /^.*(vimeo.com\/|video\/)(\d+).*/;
+ return url.match(regex) ? RegExp.$2 : url;
+ },
+
+ // Convert a URL to a location object
+ parseUrl(url) {
+ const parser = document.createElement('a');
+ parser.href = url;
+ return parser;
+ },
+
+ // Get URL query parameters
+ getUrlParams(input) {
+ let search = input;
+
+ // Parse URL if needed
+ if (input.startsWith('http://') || input.startsWith('https://')) {
+ ({ search } = this.parseUrl(input));
+ }
+
+ if (this.is.empty(search)) {
+ return null;
+ }
+
+ const hashes = search.slice(search.indexOf('?') + 1).split('&');
+
+ return hashes.reduce((params, hash) => {
+ const [
+ key,
+ val,
+ ] = hash.split('=');
+
+ return Object.assign(params, { [key]: decodeURIComponent(val) });
+ }, {});
+ },
+
+ // Convert object to URL parameters
+ buildUrlParams(input) {
+ if (!utils.is.object(input)) {
+ return '';
+ }
+
+ return Object.keys(input)
+ .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(input[key])}`)
+ .join('&');
+ },
+
+ // Remove HTML from a string
+ stripHTML(source) {
+ const fragment = document.createDocumentFragment();
+ const element = document.createElement('div');
+ fragment.appendChild(element);
+ element.innerHTML = source;
+ return fragment.firstChild.innerText;
+ },
+
+ // Get aspect ratio for dimensions
+ getAspectRatio(width, height) {
+ const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h));
+ const ratio = getRatio(width, height);
+ return `${width / ratio}:${height / ratio}`;
+ },
+
+ // Get the transition end event
+ get transitionEndEvent() {
+ const element = document.createElement('span');
+
+ const events = {
+ WebkitTransition: 'webkitTransitionEnd',
+ MozTransition: 'transitionend',
+ OTransition: 'oTransitionEnd otransitionend',
+ transition: 'transitionend',
+ };
+
+ const type = Object.keys(events).find(event => element.style[event] !== undefined);
+
+ return utils.is.string(type) ? events[type] : false;
+ },
+
+ // Force repaint of element
+ repaint(element) {
+ setTimeout(() => {
+ utils.toggleHidden(element, true);
+ element.offsetHeight; // eslint-disable-line
+ utils.toggleHidden(element, false);
+ }, 0);
+ },
+};
+
+export default utils;
diff --git a/src/less/mixins.less b/src/less/mixins.less
deleted file mode 100644
index 25f43761..00000000
--- a/src/less/mixins.less
+++ /dev/null
@@ -1,29 +0,0 @@
-// ==========================================================================
-// Plyr mixins
-// https://github.com/selz/plyr
-// ==========================================================================
-
-// <input type="range"> styling
-.plyr-range-track() {
- height: @plyr-range-track-height;
- background: transparent;
- border: 0;
- border-radius: (@plyr-range-track-height / 2);
- user-select: none;
-}
-.plyr-range-thumb() {
- position: relative;
- height: @plyr-range-thumb-height;
- width: @plyr-range-thumb-width;
- background: @plyr-range-thumb-bg;
- border: @plyr-range-thumb-border;
- border-radius: 100%;
- transition: background .2s ease, border .2s ease, transform .2s ease;
- box-shadow: @plyr-range-thumb-shadow;
- box-sizing: border-box;
-}
-.plyr-range-thumb-active() {
- background: @plyr-range-thumb-active-bg;
- border-color: @plyr-range-thumb-active-border-color;
- transform: scale(@plyr-range-thumb-active-scale);
-} \ No newline at end of file
diff --git a/src/less/plyr.less b/src/less/plyr.less
deleted file mode 100644
index b701a6ce..00000000
--- a/src/less/plyr.less
+++ /dev/null
@@ -1,770 +0,0 @@
-// ==========================================================================
-// Plyr styles
-// https://github.com/selz/plyr
-// ==========================================================================
-
-@import 'variables';
-@import 'mixins';
-
-// Animation
-// ---------------------------------------
-@keyframes plyr-progress {
- to {
- background-position: @plyr-progress-loading-size 0;
- }
-}
-
-// Styles
-// -------------------------------
-// Base
-.plyr {
- position: relative;
- max-width: 100%;
- min-width: 200px;
- font-family: @plyr-font-family;
- direction: ltr;
-
- & when (@plyr-border-box = true) {
- // border-box everything
- // http://paulirish.com/2012/box-sizing-border-box-ftw/
- &,
- *,
- *::after,
- *::before {
- box-sizing: border-box;
- }
- }
-
- & when (@plyr-touch-action = true) {
- // Fix 300ms delay
- a,
- button,
- input,
- label {
- touch-action: manipulation;
- }
- }
-
- // Focus
- &:focus {
- outline: 0;
- }
-
- // Media elements
- video,
- audio {
- width: 100%;
- height: auto;
- vertical-align: middle;
- border-radius: inherit;
- }
-
- // Range inputs
- // Specificity is for bootstrap compatibility
- input[type='range'] {
- display: block;
- height: (@plyr-range-thumb-height * @plyr-range-thumb-active-scale);
- width: 100%;
- margin: 0;
- padding: 0;
-
- -webkit-appearance: none;
- -moz-appearance: none;
- cursor: pointer;
- border: none;
- background: transparent;
-
- // WebKit
- &::-webkit-slider-runnable-track {
- .plyr-range-track();
- }
- &::-webkit-slider-thumb {
- -webkit-appearance: none;
- margin-top: -((@plyr-range-thumb-height - @plyr-range-track-height) / 2);
- .plyr-range-thumb();
- }
-
- // Mozilla
- &::-moz-range-track {
- .plyr-range-track();
- }
- &::-moz-range-thumb {
- .plyr-range-thumb();
- }
-
- // Microsoft
- &::-ms-track {
- height: @plyr-range-track-height;
- background: transparent;
- border: 0;
- color: transparent;
- }
- &::-ms-fill-upper {
- .plyr-range-track();
- }
- &::-ms-fill-lower {
- .plyr-range-track();
- background: @plyr-range-selected-bg;
- }
- &::-ms-thumb {
- .plyr-range-thumb();
- // For some reason, Edge uses the -webkit margin above
- margin-top: 0;
- }
- &::-ms-tooltip {
- display: none;
- }
-
- // Focus styles
- &:focus {
- outline: 0;
- }
- &::-moz-focus-outer {
- border: 0;
- }
- &.tab-focus:focus {
- outline-offset: 3px;
- }
-
- // Pressed styles
- &:active {
- &::-webkit-slider-thumb {
- .plyr-range-thumb-active();
- }
- &::-moz-range-thumb {
- .plyr-range-thumb-active();
- }
- &::-ms-thumb {
- .plyr-range-thumb-active();
- }
- }
- }
-}
-
-// Video range inputs
-.plyr--video input[type='range'].tab-focus:focus {
- outline: 1px dotted fade(@plyr-video-control-color, 50%);
-}
-
-// Audio range inputs
-.plyr--audio input[type='range'].tab-focus:focus {
- outline: 1px dotted fade(@plyr-audio-control-color, 50%);
-}
-
-// Screen reader only elements
-.plyr__sr-only {
- clip: rect(1px, 1px, 1px, 1px);
- overflow: hidden;
-
- // !important is not always needed
- & when (@plyr-sr-only-important = true) {
- position: absolute !important;
- padding: 0 !important;
- border: 0 !important;
- height: 1px !important;
- width: 1px !important;
- }
- & when (@plyr-sr-only-important = false) {
- position: absolute;
- padding: 0;
- border: 0;
- height: 1px;
- width: 1px;
- }
-}
-
-// Video
-.plyr__video-wrapper {
- position: relative;
- background: #000;
- border-radius: inherit;
-}
-
-// Container for embeds
-.plyr__video-embed {
- padding-bottom: 56.25%; /* 16:9 */
- height: 0;
- border-radius: inherit;
-
- // Require overflow and z-index to force border-radius
- overflow: hidden;
- z-index: 0;
-
- iframe {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- border: 0;
- user-select: none;
- }
-
- // Vimeo hack
- > div {
- position: relative;
- padding-bottom: 200%;
- transform: translateY(-35.95%);
- }
-}
-// To allow mouse events to be captured if full support
-.plyr .plyr__video-embed iframe {
- pointer-events: none;
-}
-
-// Captions
-// --------------------------------------------------------------
-// Hide default captions
-.plyr video::-webkit-media-text-track-container {
- display: none;
-}
-.plyr__captions {
- display: none;
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- padding: (@plyr-control-spacing * 2);
- transform: translateY(-(@plyr-control-spacing * 4));
- transition: transform 0.3s ease;
- color: @plyr-captions-color;
- font-size: @plyr-font-size-captions-base;
- text-align: center;
- font-weight: 400;
-
- span {
- border-radius: 2px;
- padding: floor(@plyr-control-spacing / 3) @plyr-control-spacing;
- background: @plyr-captions-bg;
- box-decoration-break: clone;
- line-height: 150%;
- }
- span:empty {
- display: none;
- }
-
- @media (min-width: @plyr-bp-screen-md) {
- font-size: @plyr-font-size-captions-medium;
- }
-}
-.plyr--captions-active .plyr__captions {
- display: block;
-}
-.plyr--hide-controls .plyr__captions {
- transform: translateY(-(@plyr-control-spacing * 1.5));
-}
-// Large captions in full screen on larger screens
-@media (min-width: @plyr-bp-screen-lg) {
- .plyr--fullscreen-active .plyr__captions {
- font-size: @plyr-font-size-captions-large;
- }
-}
-
-// Controls
-// --------------------------------------------------------------
-// Hide native controls
-.plyr ::-webkit-media-controls {
- display: none;
-}
-
-// Playback controls
-.plyr__controls {
- display: flex;
- align-items: center;
- line-height: 1;
- text-align: center;
- pointer-events: none;
-
- & > * {
- pointer-events: all;
- }
-
- // Spacing
- > button,
- .plyr__progress,
- .plyr__time {
- margin-left: (@plyr-control-spacing / 2);
-
- &:first-child {
- margin-left: 0;
- }
- }
- .plyr__volume {
- margin-left: (@plyr-control-spacing / 2);
- }
- [data-plyr='pause'] {
- margin-left: 0;
- }
-
- // Buttons
- button {
- position: relative;
- display: inline-block;
- flex-shrink: 0;
- overflow: visible; // IE11
- vertical-align: middle;
- padding: @plyr-control-padding;
- border: 0;
- background: transparent;
- border-radius: 3px;
- cursor: pointer;
- transition: background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
- color: inherit;
-
- svg {
- width: @plyr-control-icon-size;
- height: @plyr-control-icon-size;
- display: block;
- fill: currentColor;
- }
-
- // Default focus
- &:focus {
- outline: 0;
- }
- }
-
- // Hide toggle icons by default
- .icon--exit-fullscreen,
- .icon--muted,
- .icon--captions-on {
- display: none;
- }
-
- @media (min-width: @plyr-bp-screen-sm) {
- > button,
- .plyr__progress,
- .plyr__time {
- margin-left: @plyr-control-spacing;
- }
- }
-}
-// Hide controls
-.plyr--hide-controls .plyr__controls {
- opacity: 0;
- pointer-events: none;
-}
-
-// Video controls
-.plyr--video .plyr__controls {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 2;
- padding: (@plyr-control-spacing * 5) @plyr-control-spacing @plyr-control-spacing;
- background: linear-gradient(fade(@plyr-video-controls-bg, 0%), fade(@plyr-video-controls-bg, 50%));
- border-bottom-left-radius: inherit;
- border-bottom-right-radius: inherit;
- color: @plyr-video-control-color;
- transition: opacity 0.3s ease;
-
- button {
- // Hover and tab focus
- &.tab-focus:focus,
- &:hover {
- background: @plyr-video-control-bg-hover;
- color: @plyr-video-control-color-hover;
- }
- }
-}
-
-// Audio controls
-.plyr--audio .plyr__controls {
- padding: @plyr-control-spacing;
- border-radius: inherit;
- background: @plyr-audio-controls-bg;
- border: @plyr-audio-controls-border;
- color: @plyr-audio-control-color;
-
- button {
- // Hover and tab focus
- &.tab-focus:focus,
- &:hover {
- background: @plyr-audio-control-bg-hover;
- color: @plyr-audio-control-color-hover;
- }
- }
-}
-
-// Large play button (video only)
-.plyr__play-large {
- display: none;
- position: absolute;
- z-index: 1;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- padding: @plyr-control-spacing;
- background: @plyr-video-control-bg-hover;
- border: 4px solid currentColor;
- border-radius: 100%;
- box-shadow: 0 1px 1px fade(#000, 15%);
- color: @plyr-video-control-color;
- transition: all 0.3s ease;
-
- svg {
- position: relative;
- left: 2px;
- width: 20px;
- height: 20px;
- display: block;
- fill: currentColor;
- }
-
- &:focus {
- outline: 1px dotted fade(@plyr-video-control-color, 50%);
- }
-}
-.plyr .plyr__play-large {
- display: inline-block;
-}
-.plyr--audio .plyr__play-large {
- display: none;
-}
-.plyr--playing .plyr__play-large {
- opacity: 0;
- visibility: hidden;
-}
-
-// States
-.plyr__controls [data-plyr='pause'],
-.plyr--playing .plyr__controls [data-plyr='play'] {
- display: none;
-}
-.plyr--playing .plyr__controls [data-plyr='pause'] {
- display: inline-block;
-}
-
-// Change icons on state change
-.plyr--fullscreen-active .icon--exit-fullscreen,
-.plyr--muted .plyr__controls .icon--muted,
-.plyr--captions-active .plyr__controls .icon--captions-on {
- display: block;
-
- & + svg {
- display: none;
- }
-}
-
-// Some options are hidden by default
-.plyr [data-plyr='captions'],
-.plyr [data-plyr='fullscreen'] {
- display: none;
-}
-.plyr--captions-enabled [data-plyr='captions'],
-.plyr--fullscreen-enabled [data-plyr='fullscreen'] {
- display: inline-block;
-}
-
-// Tooltips
-// --------------------------------------------------------------
-.plyr__tooltip {
- position: absolute;
- z-index: 2;
- bottom: 100%;
- margin-bottom: (@plyr-tooltip-padding * 2);
- padding: @plyr-tooltip-padding (@plyr-tooltip-padding * 1.5);
- pointer-events: none;
-
- opacity: 0;
- background: @plyr-tooltip-bg;
- border-radius: @plyr-tooltip-radius;
-
- color: @plyr-tooltip-color;
- font-size: @plyr-font-size-small;
- line-height: 1.3;
-
- transform: translate(-50%, 10px) scale(0.8);
- transform-origin: 50% 100%;
- transition: transform 0.2s 0.1s ease, opacity 0.2s 0.1s ease;
-
- &::before {
- // Arrows
- content: '';
- position: absolute;
- width: 0;
- height: 0;
- left: 50%;
- transform: translateX(-50%);
-
- // The background triangle
- bottom: -@plyr-tooltip-arrow-size;
- border-right: @plyr-tooltip-arrow-size solid transparent;
- border-top: @plyr-tooltip-arrow-size solid @plyr-tooltip-bg;
- border-left: @plyr-tooltip-arrow-size solid transparent;
- z-index: 2;
- }
-}
-.plyr button:hover .plyr__tooltip,
-.plyr button.tab-focus:focus .plyr__tooltip,
-.plyr__tooltip--visible {
- opacity: 1;
- transform: translate(-50%, 0) scale(1);
-}
-.plyr button:hover .plyr__tooltip {
- z-index: 3;
-}
-
-// First tooltip
-.plyr__controls button:first-child .plyr__tooltip {
- left: 0;
- transform: translate(0, 10px) scale(0.8);
- transform-origin: 0 100%;
-
- &::before {
- left: (@plyr-control-icon-size / 2) + @plyr-control-padding;
- }
-}
-
-// Last tooltip
-.plyr__controls button:last-child .plyr__tooltip {
- right: 0;
- transform: translate(0, 10px) scale(0.8);
- transform-origin: 100% 100%;
-
- &::before {
- left: auto;
- right: (@plyr-control-icon-size / 2) + @plyr-control-padding;
- transform: translateX(50%);
- }
-}
-
-.plyr__controls button:first-child,
-.plyr__controls button:last-child {
- &:hover .plyr__tooltip,
- &.tab-focus:focus .plyr__tooltip,
- .plyr__tooltip--visible {
- transform: translate(0, 0) scale(1);
- }
-}
-
-// Playback progress
-// --------------------------------------------------------------
-// <progress> element
-.plyr__progress {
- position: relative;
- display: none;
- flex: 1;
-
- input[type='range'] {
- position: relative;
- z-index: 2;
-
- &::-webkit-slider-runnable-track {
- background: transparent;
- }
- &::-moz-range-track {
- background: transparent;
- }
- &::-ms-fill-upper {
- background: transparent;
- }
- }
-
- // Seek tooltip to show time
- .plyr__tooltip {
- left: 0;
- }
-}
-.plyr .plyr__progress {
- display: inline-block;
-}
-
-.plyr__progress--buffer,
-.plyr__progress--played,
-.plyr__volume--display {
- position: absolute;
- left: 0;
- top: 50%;
- width: 100%;
- height: @plyr-range-track-height;
- margin: -(@plyr-range-track-height / 2) 0 0;
- padding: 0;
- vertical-align: top;
- -webkit-appearance: none;
- -moz-appearance: none;
- border: none;
- border-radius: 100px;
-
- &::-webkit-progress-bar {
- background: transparent;
- }
- &::-webkit-progress-value {
- background: currentColor;
- border-radius: 100px;
- min-width: @plyr-range-track-height;
- }
- &::-moz-progress-bar {
- background: currentColor;
- border-radius: 100px;
- min-width: @plyr-range-track-height;
- }
- &::-ms-fill {
- border-radius: 100px;
- }
-}
-.plyr__progress--played,
-.plyr__volume--display {
- z-index: 1;
- color: @plyr-range-selected-bg;
- background: transparent;
- transition: none;
-
- &::-webkit-progress-value {
- min-width: @plyr-range-track-height;
- max-width: 99%;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- transition: none;
- }
- &::-moz-progress-bar {
- min-width: @plyr-range-track-height;
- max-width: 99%;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- transition: none;
- }
- &::-ms-fill {
- display: none;
- }
-}
-.plyr__progress--buffer {
- &::-webkit-progress-value {
- transition: width 0.2s ease;
- }
- &::-moz-progress-bar {
- transition: width 0.2s ease;
- }
- &::-ms-fill {
- transition: width 0.2s ease;
- }
-}
-.plyr--video .plyr__progress--buffer,
-.plyr--video .plyr__volume--display {
- background: @plyr-video-range-track-bg;
-}
-.plyr--video .plyr__progress--buffer {
- color: @plyr-video-progress-buffered-bg;
-}
-.plyr--audio .plyr__progress--buffer,
-.plyr--audio .plyr__volume--display {
- background: @plyr-audio-range-track-bg;
-}
-.plyr--audio .plyr__progress--buffer {
- color: @plyr-audio-progress-buffered-bg;
-}
-
-// Loading state
-.plyr--loading .plyr__progress--buffer {
- animation: plyr-progress 1s linear infinite;
- background-size: @plyr-progress-loading-size @plyr-progress-loading-size;
- background-repeat: repeat-x;
- background-image: linear-gradient(
- -45deg,
- @plyr-progress-loading-bg 25%,
- transparent 25%,
- transparent 50%,
- @plyr-progress-loading-bg 50%,
- @plyr-progress-loading-bg 75%,
- transparent 75%,
- transparent
- );
- color: transparent;
-}
-.plyr--video.plyr--loading .plyr__progress--buffer {
- background-color: @plyr-video-progress-buffered-bg;
-}
-.plyr--audio.plyr--loading .plyr__progress--buffer {
- background-color: @plyr-audio-progress-buffered-bg;
-}
-
-// Time
-// --------------------------------------------------------------
-.plyr__time {
- display: inline-block;
- vertical-align: middle;
- font-size: @plyr-font-size-small;
-}
-// Media duration hidden on small screens
-.plyr__time + .plyr__time {
- display: none;
-
- @media (min-width: @plyr-bp-screen-md) {
- display: inline-block;
- }
-
- // Add a slash in before
- &::before {
- content: '\2044';
- margin-right: @plyr-control-spacing;
- }
-}
-
-// Volume
-// --------------------------------------------------------------
-.plyr__volume {
- display: none;
-}
-.plyr .plyr__volume {
- flex: 1;
- position: relative;
-
- input[type='range'] {
- position: relative;
- z-index: 2;
- }
- @media (min-width: @plyr-bp-screen-sm) {
- display: block;
- max-width: 60px;
- }
- @media (min-width: @plyr-bp-screen-md) {
- max-width: 100px;
- }
-}
-
-// Hide sound controls on iOS
-// It's not supported to change volume using JavaScript:
-// https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
-.plyr--is-ios .plyr__volume,
-.plyr--is-ios [data-plyr='mute'] {
- display: none !important;
-}
-
-// Fullscreen
-// --------------------------------------------------------------
-.plyr--fullscreen-active {
- height: 100%;
- width: 100%;
- background: #000;
- border-radius: 0 !important;
-
- video {
- height: 100%;
- }
- .plyr__video-wrapper {
- height: 100%;
- width: 100%;
- }
- .plyr__video-embed {
- // Revert overflow change
- overflow: visible;
- }
-
- // Vimeo requires some different styling
- &.plyr--vimeo .plyr__video-wrapper {
- height: 0;
- top: 50%;
- transform: translateY(-50%);
- }
-}
-
-// Fallback for unsupported browsers
-.plyr--fullscreen-fallback.plyr--fullscreen-active {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 10000000;
-}
diff --git a/src/less/variables.less b/src/less/variables.less
deleted file mode 100644
index 807d0854..00000000
--- a/src/less/variables.less
+++ /dev/null
@@ -1,72 +0,0 @@
-// ==========================================================================
-// Plyr variables
-// https://github.com/selz/plyr
-// ==========================================================================
-
-// Settings
-@plyr-border-box: true;
-@plyr-touch-action: true;
-@plyr-sr-only-important: true;
-
-// Colors
-@plyr-color-main: #3498db;
-
-// Font
-@plyr-font-family: Avenir, 'Avenir Next', 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif;
-@plyr-font-size-small: 14px;
-@plyr-font-size-base: 16px;
-
-// Captions
-@plyr-captions-bg: fade(#000, 70%);
-@plyr-captions-color: #fff;
-@plyr-font-size-captions-base: @plyr-font-size-base;
-@plyr-font-size-captions-medium: ceil(@plyr-font-size-base * 1.5);
-@plyr-font-size-captions-large: (@plyr-font-size-base * 2);
-
-// Controls
-@plyr-control-icon-size: 18px;
-@plyr-control-spacing: 10px;
-@plyr-control-padding: (@plyr-control-spacing * .7);
-@plyr-video-controls-bg: #000;
-@plyr-video-control-color: #fff;
-@plyr-video-control-color-hover: #fff;
-@plyr-video-control-bg-hover: @plyr-color-main;
-@plyr-audio-controls-bg: #fff;
-@plyr-audio-controls-border: 1px solid #dbe3e8;
-@plyr-audio-control-color: #565D64;
-@plyr-audio-control-color-hover: #fff;
-@plyr-audio-control-bg-hover: @plyr-color-main;
-
-// Tooltips
-@plyr-tooltip-bg: fade(#000, 70%);
-@plyr-tooltip-color: #fff;
-@plyr-tooltip-padding: (@plyr-control-spacing / 2);
-@plyr-tooltip-arrow-size: 4px;
-@plyr-tooltip-radius: 3px;
-
-// Progress
-@plyr-progress-loading-size: 25px;
-@plyr-progress-loading-bg: fade(#000, 15%);
-@plyr-video-progress-bg: fade(#fff, 25%);
-@plyr-video-progress-buffered-bg: @plyr-video-progress-bg;
-@plyr-audio-progress-bg: fade(#C6D6DB, 66%);
-@plyr-audio-progress-buffered-bg: @plyr-audio-progress-bg;
-
-// Range sliders
-@plyr-range-track-height: 8px;
-@plyr-range-thumb-height: floor(@plyr-range-track-height * 2);
-@plyr-range-thumb-width: floor(@plyr-range-track-height * 2);
-@plyr-range-thumb-bg: #fff;
-@plyr-range-thumb-border: 2px solid transparent;
-@plyr-range-thumb-shadow: 0 1px 1px fade(@plyr-video-controls-bg, 15%), 0 0 0 1px fade(#000, 15%);
-@plyr-range-thumb-active-border-color: #fff;
-@plyr-range-thumb-active-bg: @plyr-video-control-bg-hover;
-@plyr-range-thumb-active-scale: 1.25;
-@plyr-video-range-track-bg: @plyr-video-progress-buffered-bg;
-@plyr-audio-range-track-bg: @plyr-audio-progress-buffered-bg;
-@plyr-range-selected-bg: @plyr-color-main;
-
-// Breakpoints
-@plyr-bp-screen-sm: 480px;
-@plyr-bp-screen-md: 768px;
-@plyr-bp-screen-lg: 1024px; \ No newline at end of file
diff --git a/src/sass/base.scss b/src/sass/base.scss
new file mode 100644
index 00000000..1a8070a0
--- /dev/null
+++ b/src/sass/base.scss
@@ -0,0 +1,59 @@
+// --------------------------------------------------------------
+// Base styling
+// --------------------------------------------------------------
+
+// Base
+.plyr {
+ @include plyr-font-smoothing($plyr-font-smoothing);
+
+ direction: ltr;
+ font-family: $plyr-font-family;
+ font-variant-numeric: tabular-nums; // Force monosace-esque number widths
+ font-weight: $plyr-font-weight-regular;
+ line-height: $plyr-line-height;
+ max-width: 100%;
+ min-width: 200px;
+ position: relative;
+ text-shadow: none;
+ transition: box-shadow 0.3s ease;
+
+ // Media elements
+ video,
+ audio {
+ border-radius: inherit;
+ height: auto;
+ vertical-align: middle;
+ width: 100%;
+ }
+
+ // Ignore focus
+ &:focus {
+ outline: 0;
+ }
+}
+
+// border-box everything
+// http://paulirish.com/2012/box-sizing-border-box-ftw/
+@if $plyr-border-box {
+ .plyr--full-ui {
+ box-sizing: border-box;
+
+ *,
+ *::after,
+ *::before {
+ box-sizing: inherit;
+ }
+ }
+}
+
+// Fix 300ms delay
+@if $plyr-touch-action {
+ .plyr--full-ui {
+ a,
+ button,
+ input,
+ label {
+ touch-action: manipulation;
+ }
+ }
+}
diff --git a/src/sass/components/badges.scss b/src/sass/components/badges.scss
new file mode 100644
index 00000000..3a9a28b5
--- /dev/null
+++ b/src/sass/components/badges.scss
@@ -0,0 +1,12 @@
+// --------------------------------------------------------------
+// Badges
+// --------------------------------------------------------------
+
+.plyr__badge {
+ background: $plyr-badge-bg;
+ border-radius: 2px;
+ color: $plyr-badge-color;
+ font-size: $plyr-font-size-badge;
+ line-height: 1;
+ padding: 3px 4px;
+}
diff --git a/src/sass/components/captions.scss b/src/sass/components/captions.scss
new file mode 100644
index 00000000..9dfc2be8
--- /dev/null
+++ b/src/sass/components/captions.scss
@@ -0,0 +1,58 @@
+// --------------------------------------------------------------
+// Captions
+// --------------------------------------------------------------
+
+// Hide default captions
+.plyr--full-ui ::-webkit-media-text-track-container {
+ display: none;
+}
+
+.plyr__captions {
+ animation: plyr-fade-in 0.3s ease;
+ bottom: 0;
+ color: $plyr-captions-color;
+ display: none;
+ font-size: $plyr-font-size-captions-small;
+ left: 0;
+ padding: $plyr-control-spacing;
+ position: absolute;
+ text-align: center;
+ transform: translateY(-($plyr-control-spacing * 4));
+ transition: transform 0.4s ease-in-out;
+ width: 100%;
+
+ span {
+ background: $plyr-captions-bg;
+ border-radius: 2px;
+ box-decoration-break: clone;
+ line-height: 185%;
+ padding: 0.2em 0.5em;
+ white-space: pre-wrap;
+
+ // Firefox adds a <div> when using getCueAsHTML()
+ div {
+ display: inline;
+ }
+ }
+
+ span:empty {
+ display: none;
+ }
+
+ @media (min-width: $plyr-bp-sm) {
+ font-size: $plyr-font-size-captions-base;
+ padding: ($plyr-control-spacing * 2);
+ }
+
+ @media (min-width: $plyr-bp-md) {
+ font-size: $plyr-font-size-captions-medium;
+ }
+}
+
+.plyr--captions-active .plyr__captions {
+ display: block;
+}
+
+.plyr--hide-controls .plyr__captions {
+ transform: translateY(-($plyr-control-spacing * 1.5));
+}
diff --git a/src/sass/components/control.scss b/src/sass/components/control.scss
new file mode 100644
index 00000000..52716805
--- /dev/null
+++ b/src/sass/components/control.scss
@@ -0,0 +1,89 @@
+// --------------------------------------------------------------
+// Control buttons
+// --------------------------------------------------------------
+
+.plyr__control {
+ background: transparent;
+ border: 0;
+ border-radius: $plyr-control-radius;
+ color: inherit;
+ cursor: pointer;
+ flex-shrink: 0;
+ overflow: visible; // IE11
+ padding: $plyr-control-padding;
+ position: relative;
+ transition: all 0.3s ease;
+
+ svg {
+ display: block;
+ fill: currentColor;
+ height: $plyr-control-icon-size;
+ pointer-events: none;
+ width: $plyr-control-icon-size;
+ }
+
+ // Default focus
+ &:focus {
+ outline: 0;
+ }
+
+ // Tab focus
+ &.plyr__tab-focus {
+ @include plyr-tab-focus();
+ }
+}
+
+// Change icons on state change
+.plyr__control[aria-pressed='false'] .icon--pressed,
+.plyr__control[aria-pressed='true'] .icon--not-pressed,
+.plyr__control[aria-pressed='false'] .label--pressed,
+.plyr__control[aria-pressed='true'] .label--not-pressed {
+ display: none;
+}
+
+// Audio styles
+.plyr--audio .plyr__control {
+ &.plyr__tab-focus,
+ &:hover,
+ &[aria-expanded='true'] {
+ background: $plyr-audio-control-bg-hover;
+ color: $plyr-audio-control-color-hover;
+ }
+}
+
+// Large play button (video only)
+.plyr__control--overlaid {
+ background: rgba($plyr-video-control-bg-hover, 0.8);
+ border: 0;
+ border-radius: 100%;
+ box-shadow: 0 1px 1px rgba(#000, 0.15);
+ color: $plyr-video-control-color;
+ display: none;
+ left: 50%;
+ padding: ceil($plyr-control-spacing * 1.5);
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ z-index: 2;
+
+ svg {
+ height: $plyr-control-icon-size-large;
+ left: 2px; // Offset to make the play button look right
+ position: relative;
+ width: $plyr-control-icon-size-large;
+ }
+
+ &:hover,
+ &:focus {
+ background: $plyr-video-control-bg-hover;
+ }
+}
+
+.plyr--playing .plyr__control--overlaid {
+ opacity: 0;
+ visibility: hidden;
+}
+
+.plyr--full-ui.plyr--video .plyr__control--overlaid {
+ display: block;
+}
diff --git a/src/sass/components/controls.scss b/src/sass/components/controls.scss
new file mode 100644
index 00000000..91db1b20
--- /dev/null
+++ b/src/sass/components/controls.scss
@@ -0,0 +1,105 @@
+// --------------------------------------------------------------
+// Controls
+// --------------------------------------------------------------
+
+// Hide native controls
+.plyr--full-ui ::-webkit-media-controls {
+ display: none;
+}
+
+// Playback controls
+.plyr__controls {
+ align-items: center;
+ display: flex;
+ text-align: center;
+
+ // Spacing
+ > .plyr__control,
+ .plyr__progress,
+ .plyr__time,
+ .plyr__menu {
+ margin-left: ($plyr-control-spacing / 2);
+
+ &:first-child,
+ &:first-child + [data-plyr='pause'] {
+ margin-left: 0;
+ }
+ }
+
+ .plyr__volume {
+ margin-left: ($plyr-control-spacing / 2);
+ }
+
+ @media (min-width: $plyr-bp-sm) {
+ > .plyr__control,
+ .plyr__progress,
+ .plyr__time,
+ .plyr__menu {
+ margin-left: $plyr-control-spacing;
+ }
+
+ > .plyr__control + .plyr__control,
+ .plyr__menu + .plyr__control,
+ > .plyr__control + .plyr__menu {
+ margin-left: ($plyr-control-spacing / 2);
+ }
+ }
+}
+
+// Video controls
+.plyr--video .plyr__controls {
+ background: linear-gradient(rgba($plyr-video-controls-bg, 0), rgba($plyr-video-controls-bg, 0.7));
+ border-bottom-left-radius: inherit;
+ border-bottom-right-radius: inherit;
+ bottom: 0;
+ color: $plyr-video-control-color;
+ left: 0;
+ padding: ($plyr-control-spacing * 3.5) $plyr-control-spacing $plyr-control-spacing;
+ position: absolute;
+ right: 0;
+ transition: opacity 0.4s ease-in-out, transform 0.4s ease-in-out;
+ z-index: 2;
+
+ .plyr__control {
+ svg {
+ filter: drop-shadow(0 1px 1px rgba(#000, 0.15));
+ }
+
+ // Hover and tab focus
+ &.plyr__tab-focus,
+ &:hover,
+ &[aria-expanded='true'] {
+ background: $plyr-video-control-bg-hover;
+ color: $plyr-video-control-color-hover;
+ }
+ }
+}
+
+// Audio controls
+.plyr--audio .plyr__controls {
+ background: $plyr-audio-controls-bg;
+ border-radius: inherit;
+ color: $plyr-audio-control-color;
+ padding: $plyr-control-spacing;
+}
+
+// Hide controls
+.plyr--video.plyr--hide-controls .plyr__controls {
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(100%);
+}
+
+// Some options are hidden by default
+.plyr [data-plyr='captions'],
+.plyr [data-plyr='pip'],
+.plyr [data-plyr='airplay'],
+.plyr [data-plyr='fullscreen'] {
+ display: none;
+}
+.plyr--captions-enabled [data-plyr='captions'],
+.plyr--pip-supported [data-plyr='pip'],
+.plyr--airplay-supported [data-plyr='airplay'],
+.plyr--fullscreen-enabled [data-plyr='fullscreen'] {
+ display: inline-block;
+}
diff --git a/src/sass/components/embed.scss b/src/sass/components/embed.scss
new file mode 100644
index 00000000..56916f17
--- /dev/null
+++ b/src/sass/components/embed.scss
@@ -0,0 +1,36 @@
+// --------------------------------------------------------------
+// Embedded players
+// YouTube, Vimeo, etc
+// --------------------------------------------------------------
+
+.plyr__video-embed {
+ // Default to 16:9 ratio but this is set by JavaScript based on config
+ $padding: ((100 / 16) * 9);
+ $height: 200;
+ $offset: to-percentage(($height - $padding) / ($height / 50));
+
+ height: 0;
+ padding-bottom: to-percentage($padding);
+ position: relative;
+
+ iframe {
+ border: 0;
+ height: 100%;
+ left: 0;
+ position: absolute;
+ top: 0;
+ user-select: none;
+ width: 100%;
+ }
+
+ // Vimeo hack
+ > div {
+ padding-bottom: to-percentage($height);
+ position: relative;
+ transform: translateY(-$offset);
+ }
+}
+// To allow mouse events to be captured if full support
+.plyr--full-ui .plyr__video-embed iframe {
+ pointer-events: none;
+}
diff --git a/src/sass/components/menus.scss b/src/sass/components/menus.scss
new file mode 100644
index 00000000..4ad67ec1
--- /dev/null
+++ b/src/sass/components/menus.scss
@@ -0,0 +1,198 @@
+// --------------------------------------------------------------
+// Menus
+// --------------------------------------------------------------
+
+.plyr__menu {
+ display: flex; // Edge fix
+ position: relative;
+
+ // Animate the icon
+ .plyr__control svg {
+ transition: transform 0.3s ease;
+ }
+ .plyr__control[aria-expanded='true'] {
+ svg {
+ transform: rotate(90deg);
+ }
+
+ // Hide tooltip
+ .plyr__tooltip {
+ display: none;
+ }
+ }
+
+ // The actual menu container
+ &__container {
+ animation: plyr-popup 0.2s ease;
+ background: $plyr-menu-bg;
+ border-radius: 4px;
+ bottom: 100%;
+ box-shadow: $plyr-menu-shadow;
+ color: $plyr-menu-color;
+ font-size: $plyr-font-size-base;
+ margin-bottom: 10px;
+ position: absolute;
+ right: -3px;
+ text-align: left;
+ white-space: nowrap;
+ z-index: 1;
+
+ > div {
+ overflow: hidden;
+ transition: height 0.35s cubic-bezier(0.4, 0, 0.2, 1), width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ // Arrow
+ &::after {
+ border: 4px solid transparent;
+ border-top-color: $plyr-menu-bg;
+ content: '';
+ height: 0;
+ position: absolute;
+ right: 15px;
+ top: 100%;
+ width: 0;
+ }
+
+ ul {
+ list-style: none;
+ margin: 0;
+ overflow: hidden;
+ padding: $plyr-control-padding;
+
+ li {
+ margin-top: 2px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+ }
+
+ // Options
+ .plyr__control {
+ align-items: center;
+ color: $plyr-menu-color;
+ display: flex;
+ padding: ceil($plyr-control-padding / 2) ($plyr-control-padding * 2);
+ user-select: none;
+ width: 100%;
+
+ &::after {
+ border: 4px solid transparent;
+ content: '';
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ transition: border-color 0.2s ease;
+ }
+
+ &--forward {
+ padding-right: ceil($plyr-control-padding * 4);
+
+ &::after {
+ border-left-color: rgba($plyr-menu-color, 0.8);
+ right: 5px;
+ }
+
+ &.plyr__tab-focus::after,
+ &:hover::after {
+ border-left-color: currentColor;
+ }
+ }
+
+ &--back {
+ $horizontal-padding: ($plyr-control-padding * 2);
+ font-weight: $plyr-font-weight-regular;
+ margin: $plyr-control-padding;
+ margin-bottom: floor($plyr-control-padding / 2);
+ padding-left: ceil($plyr-control-padding * 4);
+ position: relative;
+
+ width: calc(100% - #{$horizontal-padding});
+
+ &::after {
+ border-right-color: rgba($plyr-menu-color, 0.8);
+ left: $plyr-control-padding;
+ }
+
+ &::before {
+ background: $plyr-menu-border-color;
+ box-shadow: 0 1px 0 $plyr-menu-border-shadow-color;
+ content: '';
+ height: 1px;
+ left: 0;
+ margin-top: ceil($plyr-control-padding / 2);
+ overflow: hidden;
+ position: absolute;
+ right: 0;
+ top: 100%;
+ }
+
+ &.plyr__tab-focus::after,
+ &:hover::after {
+ border-right-color: currentColor;
+ }
+ }
+ }
+
+ label.plyr__control {
+ padding-left: $plyr-control-padding;
+
+ input[type='radio'] + span {
+ background: rgba(#000, 0.1);
+ border-radius: 100%;
+ display: block;
+ flex-shrink: 0;
+ height: 16px;
+ margin-right: $plyr-control-spacing;
+ position: relative;
+ transition: all 0.3s ease;
+ width: 16px;
+
+ &::after {
+ background: #fff;
+ border-radius: 100%;
+ content: '';
+ height: 6px;
+ left: 5px;
+ opacity: 0;
+ position: absolute;
+ top: 5px;
+ transform: scale(0);
+ transition: transform 0.3s ease, opacity 0.3s ease;
+ width: 6px;
+ }
+ }
+
+ input[type='radio']:checked + span {
+ background: $plyr-color-main;
+
+ &::after {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ input[type='radio']:focus + span {
+ @include plyr-tab-focus();
+ }
+
+ &.plyr__tab-focus input[type='radio'] + span,
+ &:hover input[type='radio'] + span {
+ background: rgba(#000, 0.1);
+ }
+ }
+
+ // Option value
+ .plyr__menu__value {
+ align-items: center;
+ display: flex;
+ margin-left: auto;
+ margin-right: -$plyr-control-padding;
+ overflow: hidden;
+ padding-left: ceil($plyr-control-padding * 3.5);
+ pointer-events: none;
+ }
+ }
+}
diff --git a/src/sass/components/progress.scss b/src/sass/components/progress.scss
new file mode 100644
index 00000000..7490ee17
--- /dev/null
+++ b/src/sass/components/progress.scss
@@ -0,0 +1,94 @@
+// --------------------------------------------------------------
+// Playback progress
+// --------------------------------------------------------------
+
+.plyr__progress {
+ display: flex;
+ flex: 1;
+ position: relative;
+
+ input[type='range'] {
+ position: relative;
+ z-index: 2;
+ }
+
+ // Seek tooltip to show time
+ .plyr__tooltip {
+ font-size: $plyr-font-size-time;
+ left: 0;
+ }
+}
+
+.plyr__progress--buffer {
+ -webkit-appearance: none; /* stylelint-disable-line */
+ background: transparent;
+ border: 0;
+ border-radius: 100px;
+ height: $plyr-range-track-height;
+ left: 0;
+ margin: -($plyr-range-track-height / 2) 0 0;
+ padding: 0;
+ position: absolute;
+ top: 50%;
+ width: 100%;
+
+ &::-webkit-progress-bar {
+ background: transparent;
+ transition: width 0.2s ease;
+ }
+
+ &::-webkit-progress-value {
+ background: currentColor;
+ border-radius: 100px;
+ min-width: $plyr-range-track-height;
+ }
+
+ // Mozilla
+ &::-moz-progress-bar {
+ background: currentColor;
+ border-radius: 100px;
+ min-width: $plyr-range-track-height;
+ transition: width 0.2s ease;
+ }
+
+ // Microsoft
+ &::-ms-fill {
+ border-radius: 100px;
+ transition: width 0.2s ease;
+ }
+}
+
+.plyr--video .plyr__progress--buffer {
+ box-shadow: 0 1px 1px rgba(#000, 0.15);
+ color: $plyr-video-progress-buffered-bg;
+}
+
+.plyr--audio .plyr__progress--buffer {
+ color: $plyr-audio-progress-buffered-bg;
+}
+
+// Loading state
+.plyr--loading .plyr__progress--buffer {
+ animation: plyr-progress 1s linear infinite;
+ background-image: linear-gradient(
+ -45deg,
+ $plyr-progress-loading-bg 25%,
+ transparent 25%,
+ transparent 50%,
+ $plyr-progress-loading-bg 50%,
+ $plyr-progress-loading-bg 75%,
+ transparent 75%,
+ transparent
+ );
+ background-repeat: repeat-x;
+ background-size: $plyr-progress-loading-size $plyr-progress-loading-size;
+ color: transparent;
+}
+
+.plyr--video.plyr--loading .plyr__progress--buffer {
+ background-color: $plyr-video-progress-buffered-bg;
+}
+
+.plyr--audio.plyr--loading .plyr__progress--buffer {
+ background-color: $plyr-audio-progress-buffered-bg;
+}
diff --git a/src/sass/components/sliders.scss b/src/sass/components/sliders.scss
new file mode 100644
index 00000000..62b8d7a9
--- /dev/null
+++ b/src/sass/components/sliders.scss
@@ -0,0 +1,154 @@
+// --------------------------------------------------------------
+// Slider inputs - <input type="range">
+// --------------------------------------------------------------
+
+.plyr--full-ui input[type='range'] {
+ // WebKit
+ -webkit-appearance: none; /* stylelint-disable-line */
+ background: transparent;
+ border: 0;
+ border-radius: ($plyr-range-thumb-height * 2);
+ // color is used in JS to populate lower fill for WebKit
+ color: $plyr-range-fill-bg;
+ display: block;
+ height: $plyr-range-max-height;
+ margin: 0;
+ padding: 0;
+ transition: box-shadow 0.3s ease;
+ width: 100%;
+
+ &::-webkit-slider-runnable-track {
+ @include plyr-range-track();
+ background-image: linear-gradient(to right, currentColor var(--value), transparent var(--value));
+ }
+
+ &::-webkit-slider-thumb {
+ @include plyr-range-thumb();
+ -webkit-appearance: none; /* stylelint-disable-line */
+ margin-top: -(($plyr-range-thumb-height - $plyr-range-track-height) / 2);
+ }
+
+ // Mozilla
+ &::-moz-range-track {
+ @include plyr-range-track();
+ }
+
+ &::-moz-range-thumb {
+ @include plyr-range-thumb();
+ }
+
+ &::-moz-range-progress {
+ background: currentColor;
+ border-radius: ($plyr-range-track-height / 2);
+ height: $plyr-range-track-height;
+ }
+
+ // Microsoft
+ &::-ms-track {
+ @include plyr-range-track();
+ color: transparent;
+ }
+
+ &::-ms-fill-upper {
+ @include plyr-range-track();
+ }
+
+ &::-ms-fill-lower {
+ @include plyr-range-track();
+
+ background: currentColor;
+ }
+
+ &::-ms-thumb {
+ @include plyr-range-thumb();
+ // For some reason, Edge uses the -webkit margin above
+ margin-top: 0;
+ }
+
+ &::-ms-tooltip {
+ display: none;
+ }
+
+ // Focus styles
+ &:focus {
+ outline: 0;
+ }
+
+ &::-moz-focus-outer {
+ border: 0;
+ }
+
+ &.plyr__tab-focus {
+ &::-webkit-slider-runnable-track {
+ @include plyr-tab-focus();
+ }
+
+ &::-moz-range-track {
+ @include plyr-tab-focus();
+ }
+
+ &::-ms-track {
+ @include plyr-tab-focus();
+ }
+ }
+}
+
+// Video range inputs
+.plyr--full-ui.plyr--video input[type='range'] {
+ &::-webkit-slider-runnable-track {
+ background-color: $plyr-video-range-track-bg;
+ }
+
+ &::-moz-range-track {
+ background-color: $plyr-video-range-track-bg;
+ }
+
+ &::-ms-track {
+ background-color: $plyr-video-range-track-bg;
+ }
+
+ // Pressed styles
+ &:active {
+ &::-webkit-slider-thumb {
+ @include plyr-range-thumb-active();
+ }
+
+ &::-moz-range-thumb {
+ @include plyr-range-thumb-active();
+ }
+
+ &::-ms-thumb {
+ @include plyr-range-thumb-active();
+ }
+ }
+}
+
+// Audio range inputs
+.plyr--full-ui.plyr--audio input[type='range'] {
+ &::-webkit-slider-runnable-track {
+ background-color: $plyr-audio-range-track-bg;
+ }
+
+ &::-moz-range-track {
+ background-color: $plyr-audio-range-track-bg;
+ }
+
+ &::-ms-track {
+ background-color: $plyr-audio-range-track-bg;
+ }
+
+ // Pressed styles
+ &:active {
+ &::-webkit-slider-thumb {
+ @include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color);
+ }
+
+ &::-moz-range-thumb {
+ @include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color);
+ }
+
+ &::-ms-thumb {
+ @include plyr-range-thumb-active($plyr-audio-range-thumb-shadow-color);
+ }
+ }
+}
diff --git a/src/sass/components/times.scss b/src/sass/components/times.scss
new file mode 100644
index 00000000..240d3528
--- /dev/null
+++ b/src/sass/components/times.scss
@@ -0,0 +1,24 @@
+// --------------------------------------------------------------
+// Time
+// --------------------------------------------------------------
+
+.plyr__time {
+ font-size: $plyr-font-size-time;
+}
+
+// Media duration hidden on small screens
+.plyr__time + .plyr__time {
+ // Add a slash in before
+ &::before {
+ content: '\2044';
+ margin-right: $plyr-control-spacing;
+ }
+
+ @media (max-width: $plyr-bp-sm-max) {
+ display: none;
+ }
+}
+
+.plyr--video .plyr__time {
+ text-shadow: 0 1px 1px rgba(#000, 0.15);
+}
diff --git a/src/sass/components/tooltips.scss b/src/sass/components/tooltips.scss
new file mode 100644
index 00000000..19a9ce56
--- /dev/null
+++ b/src/sass/components/tooltips.scss
@@ -0,0 +1,85 @@
+// --------------------------------------------------------------
+// Tooltips
+// --------------------------------------------------------------
+
+.plyr__tooltip {
+ background: $plyr-tooltip-bg;
+ border-radius: $plyr-tooltip-radius;
+ bottom: 100%;
+ box-shadow: $plyr-tooltip-shadow;
+ color: $plyr-tooltip-color;
+ font-size: $plyr-font-size-small;
+ font-weight: $plyr-font-weight-regular;
+ line-height: 1.3;
+ margin-bottom: ($plyr-tooltip-padding * 2);
+ opacity: 0;
+ padding: $plyr-tooltip-padding ($plyr-tooltip-padding * 1.5);
+ pointer-events: none;
+ position: absolute;
+ transform: translate(-50%, 10px) scale(0.8);
+ transform-origin: 50% 100%;
+ transition: transform 0.2s 0.1s ease, opacity 0.2s 0.1s ease;
+ z-index: 2;
+
+ // The background triangle
+ &::before {
+ border-left: $plyr-tooltip-arrow-size solid transparent;
+ border-right: $plyr-tooltip-arrow-size solid transparent;
+ border-top: $plyr-tooltip-arrow-size solid $plyr-tooltip-bg;
+ bottom: -$plyr-tooltip-arrow-size;
+ content: '';
+ height: 0;
+ left: 50%;
+ position: absolute;
+ transform: translateX(-50%);
+ width: 0;
+ z-index: 2;
+ }
+}
+
+// Displaying
+.plyr .plyr__control:hover .plyr__tooltip,
+.plyr .plyr__control.plyr__tab-focus .plyr__tooltip,
+.plyr__tooltip--visible {
+ opacity: 1;
+ transform: translate(-50%, 0) scale(1);
+}
+
+.plyr .plyr__control:hover .plyr__tooltip {
+ z-index: 3;
+}
+
+// First tooltip
+.plyr__controls > .plyr__control:first-child .plyr__tooltip,
+.plyr__controls > .plyr__control:first-child + .plyr__control .plyr__tooltip {
+ left: 0;
+ transform: translate(0, 10px) scale(0.8);
+ transform-origin: 0 100%;
+
+ &::before {
+ left: ($plyr-control-icon-size / 2) + $plyr-control-padding;
+ }
+}
+
+// Last tooltip
+.plyr__controls > .plyr__control:last-child .plyr__tooltip {
+ right: 0;
+ transform: translate(0, 10px) scale(0.8);
+ transform-origin: 100% 100%;
+
+ &::before {
+ left: auto;
+ right: ($plyr-control-icon-size / 2) + $plyr-control-padding;
+ transform: translateX(50%);
+ }
+}
+
+.plyr__controls > .plyr__control:first-child,
+.plyr__controls > .plyr__control:first-child + .plyr__control,
+.plyr__controls > .plyr__control:last-child {
+ &:hover .plyr__tooltip,
+ &.plyr__tab-focus .plyr__tooltip,
+ .plyr__tooltip--visible {
+ transform: translate(0, 0) scale(1);
+ }
+}
diff --git a/src/sass/components/video.scss b/src/sass/components/video.scss
new file mode 100644
index 00000000..3caf866d
--- /dev/null
+++ b/src/sass/components/video.scss
@@ -0,0 +1,21 @@
+// --------------------------------------------------------------
+// Video styles
+// --------------------------------------------------------------
+
+.plyr--video {
+ overflow: hidden;
+
+ // Menu open
+ &.plyr--menu-open {
+ overflow: visible;
+ }
+}
+
+.plyr__video-wrapper {
+ background: #000;
+ border-radius: inherit;
+ overflow: hidden;
+ position: relative;
+ // Require z-index to force border-radius
+ z-index: 0;
+}
diff --git a/src/sass/components/volume.scss b/src/sass/components/volume.scss
new file mode 100644
index 00000000..e12dc675
--- /dev/null
+++ b/src/sass/components/volume.scss
@@ -0,0 +1,29 @@
+// --------------------------------------------------------------
+// Volume
+// --------------------------------------------------------------
+
+.plyr__volume {
+ flex: 1;
+ position: relative;
+
+ input[type='range'] {
+ position: relative;
+ z-index: 2;
+ }
+
+ @media (min-width: $plyr-bp-sm) {
+ max-width: 50px;
+ }
+
+ @media (min-width: $plyr-bp-md) {
+ max-width: 80px;
+ }
+}
+
+// Hide sound controls on iOS
+// It's not supported to change volume using JavaScript:
+// https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
+.plyr--is-ios .plyr__volume,
+.plyr--is-ios [data-plyr='mute'] {
+ display: none !important;
+}
diff --git a/src/sass/lib/animation.scss b/src/sass/lib/animation.scss
new file mode 100644
index 00000000..b6c22d42
--- /dev/null
+++ b/src/sass/lib/animation.scss
@@ -0,0 +1,31 @@
+// --------------------------------------------------------------
+// Animations
+// --------------------------------------------------------------
+
+@keyframes plyr-progress {
+ to {
+ background-position: $plyr-progress-loading-size 0;
+ }
+}
+
+@keyframes plyr-popup {
+ 0% {
+ opacity: 0.5;
+ transform: translateY(10px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes plyr-fade-in {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
diff --git a/src/sass/lib/functions.scss b/src/sass/lib/functions.scss
new file mode 100644
index 00000000..a99a1b80
--- /dev/null
+++ b/src/sass/lib/functions.scss
@@ -0,0 +1,7 @@
+// ==========================================================================
+// Useful functions
+// ==========================================================================
+
+@function to-percentage($input) {
+ @return $input * 1%;
+}
diff --git a/src/sass/lib/mixins.scss b/src/sass/lib/mixins.scss
new file mode 100644
index 00000000..8b333f65
--- /dev/null
+++ b/src/sass/lib/mixins.scss
@@ -0,0 +1,101 @@
+// ==========================================================================
+// Mixins
+// ==========================================================================
+
+// Nicer focus styles
+// ---------------------------------------
+@mixin plyr-tab-focus($color: $plyr-tab-focus-default-color) {
+ box-shadow: 0 0 0 3px rgba($color, 0.35);
+ outline: 0;
+}
+
+// Font smoothing
+// ---------------------------------------
+@mixin plyr-font-smoothing($mode: true) {
+ @if $mode {
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+ } @else {
+ -moz-osx-font-smoothing: auto;
+ -webkit-font-smoothing: subpixel-antialiased;
+ }
+}
+
+// <input type="range"> styling
+// ---------------------------------------
+@mixin plyr-range-track() {
+ background: transparent;
+ border: 0;
+ border-radius: ($plyr-range-track-height / 2);
+ height: $plyr-range-track-height;
+ transition: all 0.3s ease;
+ user-select: none;
+}
+
+@mixin plyr-range-thumb() {
+ background: $plyr-range-thumb-bg;
+ border: 0;
+ border-radius: 100%;
+ box-shadow: $plyr-range-thumb-shadow;
+ box-sizing: border-box;
+ height: $plyr-range-thumb-height;
+ position: relative;
+ transition: all 0.2s ease;
+ width: $plyr-range-thumb-height;
+}
+
+@mixin plyr-range-thumb-active($color: rgba($plyr-range-thumb-bg, 0.5)) {
+ box-shadow: $plyr-range-thumb-shadow, 0 0 0 $plyr-range-thumb-active-shadow-width $color;
+}
+
+// Fullscreen styles
+// ---------------------------------------
+@mixin plyr-fullscreen-active() {
+ background: #000;
+ border-radius: 0 !important;
+ height: 100%;
+ margin: 0;
+ width: 100%;
+
+ video {
+ height: 100%;
+ }
+
+ .plyr__video-wrapper {
+ height: 100%;
+ width: 100%;
+ }
+
+ .plyr__video-embed {
+ // Revert overflow change
+ overflow: visible;
+ }
+
+ // Vimeo requires some different styling
+ &.plyr--vimeo .plyr__video-wrapper {
+ height: 0;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ // Display correct icon
+ .plyr__control .icon--exit-fullscreen {
+ display: block;
+
+ + svg {
+ display: none;
+ }
+ }
+
+ // Hide cursor in fullscreen when controls hidden
+ &.plyr--hide-controls {
+ cursor: none;
+ }
+
+ // Large captions in full screen on larger screens
+ @media (min-width: $plyr-bp-lg) {
+ .plyr__captions {
+ font-size: $plyr-font-size-captions-large;
+ }
+ }
+}
diff --git a/src/sass/plugins/ads.scss b/src/sass/plugins/ads.scss
new file mode 100644
index 00000000..c5acef75
--- /dev/null
+++ b/src/sass/plugins/ads.scss
@@ -0,0 +1,56 @@
+// ==========================================================================
+// Advertisements
+// ==========================================================================
+
+.plyr__ads {
+ border-radius: inherit;
+ bottom: 0;
+ cursor: pointer;
+ left: 0;
+ overflow: hidden;
+ position: absolute;
+ right: 0;
+ top: 0;
+ z-index: -1; // Hide it by default
+
+ // Make sure the inner container is big enough for the ad creative.
+ > div,
+ > div iframe {
+ height: 100%;
+ position: absolute;
+ width: 100%;
+ }
+
+ // The countdown label
+ &::after {
+ background: rgba($plyr-color-gunmetal, 0.8);
+ border-radius: 2px;
+ bottom: $plyr-control-spacing;
+ color: #fff;
+ content: attr(data-badge-text);
+ font-size: 11px;
+ padding: 2px 6px;
+ pointer-events: none;
+ position: absolute;
+ right: $plyr-control-spacing;
+ z-index: 3;
+ }
+
+ &::after:empty {
+ display: none;
+ }
+}
+
+// Advertisement cue's for the progress bar
+.plyr__cues {
+ background: currentColor;
+ display: block;
+ height: $plyr-range-track-height;
+ left: 0;
+ margin: -($plyr-range-track-height / 2) 0 0;
+ opacity: 0.8;
+ position: absolute;
+ top: 50%;
+ width: 3px;
+ z-index: 3; // Between progress and thumb
+}
diff --git a/src/sass/plyr.scss b/src/sass/plyr.scss
new file mode 100644
index 00000000..14856957
--- /dev/null
+++ b/src/sass/plyr.scss
@@ -0,0 +1,47 @@
+// ==========================================================================
+// Plyr styles
+// https://github.com/sampotts/plyr
+// TODO: Review use of BEM classnames
+// ==========================================================================
+@charset 'UTF-8';
+
+@import 'settings/breakpoints';
+@import 'settings/colors';
+@import 'settings/cosmetics';
+@import 'settings/type';
+
+@import 'settings/badges';
+@import 'settings/captions';
+@import 'settings/controls';
+@import 'settings/helpers';
+@import 'settings/menus';
+@import 'settings/progress';
+@import 'settings/sliders';
+@import 'settings/tooltips';
+
+@import 'lib/animation';
+@import 'lib/functions';
+@import 'lib/mixins';
+
+@import 'base';
+
+@import 'components/badges';
+@import 'components/captions';
+@import 'components/control';
+@import 'components/controls';
+@import 'components/embed';
+@import 'components/menus';
+@import 'components/progress';
+@import 'components/sliders';
+@import 'components/times';
+@import 'components/tooltips';
+@import 'components/video';
+@import 'components/volume';
+
+@import 'states/error';
+@import 'states/fullscreen';
+
+@import 'plugins/ads';
+
+@import 'utils/animation';
+@import 'utils/hidden';
diff --git a/src/sass/settings/badges.scss b/src/sass/settings/badges.scss
new file mode 100644
index 00000000..4f98c9a8
--- /dev/null
+++ b/src/sass/settings/badges.scss
@@ -0,0 +1,6 @@
+// ==========================================================================
+// Badges
+// ==========================================================================
+
+$plyr-badge-bg: $plyr-color-fiord !default;
+$plyr-badge-color: #fff !default;
diff --git a/src/sass/settings/breakpoints.scss b/src/sass/settings/breakpoints.scss
new file mode 100644
index 00000000..77a5b93a
--- /dev/null
+++ b/src/sass/settings/breakpoints.scss
@@ -0,0 +1,12 @@
+// ==========================================================================
+// Breakpoints
+// ==========================================================================
+
+$plyr-bp-sm: 480px !default;
+$plyr-bp-md: 768px !default;
+$plyr-bp-lg: 1024px !default;
+
+// Max-width media queries
+$plyr-bp-xs-max: ($plyr-bp-sm - 1);
+$plyr-bp-sm-max: ($plyr-bp-md - 1);
+$plyr-bp-md-max: ($plyr-bp-lg - 1);
diff --git a/src/sass/settings/captions.scss b/src/sass/settings/captions.scss
new file mode 100644
index 00000000..0c259046
--- /dev/null
+++ b/src/sass/settings/captions.scss
@@ -0,0 +1,10 @@
+// ==========================================================================
+// Captions
+// ==========================================================================
+
+$plyr-captions-bg: rgba(#000, 0.8) !default;
+$plyr-captions-color: #fff !default;
+$plyr-font-size-captions-base: $plyr-font-size-base !default;
+$plyr-font-size-captions-small: $plyr-font-size-small !default;
+$plyr-font-size-captions-medium: $plyr-font-size-large !default;
+$plyr-font-size-captions-large: $plyr-font-size-xlarge !default;
diff --git a/src/sass/settings/colors.scss b/src/sass/settings/colors.scss
new file mode 100644
index 00000000..c9ea580c
--- /dev/null
+++ b/src/sass/settings/colors.scss
@@ -0,0 +1,9 @@
+// ==========================================================================
+// Colors
+// ==========================================================================
+
+$plyr-color-main: #1aafff !default;
+$plyr-color-gunmetal: #2f343d !default;
+$plyr-color-fiord: #4f5b5f !default;
+$plyr-color-lynch: #6b7d85 !default;
+$plyr-color-heather: #b7c5cd !default;
diff --git a/src/sass/settings/controls.scss b/src/sass/settings/controls.scss
new file mode 100644
index 00000000..64f05cec
--- /dev/null
+++ b/src/sass/settings/controls.scss
@@ -0,0 +1,19 @@
+// ==========================================================================
+// Controls
+// ==========================================================================
+
+$plyr-control-icon-size: 18px !default;
+$plyr-control-icon-size-large: 20px !default;
+$plyr-control-spacing: 10px !default;
+$plyr-control-padding: ($plyr-control-spacing * 0.7) !default;
+$plyr-control-radius: 3px !default;
+
+$plyr-video-controls-bg: #000 !default;
+$plyr-video-control-color: #fff !default;
+$plyr-video-control-color-hover: #fff !default;
+$plyr-video-control-bg-hover: $plyr-color-main !default;
+
+$plyr-audio-controls-bg: #fff !default;
+$plyr-audio-control-color: $plyr-color-fiord !default;
+$plyr-audio-control-color-hover: #fff !default;
+$plyr-audio-control-bg-hover: $plyr-color-main !default;
diff --git a/src/sass/settings/cosmetics.scss b/src/sass/settings/cosmetics.scss
new file mode 100644
index 00000000..d6e4b86d
--- /dev/null
+++ b/src/sass/settings/cosmetics.scss
@@ -0,0 +1,5 @@
+// ==========================================================================
+// Cosmetic
+// ==========================================================================
+
+$plyr-tab-focus-default-color: $plyr-color-main !default;
diff --git a/src/sass/settings/helpers.scss b/src/sass/settings/helpers.scss
new file mode 100644
index 00000000..fd70f523
--- /dev/null
+++ b/src/sass/settings/helpers.scss
@@ -0,0 +1,7 @@
+// ==========================================================================
+// Enable helpers
+// ==========================================================================
+
+$plyr-border-box: true !default;
+$plyr-touch-action: true !default;
+$plyr-sr-only-important: true !default;
diff --git a/src/sass/settings/menus.scss b/src/sass/settings/menus.scss
new file mode 100644
index 00000000..64df9863
--- /dev/null
+++ b/src/sass/settings/menus.scss
@@ -0,0 +1,10 @@
+// ==========================================================================
+// Menus
+// ==========================================================================
+
+$plyr-menu-bg: rgba(#fff, 0.9) !default;
+$plyr-menu-color: $plyr-color-fiord !default;
+$plyr-menu-arrow-size: 6px !default;
+$plyr-menu-border-color: $plyr-color-heather !default;
+$plyr-menu-border-shadow-color: #fff !default;
+$plyr-menu-shadow: 0 1px 2px rgba(#000, 0.15) !default;
diff --git a/src/sass/settings/progress.scss b/src/sass/settings/progress.scss
new file mode 100644
index 00000000..074ee3c6
--- /dev/null
+++ b/src/sass/settings/progress.scss
@@ -0,0 +1,11 @@
+// ==========================================================================
+// Progress
+// ==========================================================================
+
+// Loading
+$plyr-progress-loading-size: 25px !default;
+$plyr-progress-loading-bg: rgba($plyr-color-gunmetal, 0.6) !default;
+
+// Buffered
+$plyr-video-progress-buffered-bg: rgba(#fff, 0.25) !default;
+$plyr-audio-progress-buffered-bg: rgba($plyr-color-heather, 0.66) !default;
diff --git a/src/sass/settings/sliders.scss b/src/sass/settings/sliders.scss
new file mode 100644
index 00000000..3c75b797
--- /dev/null
+++ b/src/sass/settings/sliders.scss
@@ -0,0 +1,24 @@
+// ==========================================================================
+// Sliders
+// ==========================================================================
+
+// Active state
+$plyr-range-thumb-active-shadow-width: 3px !default;
+
+// Thumb
+$plyr-range-thumb-height: 14px !default;
+$plyr-range-thumb-bg: #fff !default;
+$plyr-range-thumb-border: 2px solid transparent !default;
+$plyr-range-thumb-shadow: 0 1px 1px rgba(#000, 0.15), 0 0 0 1px rgba($plyr-color-gunmetal, 0.2) !default;
+
+// Track
+$plyr-range-track-height: 6px !default;
+$plyr-range-max-height: ($plyr-range-thumb-active-shadow-width * 2) + $plyr-range-thumb-height !default;
+
+// Fill
+$plyr-range-fill-bg: $plyr-color-main !default;
+
+// Type specific
+$plyr-video-range-track-bg: $plyr-video-progress-buffered-bg !default;
+$plyr-audio-range-track-bg: $plyr-audio-progress-buffered-bg !default;
+$plyr-audio-range-thumb-shadow-color: rgba(#000, 0.1) !default;
diff --git a/src/sass/settings/tooltips.scss b/src/sass/settings/tooltips.scss
new file mode 100644
index 00000000..fd304d60
--- /dev/null
+++ b/src/sass/settings/tooltips.scss
@@ -0,0 +1,10 @@
+// ==========================================================================
+// Tooltips
+// ==========================================================================
+
+$plyr-tooltip-bg: rgba(#fff, 0.9) !default;
+$plyr-tooltip-color: $plyr-color-fiord !default;
+$plyr-tooltip-padding: ($plyr-control-spacing / 2) !default;
+$plyr-tooltip-arrow-size: 4px !default;
+$plyr-tooltip-radius: 3px !default;
+$plyr-tooltip-shadow: 0 1px 2px rgba(#000, 0.15) !default;
diff --git a/src/sass/settings/type.scss b/src/sass/settings/type.scss
new file mode 100644
index 00000000..7c587446
--- /dev/null
+++ b/src/sass/settings/type.scss
@@ -0,0 +1,19 @@
+// ==========================================================================
+// Typography
+// ==========================================================================
+
+$plyr-font-family: Avenir, 'Avenir Next', 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif !default;
+$plyr-font-size-base: 16px !default;
+$plyr-font-size-small: 14px !default;
+$plyr-font-size-large: 18px !default;
+$plyr-font-size-xlarge: 21px !default;
+
+$plyr-font-size-time: 14px !default;
+$plyr-font-size-badge: 9px !default;
+
+$plyr-font-weight-regular: 500 !default;
+$plyr-font-weight-bold: 600 !default;
+
+$plyr-line-height: 1.7 !default;
+
+$plyr-font-smoothing: true !default;
diff --git a/src/sass/states/error.scss b/src/sass/states/error.scss
new file mode 100644
index 00000000..64d05c7b
--- /dev/null
+++ b/src/sass/states/error.scss
@@ -0,0 +1,25 @@
+// --------------------------------------------------------------
+// Error state
+// --------------------------------------------------------------
+
+.plyr--has-error {
+ pointer-events: none;
+
+ &::after {
+ align-items: center;
+ background: rgba(#000, 90%);
+ color: #fff;
+ content: attr(data-plyr-error);
+ display: flex;
+ font-size: $plyr-font-size-base;
+ height: 100%;
+ justify-content: center;
+ left: 0;
+ position: absolute;
+ text-align: center;
+ text-shadow: 0 1px 1px rgba(#000, 10%);
+ top: 0;
+ width: 100%;
+ z-index: 10;
+ }
+}
diff --git a/src/sass/states/fullscreen.scss b/src/sass/states/fullscreen.scss
new file mode 100644
index 00000000..5632a60f
--- /dev/null
+++ b/src/sass/states/fullscreen.scss
@@ -0,0 +1,34 @@
+// --------------------------------------------------------------
+// Fullscreen
+// --------------------------------------------------------------
+
+.plyr:fullscreen {
+ @include plyr-fullscreen-active();
+}
+
+/* stylelint-disable-next-line */
+.plyr:-webkit-full-screen {
+ @include plyr-fullscreen-active();
+}
+
+/* stylelint-disable-next-line */
+.plyr:-moz-full-screen {
+ @include plyr-fullscreen-active();
+}
+
+/* stylelint-disable-next-line */
+.plyr:-ms-fullscreen {
+ @include plyr-fullscreen-active();
+}
+
+// Fallback for unsupported browsers
+.plyr--fullscreen-fallback {
+ @include plyr-fullscreen-active();
+
+ bottom: 0;
+ left: 0;
+ position: fixed;
+ right: 0;
+ top: 0;
+ z-index: 10000000;
+}
diff --git a/src/sass/utils/animation.scss b/src/sass/utils/animation.scss
new file mode 100644
index 00000000..4a5b6b90
--- /dev/null
+++ b/src/sass/utils/animation.scss
@@ -0,0 +1,7 @@
+// --------------------------------------------------------------
+// Animation utils
+// --------------------------------------------------------------
+
+.plyr--no-transition {
+ transition: none !important;
+}
diff --git a/src/sass/utils/hidden.scss b/src/sass/utils/hidden.scss
new file mode 100644
index 00000000..f3b46147
--- /dev/null
+++ b/src/sass/utils/hidden.scss
@@ -0,0 +1,33 @@
+// --------------------------------------------------------------
+// Hiding content nicely
+// --------------------------------------------------------------
+
+// Attributes
+.plyr--full-ui [hidden] {
+ display: none;
+}
+
+.plyr--full-ui [aria-hidden='true'] {
+ display: none;
+}
+
+// Screen reader only elements
+.plyr__sr-only {
+ clip: rect(1px, 1px, 1px, 1px);
+ overflow: hidden;
+
+ // !important is not always needed
+ @if $plyr-sr-only-important {
+ border: 0 !important;
+ height: 1px !important;
+ padding: 0 !important;
+ position: absolute !important;
+ width: 1px !important;
+ } @else {
+ border: 0;
+ height: 1px;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+ }
+}
diff --git a/src/scss/mixins.scss b/src/scss/mixins.scss
deleted file mode 100644
index b4bfca6a..00000000
--- a/src/scss/mixins.scss
+++ /dev/null
@@ -1,29 +0,0 @@
-// ==========================================================================
-// Plyr mixins
-// https://github.com/selz/plyr
-// ==========================================================================
-
-// <input type="range"> styling
-@mixin plyr-range-track() {
- height: $plyr-range-track-height;
- background: transparent;
- border: 0;
- border-radius: ($plyr-range-track-height / 2);
- user-select: none;
-}
-@mixin plyr-range-thumb() {
- position: relative;
- height: $plyr-range-thumb-height;
- width: $plyr-range-thumb-width;
- background: $plyr-range-thumb-bg;
- border: $plyr-range-thumb-border;
- border-radius: 100%;
- transition: background .2s ease, border .2s ease, transform .2s ease;
- box-shadow: $plyr-range-thumb-shadow;
- box-sizing: border-box;
-}
-@mixin plyr-range-thumb-active() {
- background: $plyr-range-thumb-active-bg;
- border-color: $plyr-range-thumb-active-border-color;
- transform: scale($plyr-range-thumb-active-scale);
-} \ No newline at end of file
diff --git a/src/scss/plyr.scss b/src/scss/plyr.scss
deleted file mode 100644
index 11488ae4..00000000
--- a/src/scss/plyr.scss
+++ /dev/null
@@ -1,770 +0,0 @@
-// ==========================================================================
-// Plyr styles
-// https://github.com/selz/plyr
-// ==========================================================================
-
-@import "variables";
-@import "mixins";
-
-// Animation
-// ---------------------------------------
-@keyframes plyr-progress {
- to {
- background-position: $plyr-progress-loading-size 0;
- }
-}
-
-// Styles
-// -------------------------------
-// Base
-.plyr {
- position: relative;
- max-width: 100%;
- min-width: 200px;
- font-family: $plyr-font-family;
- direction: ltr;
-
- @if $plyr-border-box == true {
- // border-box everything
- // http://paulirish.com/2012/box-sizing-border-box-ftw/
- &,
- *,
- *::after,
- *::before {
- box-sizing: border-box;
- }
- }
-
- @if $plyr-touch-action == true {
- // Fix 300ms delay
- a,
- button,
- input,
- label {
- touch-action: manipulation;
- }
- }
-
- // Focus
- &:focus {
- outline: 0;
- }
-
- // Media elements
- video,
- audio {
- width: 100%;
- height: auto;
- vertical-align: middle;
- border-radius: inherit;
- }
-
- // Range inputs
- // Specificity is for bootstrap compatibility
- input[type="range"] {
- display: block;
- height: ($plyr-range-thumb-height * $plyr-range-thumb-active-scale);
- width: 100%;
- margin: 0;
- padding: 0;
- vertical-align: middle;
-
- appearance: none;
- cursor: pointer;
- border: none;
- background: transparent;
-
- // WebKit
- &::-webkit-slider-runnable-track {
- @include plyr-range-track();
- }
- &::-webkit-slider-thumb {
- -webkit-appearance: none;
- margin-top: -(($plyr-range-thumb-height - $plyr-range-track-height) / 2);
- @include plyr-range-thumb();
- }
-
- // Mozilla
- &::-moz-range-track {
- @include plyr-range-track();
- }
- &::-moz-range-thumb {
- @include plyr-range-thumb();
- }
-
- // Microsoft
- &::-ms-track {
- height: $plyr-range-track-height;
- background: transparent;
- border: 0;
- color: transparent;
- }
- &::-ms-fill-upper {
- @include plyr-range-track();
- }
- &::-ms-fill-lower {
- @include plyr-range-track();
- background: $plyr-range-selected-bg;
- }
- &::-ms-thumb {
- @include plyr-range-thumb();
- // For some reason, Edge uses the -webkit margin above
- margin-top: 0;
- }
- &::-ms-tooltip {
- display: none;
- }
-
- // Focus styles
- &:focus {
- outline: 0;
- }
- &::-moz-focus-outer {
- border: 0;
- }
- &.tab-focus:focus {
- outline-offset: 3px;
- }
-
- // Pressed styles
- &:active {
- &::-webkit-slider-thumb {
- @include plyr-range-thumb-active();
- }
- &::-moz-range-thumb {
- @include plyr-range-thumb-active();
- }
- &::-ms-thumb {
- @include plyr-range-thumb-active();
- }
- }
- }
-}
-
-// Video range inputs
-.plyr--video input[type="range"].tab-focus:focus {
- outline: 1px dotted transparentize($plyr-video-control-color, 0.5);
-}
-
-// Audio range inputs
-.plyr--audio input[type="range"].tab-focus:focus {
- outline: 1px dotted transparentize($plyr-audio-control-color, 0.5);
-}
-
-// Screen reader only elements
-.plyr__sr-only {
- clip: rect(1px, 1px, 1px, 1px);
- overflow: hidden;
-
- // !important is not always needed
- @if $plyr-sr-only-important == true {
- position: absolute !important;
- padding: 0 !important;
- border: 0 !important;
- height: 1px !important;
- width: 1px !important;
- } @else {
- position: absolute;
- padding: 0;
- border: 0;
- height: 1px;
- width: 1px;
- }
-}
-
-// Video
-.plyr__video-wrapper {
- position: relative;
- background: #000;
- border-radius: inherit;
-}
-
-// Container for embeds
-.plyr__video-embed {
- padding-bottom: 56.25%; /* 16:9 */
- height: 0;
- border-radius: inherit;
-
- // Require overflow and z-index to force border-radius
- overflow: hidden;
- z-index: 0;
-
- iframe {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- border: 0;
- user-select: none;
- }
-
- // Vimeo hack
- > div {
- position: relative;
- padding-bottom: 200%;
- transform: translateY(-35.95%);
- }
-}
-// To allow mouse events to be captured if full support
-.plyr .plyr__video-embed iframe {
- pointer-events: none;
-}
-
-// Captions
-// --------------------------------------------------------------
-// Hide default captions
-.plyr video::-webkit-media-text-track-container {
- display: none;
-}
-.plyr__captions {
- display: none;
- position: absolute;
- bottom: 0;
- left: 0;
- width: 100%;
- padding: ($plyr-control-spacing * 2);
- transform: translateY(-($plyr-control-spacing * 6));
- transition: transform 0.3s ease;
- color: $plyr-captions-color;
- font-size: $plyr-font-size-captions-base;
- text-align: center;
- font-weight: 400;
-
- span {
- border-radius: 2px;
- padding: floor($plyr-control-spacing / 3) $plyr-control-spacing;
- background: $plyr-captions-bg;
- box-decoration-break: clone;
- line-height: 150%;
- }
- span:empty {
- display: none;
- }
-
- @media (min-width: $plyr-bp-screen-md) {
- font-size: $plyr-font-size-captions-medium;
- }
-}
-.plyr--captions-active .plyr__captions {
- display: block;
-}
-.plyr--hide-controls .plyr__captions {
- transform: translateY(-($plyr-control-spacing * 2));
-}
-// Large captions in full screen on larger screens
-@media (min-width: $plyr-bp-screen-lg) {
- .plyr--fullscreen-active .plyr__captions {
- font-size: $plyr-font-size-captions-large;
- }
-}
-
-// Controls
-// --------------------------------------------------------------
-// Hide native controls
-.plyr ::-webkit-media-controls {
- display: none;
-}
-
-// Playback controls
-.plyr__controls {
- display: flex;
- align-items: center;
- line-height: 1;
- text-align: center;
- pointer-events: none;
-
- & > * {
- pointer-events: all;
- }
-
- // Spacing
- > button,
- .plyr__progress,
- .plyr__time {
- margin-left: ($plyr-control-spacing / 2);
-
- &:first-child {
- margin-left: 0;
- }
- }
- .plyr__volume {
- margin-left: ($plyr-control-spacing / 2);
- }
- [data-plyr="pause"] {
- margin-left: 0;
- }
-
- // Buttons
- button {
- position: relative;
- display: inline-block;
- flex-shrink: 0;
- overflow: visible; // IE11
- vertical-align: middle;
- padding: ($plyr-control-spacing * 0.7);
- border: 0;
- background: transparent;
- border-radius: 3px;
- cursor: pointer;
- transition: background 0.3s ease, color 0.3s ease, opacity 0.3s ease;
- color: inherit;
-
- svg {
- width: $plyr-control-icon-size;
- height: $plyr-control-icon-size;
- display: block;
- fill: currentColor;
- }
-
- // Default focus
- &:focus {
- outline: 0;
- }
- }
-
- // Hide toggle icons by default
- .icon--exit-fullscreen,
- .icon--muted,
- .icon--captions-on {
- display: none;
- }
-
- @media (min-width: $plyr-bp-screen-sm) {
- > button,
- .plyr__progress,
- .plyr__time {
- margin-left: $plyr-control-spacing;
- }
- }
-}
-// Hide controls
-.plyr--hide-controls .plyr__controls {
- opacity: 0;
- pointer-events: none;
-}
-
-// Video controls
-.plyr--video .plyr__controls {
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- padding: ($plyr-control-spacing * 5) $plyr-control-spacing $plyr-control-spacing;
- background: linear-gradient(
- transparentize($plyr-video-controls-bg, 1),
- transparentize($plyr-video-controls-bg, 0.5)
- );
- border-bottom-left-radius: inherit;
- border-bottom-right-radius: inherit;
- color: $plyr-video-control-color;
- transition: opacity 0.3s ease;
-
- button {
- // Hover and tab focus
- &.tab-focus:focus,
- &:hover {
- background: $plyr-video-control-bg-hover;
- color: $plyr-video-control-color-hover;
- }
- }
-}
-
-// Audio controls
-.plyr--audio .plyr__controls {
- padding: $plyr-control-spacing;
- border-radius: inherit;
- background: $plyr-audio-controls-bg;
- border: $plyr-audio-controls-border;
- color: $plyr-audio-control-color;
-
- button {
- // Hover and tab focus
- &.tab-focus:focus,
- &:hover {
- background: $plyr-audio-control-bg-hover;
- color: $plyr-audio-control-color-hover;
- }
- }
-}
-
-// Large play button (video only)
-.plyr__play-large {
- display: none;
- position: absolute;
- z-index: 1;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- padding: $plyr-control-spacing;
- background: $plyr-video-control-bg-hover;
- border: 4px solid currentColor;
- border-radius: 100%;
- box-shadow: 0 1px 1px transparentize(#000, 0.85);
- color: $plyr-video-control-color;
- transition: all 0.3s ease;
-
- svg {
- position: relative;
- left: 2px;
- width: 20px;
- height: 20px;
- display: block;
- fill: currentColor;
- }
-
- &:focus {
- outline: 1px dotted transparentize($plyr-video-control-color, 0.5);
- }
-}
-.plyr .plyr__play-large {
- display: inline-block;
-}
-.plyr--audio .plyr__play-large {
- display: none;
-}
-.plyr--playing .plyr__play-large {
- opacity: 0;
- visibility: hidden;
-}
-
-// States
-.plyr__controls [data-plyr="pause"],
-.plyr--playing .plyr__controls [data-plyr="play"] {
- display: none;
-}
-.plyr--playing .plyr__controls [data-plyr="pause"] {
- display: inline-block;
-}
-
-// Change icons on state change
-.plyr--fullscreen-active .icon--exit-fullscreen,
-.plyr--muted .plyr__controls .icon--muted,
-.plyr--captions-active .plyr__controls .icon--captions-on {
- display: block;
-
- & + svg {
- display: none;
- }
-}
-
-// Some options are hidden by default
-.plyr [data-plyr="captions"],
-.plyr [data-plyr="fullscreen"] {
- display: none;
-}
-.plyr--captions-enabled [data-plyr="captions"],
-.plyr--fullscreen-enabled [data-plyr="fullscreen"] {
- display: inline-block;
-}
-
-// Tooltips
-// --------------------------------------------------------------
-.plyr__tooltip {
- position: absolute;
- z-index: 2;
- bottom: 100%;
- margin-bottom: ($plyr-tooltip-padding * 2);
- padding: $plyr-tooltip-padding ($plyr-tooltip-padding * 1.5);
- pointer-events: none;
-
- opacity: 0;
- background: $plyr-tooltip-bg;
- border-radius: $plyr-tooltip-radius;
-
- color: $plyr-tooltip-color;
- font-size: $plyr-font-size-small;
- line-height: 1.3;
-
- transform: translate(-50%, 10px) scale(0.8);
- transform-origin: 50% 100%;
- transition: transform 0.2s 0.1s ease, opacity 0.2s 0.1s ease;
-
- &::before {
- // Arrows
- content: "";
- position: absolute;
- width: 0;
- height: 0;
- left: 50%;
- transform: translateX(-50%);
-
- // The background triangle
- bottom: -$plyr-tooltip-arrow-size;
- border-right: $plyr-tooltip-arrow-size solid transparent;
- border-top: $plyr-tooltip-arrow-size solid $plyr-tooltip-bg;
- border-left: $plyr-tooltip-arrow-size solid transparent;
- z-index: 2;
- }
-}
-.plyr button:hover .plyr__tooltip,
-.plyr button.tab-focus:focus .plyr__tooltip,
-.plyr__tooltip--visible {
- opacity: 1;
- transform: translate(-50%, 0) scale(1);
-}
-.plyr button:hover .plyr__tooltip {
- z-index: 3;
-}
-
-// First tooltip
-.plyr__controls button:first-child .plyr__tooltip {
- left: 0;
- transform: translate(0, 10px) scale(0.8);
- transform-origin: 0 100%;
-
- &::before {
- left: ($plyr-control-icon-size / 2) + $plyr-control-padding;
- }
-}
-
-// Last tooltip
-.plyr__controls button:last-child .plyr__tooltip {
- right: 0;
- transform: translate(0, 10px) scale(0.8);
- transform-origin: 100% 100%;
-
- &::before {
- left: auto;
- right: ($plyr-control-icon-size / 2) + $plyr-control-padding;
- transform: translateX(50%);
- }
-}
-
-.plyr__controls button:first-child,
-.plyr__controls button:last-child {
- &:hover .plyr__tooltip,
- &.tab-focus:focus .plyr__tooltip,
- .plyr__tooltip--visible {
- transform: translate(0, 0) scale(1);
- }
-}
-
-// Playback progress
-// --------------------------------------------------------------
-// <progress> element
-.plyr__progress {
- display: none;
- position: relative;
- flex: 1;
-
- input[type="range"] {
- position: relative;
- z-index: 2;
-
- &::-webkit-slider-runnable-track {
- background: transparent;
- }
- &::-moz-range-track {
- background: transparent;
- }
- &::-ms-fill-upper {
- background: transparent;
- }
- }
-
- // Seek tooltip to show time
- .plyr__tooltip {
- left: 0;
- }
-}
-.plyr .plyr__progress {
- display: inline-block;
-}
-
-.plyr__progress--buffer,
-.plyr__progress--played,
-.plyr__volume--display {
- position: absolute;
- left: 0;
- top: 50%;
- width: 100%;
- height: $plyr-range-track-height;
- margin: -($plyr-range-track-height / 2) 0 0;
- padding: 0;
- vertical-align: top;
- appearance: none;
- border: none;
- border-radius: 100px;
-
- &::-webkit-progress-bar {
- background: transparent;
- }
- &::-webkit-progress-value {
- background: currentColor;
- border-radius: 100px;
- min-width: $plyr-range-track-height;
- }
- &::-moz-progress-bar {
- background: currentColor;
- border-radius: 100px;
- min-width: $plyr-range-track-height;
- }
- &::-ms-fill {
- border-radius: 100px;
- }
-}
-.plyr__progress--played,
-.plyr__volume--display {
- z-index: 1;
- color: $plyr-range-selected-bg;
- background: transparent;
- transition: none;
-
- &::-webkit-progress-value {
- min-width: $plyr-range-track-height;
- max-width: 99%;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- transition: none;
- }
- &::-moz-progress-bar {
- min-width: $plyr-range-track-height;
- max-width: 99%;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- transition: none;
- }
- &::-ms-fill {
- display: none;
- }
-}
-.plyr__progress--buffer {
- &::-webkit-progress-value {
- transition: width 0.2s ease;
- }
- &::-moz-progress-bar {
- transition: width 0.2s ease;
- }
- &::-ms-fill {
- transition: width 0.2s ease;
- }
-}
-.plyr--video .plyr__progress--buffer,
-.plyr--video .plyr__volume--display {
- background: $plyr-video-range-track-bg;
-}
-.plyr--video .plyr__progress--buffer {
- color: $plyr-video-progress-buffered-bg;
-}
-.plyr--audio .plyr__progress--buffer,
-.plyr--audio .plyr__volume--display {
- background: $plyr-audio-range-track-bg;
-}
-.plyr--audio .plyr__progress--buffer {
- color: $plyr-audio-progress-buffered-bg;
-}
-
-// Loading state
-.plyr--loading .plyr__progress--buffer {
- animation: plyr-progress 1s linear infinite;
- background-size: $plyr-progress-loading-size $plyr-progress-loading-size;
- background-repeat: repeat-x;
- background-image: linear-gradient(
- -45deg,
- $plyr-progress-loading-bg 25%,
- transparent 25%,
- transparent 50%,
- $plyr-progress-loading-bg 50%,
- $plyr-progress-loading-bg 75%,
- transparent 75%,
- transparent
- );
- color: transparent;
-}
-.plyr--video.plyr--loading .plyr__progress--buffer {
- background-color: $plyr-video-progress-buffered-bg;
-}
-.plyr--audio.plyr--loading .plyr__progress--buffer {
- background-color: $plyr-audio-progress-buffered-bg;
-}
-
-// Time
-// --------------------------------------------------------------
-.plyr__time {
- display: inline-block;
- vertical-align: middle;
- font-size: $plyr-font-size-small;
-}
-// Media duration hidden on small screens
-.plyr__time + .plyr__time {
- display: none;
-
- @media (min-width: $plyr-bp-screen-md) {
- display: inline-block;
- }
-
- // Add a slash in before
- &::before {
- content: "\2044";
- margin-right: $plyr-control-spacing;
- }
-}
-
-// Volume
-// --------------------------------------------------------------
-.plyr__volume {
- display: none;
-}
-.plyr .plyr__volume {
- flex: 1;
- position: relative;
-
- input[type="range"] {
- position: relative;
- z-index: 2;
- }
- @media (min-width: $plyr-bp-screen-sm) {
- display: block;
- max-width: 60px;
- }
- @media (min-width: $plyr-bp-screen-md) {
- max-width: 100px;
- }
-}
-
-// Hide sound controls on iOS
-// It's not supported to change volume using JavaScript:
-// https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html
-.plyr--is-ios .plyr__volume,
-.plyr--is-ios [data-plyr="mute"] {
- display: none !important;
-}
-
-// Fullscreen
-// --------------------------------------------------------------
-.plyr--fullscreen-active {
- height: 100%;
- width: 100%;
- background: #000;
- border-radius: 0 !important;
-
- video {
- height: 100%;
- }
- .plyr__video-wrapper {
- height: 100%;
- width: 100%;
- }
- .plyr__video-embed {
- // Revert overflow change
- overflow: visible;
- }
-
- // Vimeo requires some different styling
- &.plyr--vimeo .plyr__video-wrapper {
- height: 0;
- top: 50%;
- transform: translateY(-50%);
- }
-}
-
-// Fallback for unsupported browsers
-.plyr--fullscreen-fallback.plyr--fullscreen-active {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 10000000;
-}
diff --git a/src/scss/variables.scss b/src/scss/variables.scss
deleted file mode 100644
index da75f11a..00000000
--- a/src/scss/variables.scss
+++ /dev/null
@@ -1,73 +0,0 @@
-// ==========================================================================
-// Plyr variables
-// https://github.com/selz/plyr
-// https://robots.thoughtbot.com/sass-default
-// ==========================================================================
-
-// Settings
-$plyr-border-box: true !default;
-$plyr-touch-action: true !default;
-$plyr-sr-only-important: true !default;
-
-// Colors
-$plyr-color-main: #3498db !default;
-
-// Font sizes
-$plyr-font-family: Avenir, 'Avenir Next', 'Helvetica Neue', 'Segoe UI', Helvetica, Arial, sans-serif !default;
-$plyr-font-size-small: 14px !default;
-$plyr-font-size-base: 16px !default;
-
-// Captions
-$plyr-captions-bg: transparentize(#000, .3) !default;
-$plyr-captions-color: #fff !default;
-$plyr-font-size-captions-base: $plyr-font-size-base !default;
-$plyr-font-size-captions-medium: ceil($plyr-font-size-base * 1.5) !default;
-$plyr-font-size-captions-large: ($plyr-font-size-base * 2) !default;
-
-// Controls
-$plyr-control-icon-size: 18px !default;
-$plyr-control-spacing: 10px !default;
-$plyr-control-padding: ($plyr-control-spacing * .7) !default;
-$plyr-video-controls-bg: #000 !default;
-$plyr-video-control-color: #fff !default;
-$plyr-video-control-color-hover: #fff !default;
-$plyr-video-control-bg-hover: $plyr-color-main !default;
-$plyr-audio-controls-bg: #fff !default;
-$plyr-audio-controls-border: 1px solid #dbe3e8 !default;
-$plyr-audio-control-color: #565D64 !default;
-$plyr-audio-control-color-hover: #fff !default;
-$plyr-audio-control-bg-hover: $plyr-color-main;
-
-// Tooltips
-$plyr-tooltip-bg: transparentize(#000, .3) !default;
-$plyr-tooltip-color: #fff !default;
-$plyr-tooltip-padding: ($plyr-control-spacing / 2) !default;
-$plyr-tooltip-arrow-size: 4px !default;
-$plyr-tooltip-radius: 3px !default;
-
-// Progress
-$plyr-progress-loading-size: 25px !default;
-$plyr-progress-loading-bg: transparentize(#000, .85) !default;
-$plyr-video-progress-bg: transparentize(#fff, .75) !default;
-$plyr-video-progress-buffered-bg: $plyr-video-progress-bg !default;
-$plyr-audio-progress-bg: transparentize(#C6D6DB, .33) !default;
-$plyr-audio-progress-buffered-bg: $plyr-audio-progress-bg !default;
-
-// Range sliders
-$plyr-range-track-height: 8px !default;
-$plyr-range-thumb-height: floor($plyr-range-track-height * 2) !default;
-$plyr-range-thumb-width: floor($plyr-range-track-height * 2) !default;
-$plyr-range-thumb-bg: #fff !default;
-$plyr-range-thumb-border: 2px solid transparent !default;
-$plyr-range-thumb-shadow: 0 1px 1px transparentize($plyr-video-controls-bg, .85), 0 0 0 1px transparentize(#000, .85) !default;
-$plyr-range-thumb-active-border-color: #fff !default;
-$plyr-range-thumb-active-bg: $plyr-video-control-bg-hover !default;
-$plyr-range-thumb-active-scale: 1.25 !default;
-$plyr-video-range-track-bg: $plyr-video-progress-buffered-bg !default;
-$plyr-audio-range-track-bg: $plyr-audio-progress-buffered-bg !default;
-$plyr-range-selected-bg: $plyr-color-main !default;
-
-// Breakpoints
-$plyr-bp-screen-sm: 480px !default;
-$plyr-bp-screen-md: 768px !default;
-$plyr-bp-screen-lg: 1024px !default; \ No newline at end of file
diff --git a/src/sprite/plyr-airplay.svg b/src/sprite/plyr-airplay.svg
new file mode 100644
index 00000000..45c55414
--- /dev/null
+++ b/src/sprite/plyr-airplay.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <g>
+ <path d="M16,1 L2,1 C1.447,1 1,1.447 1,2 L1,12 C1,12.553 1.447,13 2,13 L5,13 L5,11 L3,11 L3,3 L15,3 L15,11 L13,11 L13,13 L16,13 C16.553,13 17,12.553 17,12 L17,2 C17,1.447 16.553,1 16,1 L16,1 Z"></path>
+ <polygon points="4 17 14 17 9 11"></polygon>
+ </g>
+</svg> \ No newline at end of file
diff --git a/src/sprite/plyr-pip.svg b/src/sprite/plyr-pip.svg
new file mode 100644
index 00000000..d841fce5
--- /dev/null
+++ b/src/sprite/plyr-pip.svg
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <g>
+ <polygon points="13.293 3.293 7.022 9.564 8.436 10.978 14.707 4.707 17 7 17 1 11 1"></polygon>
+ <path d="M13,15 L3,15 L3,5 L8,5 L8,3 L2,3 C1.448,3 1,3.448 1,4 L1,16 C1,16.552 1.448,17 2,17 L14,17 C14.552,17 15,16.552 15,16 L15,10 L13,10 L13,15 L13,15 Z"></path>
+ </g>
+</svg> \ No newline at end of file
diff --git a/src/sprite/plyr-settings.svg b/src/sprite/plyr-settings.svg
new file mode 100644
index 00000000..fbf8ecd1
--- /dev/null
+++ b/src/sprite/plyr-settings.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="18px" height="18px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <g>
+ <path d="M16.135,7.784 C14.832,7.458 14.214,5.966 14.905,4.815 C15.227,4.279 15.13,3.817 14.811,3.499 L14.501,3.189 C14.183,2.871 13.721,2.774 13.185,3.095 C12.033,3.786 10.541,3.168 10.216,1.865 C10.065,1.258 9.669,1 9.219,1 L8.781,1 C8.331,1 7.936,1.258 7.784,1.865 C7.458,3.168 5.966,3.786 4.815,3.095 C4.279,2.773 3.816,2.87 3.498,3.188 L3.188,3.498 C2.87,3.816 2.773,4.279 3.095,4.815 C3.786,5.967 3.168,7.459 1.865,7.784 C1.26,7.935 1,8.33 1,8.781 L1,9.219 C1,9.669 1.258,10.064 1.865,10.216 C3.168,10.542 3.786,12.034 3.095,13.185 C2.773,13.721 2.87,14.183 3.189,14.501 L3.499,14.811 C3.818,15.13 4.281,15.226 4.815,14.905 C5.967,14.214 7.459,14.832 7.784,16.135 C7.935,16.742 8.331,17 8.781,17 L9.219,17 C9.669,17 10.064,16.742 10.216,16.135 C10.542,14.832 12.034,14.214 13.185,14.905 C13.72,15.226 14.182,15.13 14.501,14.811 L14.811,14.501 C15.129,14.183 15.226,13.72 14.905,13.185 C14.214,12.033 14.832,10.541 16.135,10.216 C16.742,10.065 17,9.669 17,9.219 L17,8.781 C17,8.33 16.74,7.935 16.135,7.784 L16.135,7.784 Z M9,12 C7.343,12 6,10.657 6,9 C6,7.343 7.343,6 9,6 C10.657,6 12,7.343 12,9 C12,10.657 10.657,12 9,12 L9,12 Z"></path>
+ </g>
+</svg> \ No newline at end of file