ES6-ified

This commit is contained in:
Sam Potts
2017-11-04 14:25:28 +11:00
parent 3d50936b47
commit 1cc2930dc0
38 changed files with 10144 additions and 11266 deletions

212
src/js/captions.js Normal file
View File

@ -0,0 +1,212 @@
// ==========================================================================
// Plyr Captions
// ==========================================================================
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
if (!utils.is.empty(this.storage.language)) {
this.captions.language = this.storage.language;
} else 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.enabled)) {
if (!utils.is.empty(this.storage.language)) {
this.captions.enabled = this.storage.captions;
} else {
this.captions.enabled = this.config.captions.active;
}
}
// Only Vimeo and HTML5 video supported at this point
if (!['video', 'vimeo'].includes(this.type) || (this.type === 'video' && !support.textTracks)) {
this.captions.tracks = null;
// Clear menu and hide
if (this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
controls.setCaptionsMenu.call(this);
}
return;
}
// Inject the container
if (!utils.is.htmlElement(this.elements.captions)) {
this.elements.captions = utils.createElement(
'div',
utils.getAttributesFromSelector(this.config.selectors.captions)
);
utils.insertAfter(this.elements.captions, this.elements.wrapper);
}
// Get tracks from HTML5
if (this.type === 'video') {
this.captions.tracks = this.media.textTracks;
}
// Set the class hook
utils.toggleClass(
this.elements.container,
this.config.classNames.captions.enabled,
!utils.is.empty(this.captions.tracks)
);
// If no caption file exists, hide container for caption text
if (utils.is.empty(this.captions.tracks)) {
return;
}
// Enable UI
captions.show.call(this);
// Get a track
const setCurrentTrack = () => {
// Reset by default
this.captions.currentTrack = null;
// Filter doesn't seem to work for a TextTrackList :-(
Array.from(this.captions.tracks).forEach(track => {
if (track.language === this.captions.language.toLowerCase()) {
this.captions.currentTrack = track;
}
});
};
// Get current track
setCurrentTrack();
// If we couldn't get the requested language, revert to default
if (!utils.is.track(this.captions.currentTrack)) {
const { language } = this.config.captions;
// Reset to default
// We don't update user storage as the selected language could become available
this.captions.language = language;
// Get fallback track
setCurrentTrack();
// If no match, disable captions
if (!utils.is.track(this.captions.currentTrack)) {
this.toggleCaptions(false);
}
controls.updateSetting.call(this, 'captions');
}
// Setup HTML5 track rendering
if (this.type === 'video') {
// Turn off native caption rendering to avoid double captions
Array.from(this.captions.tracks).forEach(track => {
// Remove previous bindings (if we've changed source or language)
utils.off(track, 'cuechange', event => captions.setCue.call(this, event));
// Hide captions
track.mode = 'hidden';
});
// Check if suported kind
const supported =
this.captions.currentTrack && ['captions', 'subtitles'].includes(this.captions.currentTrack.kind);
if (utils.is.track(this.captions.currentTrack) && supported) {
utils.on(this.captions.currentTrack, 'cuechange', event => captions.setCue.call(this, event));
// If we change the active track while a cue is already displayed we need to update it
if (this.captions.currentTrack.activeCues && this.captions.currentTrack.activeCues.length > 0) {
controls.setCue.call(this, this.captions.currentTrack);
}
}
} else if (this.type === 'vimeo' && this.captions.active) {
this.embed.enableTextTrack(this.captions.language);
}
// Set available languages in list
if (this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
controls.setCaptionsMenu.call(this);
}
},
// 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 active = track.activeCues[0];
// Display a cue, if there is one
if (utils.is.cue(active)) {
captions.set.call(this, active.getCueAsHTML());
} else {
captions.set.call(this);
}
utils.dispatchEvent.call(this, this.media, 'cuechange');
},
// Set the current caption
set(input) {
// Requires UI
if (!this.supported.ui) {
return;
}
if (utils.is.htmlElement(this.elements.captions)) {
const content = utils.createElement('span');
// Empty the container
utils.emptyElement(this.elements.captions);
// Default to empty
const caption = !utils.is.undefined(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.warn('No captions element to render to');
}
},
// Display captions container and button (for initialization)
show() {
// If there's no caption toggle, bail
if (!this.elements.buttons.captions) {
return;
}
// Try to load the value from storage
let active = this.storage.captions;
// Otherwise fall back to the default config
if (!utils.is.boolean(active)) {
({ active } = this.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;

1177
src/js/controls.js vendored Normal file

File diff suppressed because it is too large Load Diff

301
src/js/defaults.js Normal file
View File

@ -0,0 +1,301 @@
// Default config
const defaults = {
// Disable
enabled: true,
// Custom media title
title: '',
// Logging to console
debug: false,
// Auto play (if supported)
autoplay: false,
// Default time to skip when rewind/fast forward
seekTime: 10,
// Default volume
volume: 1,
muted: false,
// Display the media duration
displayDuration: true,
// Click video to play
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/2.0.10/plyr.svg',
// Blank video (used to prevent errors on source change)
blankVideo: 'https://cdn.plyr.io/static/blank.mp4',
// Pass a custom duration
duration: null,
// 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: {
default: 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
},
// 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', 'loop'],
// 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',
toggleMute: 'Toggle Mute',
toggleCaptions: 'Toggle Captions',
toggleFullscreen: 'Toggle 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',
},
// URLs
urls: {
vimeo: {
api: 'https://player.vimeo.com/api/player.js',
},
youtube: {
api: 'https://www.youtube.com/iframe_api',
},
},
// 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',
'captionchange',
'controlshidden',
'controlsshown',
'ready',
// YouTube
'statechange',
'qualitychange',
'qualityrequested',
],
// 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',
control: 'plyr__control',
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',
uiSupported: 'plyr--full-ui',
menu: {
value: 'plyr__menu__value',
badge: 'plyr__badge',
},
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: 'tab-focus',
},
};
export default defaults;

129
src/js/fullscreen.js Normal file
View File

@ -0,0 +1,129 @@
// ==========================================================================
// Plyr fullscreen API
// ==========================================================================
import utils from './utils';
// Determine the prefix
const prefix = (() => {
let value = false;
if (utils.is.function(document.cancelFullScreen)) {
value = '';
} else {
// Check for fullscreen support by vendor prefix
['webkit', 'o', 'moz', 'ms', 'khtml'].some(pre => {
if (utils.is.function(document[`${pre}CancelFullScreen`])) {
value = pre;
return true;
} else if (utils.is.function(document.msExitFullscreen) && document.msFullscreenEnabled) {
// Special case for MS (when isn't it?)
value = 'ms';
return true;
}
return false;
});
}
return value;
})();
// Fullscreen API
const fullscreen = {
// Get the prefix
prefix,
// Check if we can use it
enabled:
document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.mozFullScreenEnabled ||
document.msFullscreenEnabled,
// Yet again Microsoft awesomeness,
// Sometimes the prefix is 'ms', sometimes 'MS' to keep you on your toes
eventType: prefix === 'ms' ? 'MSFullscreenChange' : `${prefix}fullscreenchange`,
// Is an element fullscreen
isFullScreen(element) {
if (!fullscreen.enabled) {
return false;
}
const target = utils.is.undefined(element) ? document.body : element;
switch (prefix) {
case '':
return document.fullscreenElement === target;
case 'moz':
return document.mozFullScreenElement === target;
default:
return document[`${prefix}FullscreenElement`] === target;
}
},
// Make an element fullscreen
requestFullScreen(element) {
if (!fullscreen.enabled) {
return false;
}
const target = utils.is.undefined(element) ? document.body : element;
return !prefix.length
? target.requestFullScreen()
: target[prefix + (prefix === 'ms' ? 'RequestFullscreen' : 'RequestFullScreen')]();
},
// Bail from fullscreen
cancelFullScreen() {
if (!fullscreen.enabled) {
return false;
}
return !prefix.length
? document.cancelFullScreen()
: document[prefix + (prefix === 'ms' ? 'ExitFullscreen' : 'CancelFullScreen')]();
},
// Get the current element
element() {
if (!fullscreen.enabled) {
return null;
}
return !prefix.length ? document.fullscreenElement : document[`${prefix}FullscreenElement`];
},
// Setup fullscreen
setup() {
if (!this.supported.ui || this.type === 'audio' || !this.config.fullscreen.enabled) {
return;
}
// Check for native support
const nativeSupport = fullscreen.enabled;
if (nativeSupport || (this.config.fullscreen.fallback && !utils.inFrame())) {
this.log(`${nativeSupport ? 'Native' : 'Fallback'} fullscreen enabled`);
// Add styling hook to show button
utils.toggleClass(this.elements.container, this.config.classNames.fullscreen.enabled, true);
} else {
this.log('Fullscreen not supported and fallback disabled');
}
// Toggle state
if (this.elements.buttons && this.elements.buttons.fullscreen) {
utils.toggleState(this.elements.buttons.fullscreen, false);
}
// Trap focus in container
utils.trapFocus.call(this);
},
};
export default fullscreen;

569
src/js/listeners.js Normal file
View File

@ -0,0 +1,569 @@
// ==========================================================================
// Plyr Event Listeners
// ==========================================================================
import support from './support';
import utils from './utils';
import controls from './controls';
import fullscreen from './fullscreen';
import storage from './storage';
import ui from './ui';
const listeners = {
// Listen for media events
media() {
// Time change on media
utils.on(this.media, 'timeupdate seeking', event => ui.timeUpdate.call(this, event));
// Display duration
utils.on(this.media, 'durationchange loadedmetadata', event => ui.displayDuration.call(this, event));
// Handle the media finishing
utils.on(this.media, 'ended', () => {
// Show poster on end
if (this.type === 'video' && this.config.showPosterOnEnd) {
// Restart
this.restart();
// Re-load media
this.media.load();
}
});
// Check for buffer progress
utils.on(this.media, 'progress playing', event => ui.updateProgress.call(this, event));
// Handle native mute
utils.on(this.media, 'volumechange', event => ui.updateVolume.call(this, event));
// Handle native play/pause
utils.on(this.media, 'play pause ended', event => ui.checkPlaying.call(this, event));
// Loading
utils.on(this.media, 'waiting canplay seeked', event => ui.checkLoading.call(this, event));
// Click video
if (this.supported.ui && this.config.clickToPlay && this.type !== 'audio') {
// Re-fetch the wrapper
const wrapper = utils.getElement.call(this, `.${this.config.classNames.video}`);
// 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
utils.on(wrapper, 'click', () => {
// Touch devices will just show controls (if we're hiding controls)
if (this.config.hideControls && support.touch && !this.media.paused) {
return;
}
if (this.media.paused) {
this.play();
} else if (this.media.ended) {
this.restart();
this.play();
} else {
this.pause();
}
});
}
// Disable right click
if (this.config.disableContextMenu) {
utils.on(
this.media,
'contextmenu',
event => {
event.preventDefault();
},
false
);
}
// Speed change
utils.on(this.media, 'ratechange', () => {
// Update UI
controls.updateSetting.call(this, 'speed');
// Save speed to localStorage
storage.set.call(this, {
speed: this.speed,
});
});
// Quality change
utils.on(this.media, 'qualitychange', () => {
// Update UI
controls.updateSetting.call(this, 'quality');
// Save speed to localStorage
storage.set.call(this, {
quality: this.quality,
});
});
// Caption language change
utils.on(this.media, 'captionchange', () => {
// Save speed to localStorage
storage.set.call(this, {
language: this.captions.language,
});
});
// Captions toggle
utils.on(this.media, 'captionsenabled captionsdisabled', () => {
// Update UI
controls.updateSetting.call(this, 'captions');
// Save speed to localStorage
storage.set.call(this, {
captions: this.captions.enabled,
});
});
// Proxy events to container
// Bubble up key events for Edge
utils.on(this.media, this.config.events.concat(['keyup', 'keydown']).join(' '), event => {
utils.dispatchEvent.call(this, this.elements.container, event.type, true);
});
},
// Listen for control events
controls() {
// IE doesn't support input event, so we fallback to change
const inputEvent = this.browser.isIE ? 'change' : 'input';
let last = null;
// Click play/pause helper
const togglePlay = () => {
const play = this.togglePlay();
// Determine which buttons
const target = this.elements.buttons[play ? 'pause' : 'play'];
// Transfer focus
if (utils.is.htmlElement(target)) {
target.focus();
}
};
// Get the key code for an event
function getKeyCode(event) {
return event.keyCode ? event.keyCode : event.which;
}
function handleKey(event) {
const code = getKeyCode(event);
const pressed = event.type === 'keydown';
const held = pressed && code === last;
// 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
function seekByKey() {
// Divide the max duration into 10th's and times by the number value
this.currentTime = this.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,
];
const checkFocus = [38, 40];
if (checkFocus.includes(code)) {
const focused = utils.getFocusElement();
if (utils.is.htmlElement(focused) && utils.getFocusElement().type === 'radio') {
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 (!held) {
seekByKey();
}
break;
case 32:
case 75:
// Space and K key
if (!held) {
togglePlay();
}
break;
case 38:
// Arrow up
this.increaseVolume(0.1);
break;
case 40:
// Arrow down
this.decreaseVolume(0.1);
break;
case 77:
// M key
if (!held) {
this.toggleMute();
}
break;
case 39:
// Arrow forward
this.forward();
break;
case 37:
// Arrow back
this.rewind();
break;
case 70:
// F key
this.toggleFullscreen();
break;
case 67:
// C key
if (!held) {
this.toggleCaptions();
}
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 (!fullscreen.enabled && this.fullscreen.active && code === 27) {
this.toggleFullscreen();
}
// Store last code for next cycle
last = code;
} else {
last = null;
}
}
// Keyboard shortcuts
if (this.config.keyboard.focused) {
// Handle global presses
if (this.config.keyboard.global) {
utils.on(
window,
'keydown keyup',
event => {
const code = getKeyCode(event);
const focused = utils.getFocusElement();
const allowed = [48, 49, 50, 51, 52, 53, 54, 56, 57, 75, 77, 70, 67, 73, 76, 79];
// Only handle global key press if 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 (
allowed.includes(code) &&
(!utils.is.htmlElement(focused) || !utils.matches(focused, this.config.selectors.editable))
) {
handleKey(event);
}
},
false
);
}
// Handle presses on focused
utils.on(this.elements.container, 'keydown keyup', handleKey, false);
}
// Detect tab focus
// Remove class on blur/focusout
utils.on(this.elements.container, 'focusout', event => {
utils.toggleClass(event.target, this.config.classNames.tabFocus, false);
});
// Add classname to tabbed elements
utils.on(this.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
window.setTimeout(() => {
utils.toggleClass(utils.getFocusElement(), this.config.classNames.tabFocus, true);
}, 0);
});
// Trigger custom and default handlers
const handlerProxy = (event, customHandler, defaultHandler) => {
if (utils.is.function(customHandler)) {
customHandler.call(this, event);
}
if (utils.is.function(defaultHandler)) {
defaultHandler.call(this, event);
}
};
// Play
utils.proxy(this.elements.buttons.play, 'click', this.config.listeners.play, togglePlay);
utils.proxy(this.elements.buttons.playLarge, 'click', this.config.listeners.play, togglePlay);
// Pause
utils.proxy(this.elements.buttons.pause, 'click', this.config.listeners.pause, togglePlay);
// Pause
utils.proxy(this.elements.buttons.restart, 'click', this.config.listeners.restart, () => {
this.restart();
});
// Rewind
utils.proxy(this.elements.buttons.rewind, 'click', this.config.listeners.rewind, () => {
this.rewind();
});
// Rewind
utils.proxy(this.elements.buttons.forward, 'click', this.config.listeners.forward, () => {
this.forward();
});
// Mute
utils.proxy(this.elements.buttons.mute, 'click', this.config.listeners.mute, () => {
this.toggleMute();
});
// Captions
utils.proxy(this.elements.buttons.captions, 'click', this.config.listeners.captions, () => {
this.toggleCaptions();
});
// Fullscreen
utils.proxy(this.elements.buttons.fullscreen, 'click', this.config.listeners.fullscreen, () => {
this.toggleFullscreen();
});
// Picture-in-Picture
utils.proxy(this.elements.buttons.pip, 'click', this.config.listeners.pip, () => {
this.togglePictureInPicture();
});
// Airplay
utils.proxy(this.elements.buttons.airplay, 'click', this.config.listeners.airplay, () => {
this.airPlay();
});
// Settings menu
utils.on(this.elements.buttons.settings, 'click', event => {
controls.toggleMenu.call(this, event);
});
// Click anywhere closes menu
utils.on(document.documentElement, 'click', event => {
controls.toggleMenu.call(this, event);
});
// Settings menu
utils.on(this.elements.settings.form, 'click', event => {
// Show tab in menu
controls.showTab.call(this, event);
// Settings menu items - use event delegation as items are added/removed
// Settings - Language
if (utils.matches(event.target, this.config.selectors.inputs.language)) {
handlerProxy.call(this, event, this.config.listeners.language, () => {
this.toggleCaptions(true);
this.language = event.target.value.toLowerCase();
});
} else if (utils.matches(event.target, this.config.selectors.inputs.quality)) {
// Settings - Quality
handlerProxy.call(this, event, this.config.listeners.quality, () => {
this.quality = event.target.value;
});
} else if (utils.matches(event.target, this.config.selectors.inputs.speed)) {
// Settings - Speed
handlerProxy.call(this, event, this.config.listeners.speed, () => {
this.speed = parseFloat(event.target.value);
});
} else if (utils.matches(event.target, this.config.selectors.buttons.loop)) {
// Settings - Looping
// TODO: use toggle buttons
handlerProxy.call(this, event, this.config.listeners.loop, () => {
// TODO: This should be done in the method itself I think
// var value = event.target.getAttribute('data-loop__value') || event.target.getAttribute('data-loop__type');
this.warn('Set loop');
});
}
});
// Seek
utils.proxy(this.elements.inputs.seek, inputEvent, this.config.listeners.seek, event => {
this.currentTime = event.target.value / event.target.max * this.duration;
});
// Volume
utils.proxy(this.elements.inputs.volume, inputEvent, this.config.listeners.volume, event => {
this.setVolume(event.target.value);
});
// Polyfill for lower fill in <input type="range"> for webkit
if (this.browser.isWebkit) {
utils.on(utils.getElements.call(this, 'input[type="range"]'), 'input', event => {
controls.updateRangeFill.call(this, event.target);
});
}
// Seek tooltip
utils.on(this.elements.progress, 'mouseenter mouseleave mousemove', event =>
ui.updateSeekTooltip.call(this, event)
);
// Toggle controls visibility based on mouse movement
if (this.config.hideControls) {
// Toggle controls on mouse events and entering fullscreen
utils.on(
this.elements.container,
'mouseenter mouseleave mousemove touchstart touchend touchcancel touchmove enterfullscreen',
event => {
this.toggleControls(event);
}
);
// Watch for cursor over controls so they don't hide when trying to interact
utils.on(this.elements.controls, 'mouseenter mouseleave', event => {
this.elements.controls.hover = event.type === 'mouseenter';
});
// Watch for cursor over controls so they don't hide when trying to interact
utils.on(this.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
this.elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);
});
// Focus in/out on controls
// TODO: Check we need capture here
utils.on(
this.elements.controls,
'focus blur',
event => {
this.toggleControls(event);
},
true
);
}
// Mouse wheel for volume
utils.proxy(
this.elements.inputs.volume,
'wheel',
this.config.listeners.volume,
event => {
// 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.decreaseVolume(step);
direction = -1;
} else {
this.increaseVolume(step);
direction = 1;
}
}
// Scroll up (or down on natural) to increase
if (event.deltaY > 0 || event.deltaX < 0) {
if (inverted) {
this.increaseVolume(step);
direction = 1;
} else {
this.decreaseVolume(step);
direction = -1;
}
}
// Don't break page scrolling at max and min
if ((direction === 1 && this.media.volume < 1) || (direction === -1 && this.media.volume > 0)) {
event.preventDefault();
}
},
false
);
// Handle user exiting fullscreen by escaping etc
if (fullscreen.enabled) {
utils.on(document, fullscreen.eventType, event => {
this.toggleFullscreen(event);
});
}
},
};
export default listeners;

109
src/js/media.js Normal file
View File

@ -0,0 +1,109 @@
// ==========================================================================
// Plyr Media
// ==========================================================================
import support from './support';
import utils from './utils';
import youtube from './plugins/youtube';
import vimeo from './plugins/vimeo';
import ui from './ui';
const media = {
// Setup media
setup() {
// If there's no media, bail
if (!this.media) {
this.warn('No media element found!');
return;
}
// Add type class
utils.toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), 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.type === 'video'
);
// 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, this.browser.isIos);
// Add touch class
utils.toggleClass(this.elements.container, this.config.classNames.isTouch, support.touch);
}
// Inject the player wrapper
if (['video', 'youtube', 'vimeo'].includes(this.type)) {
// 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);
}
// Embeds
if (this.isEmbed) {
switch (this.type) {
case 'youtube':
youtube.setup.call(this);
break;
case 'vimeo':
vimeo.setup.call(this);
break;
default:
break;
}
}
ui.setTitle.call(this);
},
// Cancel current network requests
// See https://github.com/sampotts/plyr/issues/174
cancelRequests() {
if (!this.isHTML5) {
return;
}
// Remove child sources
Array.from(this.media.querySelectorAll('source')).forEach(utils.removeElement);
// 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.log('Cancelled network requests');
},
};
export default media;

165
src/js/plugins/vimeo.js Normal file
View File

@ -0,0 +1,165 @@
// ==========================================================================
// Vimeo plugin
// ==========================================================================
import utils from './../utils';
import captions from './../captions';
import ui from './../ui';
const vimeo = {
// Setup YouTube
setup() {
// Remove old containers
const containers = utils.getElements.call(this, `[id^="${this.type}-"]`);
Array.from(containers).forEach(utils.removeElement);
// Add embed class for responsive
utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
// Set ID
this.media.setAttribute('id', utils.generateId(this.type));
// Load the API if not already
if (!utils.is.object(window.Vimeo)) {
utils.loadScript(this.config.urls.vimeo.api);
// Wait for load
const vimeoTimer = window.setInterval(() => {
if (utils.is.object(window.Vimeo)) {
window.clearInterval(vimeoTimer);
vimeo.ready.call(this);
}
}, 50);
} else {
vimeo.ready.call(this);
}
},
// Ready
ready() {
const player = this;
// Get Vimeo params for the iframe
const options = {
loop: this.config.loop.active,
autoplay: this.config.autoplay,
byline: false,
portrait: false,
title: false,
transparent: 0,
};
const params = utils.buildUrlParameters(options);
const id = utils.parseVimeoId(this.embedId);
// Build an iframe
const iframe = utils.createElement('iframe');
const src = `https://player.vimeo.com/video/${id}?${params}`;
iframe.setAttribute('src', src);
iframe.setAttribute('allowfullscreen', '');
player.media.appendChild(iframe);
// Setup instance
// https://github.com/vimeo/this.js
player.embed = new window.Vimeo.Player(iframe);
// Create a faux HTML5 API using the Vimeo API
player.media.play = () => {
player.embed.play();
player.media.paused = false;
};
player.media.pause = () => {
player.embed.pause();
player.media.paused = true;
};
player.media.stop = () => {
player.embed.stop();
player.media.paused = true;
};
player.media.paused = true;
player.media.currentTime = 0;
// Rebuild UI
ui.build.call(player);
player.embed.getCurrentTime().then(value => {
player.media.currentTime = value;
utils.dispatchEvent.call(this, this.media, 'timeupdate');
});
player.embed.getDuration().then(value => {
player.media.duration = value;
utils.dispatchEvent.call(player, player.media, 'durationchange');
});
// Get captions
player.embed.getTextTracks().then(tracks => {
player.captions.tracks = 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.set.call(player, cue);
});
player.embed.on('loaded', () => {
if (utils.is.htmlElement(player.embed.element) && player.supported.ui) {
const frame = player.embed.element;
// Fix Vimeo controls issue
// https://github.com/sampotts/plyr/issues/697
// frame.src = `${frame.src}&transparent=0`;
// Fix keyboard focus issues
// https://github.com/sampotts/plyr/issues/317
frame.setAttribute('tabindex', -1);
}
});
player.embed.on('play', () => {
player.media.paused = false;
utils.dispatchEvent.call(player, player.media, 'play');
utils.dispatchEvent.call(player, player.media, 'playing');
});
player.embed.on('pause', () => {
player.media.paused = true;
utils.dispatchEvent.call(player, player.media, 'pause');
});
this.embed.on('timeupdate', data => {
this.media.seeking = false;
this.media.currentTime = data.seconds;
utils.dispatchEvent.call(this, this.media, 'timeupdate');
});
this.embed.on('progress', data => {
this.media.buffered = data.percent;
utils.dispatchEvent.call(this, this.media, 'progress');
if (parseInt(data.percent, 10) === 1) {
// Trigger event
utils.dispatchEvent.call(this, this.media, 'canplaythrough');
}
});
this.embed.on('seeked', () => {
this.media.seeking = false;
utils.dispatchEvent.call(this, this.media, 'seeked');
utils.dispatchEvent.call(this, this.media, 'play');
});
this.embed.on('ended', () => {
this.media.paused = true;
utils.dispatchEvent.call(this, this.media, 'ended');
});
},
};
export default vimeo;

256
src/js/plugins/youtube.js Normal file
View File

@ -0,0 +1,256 @@
// ==========================================================================
// YouTube plugin
// ==========================================================================
import utils from './../utils';
import controls from './../controls';
import ui from './../ui';
/* Object.defineProperty(input, "value", {
get: function() {return this._value;},
set: function(v) {
// Do your stuff
this._value = v;
}
}); */
const youtube = {
// Setup YouTube
setup() {
const videoId = utils.parseYouTubeId(this.embedId);
// Remove old containers
const containers = utils.getElements.call(this, `[id^="${this.type}-"]`);
Array.from(containers).forEach(utils.removeElement);
// Add embed class for responsive
utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
// Set ID
this.media.setAttribute('id', utils.generateId(this.type));
// Setup API
if (utils.is.object(window.YT)) {
youtube.ready.call(this, videoId);
} else {
// Load the API
utils.loadScript(this.config.urls.youtube.api);
// Setup callback for the API
window.onYouTubeReadyCallbacks = window.onYouTubeReadyCallbacks || [];
// Add to queue
window.onYouTubeReadyCallbacks.push(() => {
youtube.ready.call(this, videoId);
});
// Set callback to process queue
window.onYouTubeIframeAPIReady = () => {
window.onYouTubeReadyCallbacks.forEach(callback => {
callback();
});
};
}
},
// Handle YouTube API ready
ready(videoId) {
const player = this;
// Setup instance
// https://developers.google.com/youtube/iframe_api_reference
player.embed = new window.YT.Player(player.media.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.hostname,
widget_referrer: window && window.location.href,
// Captions is flaky on YouTube
// cc_load_policy: (this.captions.active ? 1 : 0),
// cc_lang_pref: 'en',
},
events: {
onError(event) {
utils.dispatchEvent.call(player, player.media, 'error', true, {
code: event.data,
embed: event.target,
});
},
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;
// Create a faux HTML5 API using the YouTube API
player.media.play = () => {
instance.playVideo();
player.media.paused = false;
};
player.media.pause = () => {
instance.pauseVideo();
player.media.paused = true;
};
player.media.stop = () => {
instance.stopVideo();
player.media.paused = true;
};
player.media.duration = instance.getDuration();
player.media.paused = true;
player.media.muted = instance.isMuted();
player.media.currentTime = 0;
// Get available speeds
if (player.config.controls.includes('settings') && player.config.settings.includes('speed')) {
controls.setSpeedMenu.call(player, instance.getAvailablePlaybackRates());
}
// Set title
player.config.title = instance.getVideoData().title;
// Set the tabindex to avoid focus entering iframe
if (player.supported.ui) {
player.media.setAttribute('tabindex', -1);
}
// Rebuild UI
ui.build.call(player);
utils.dispatchEvent.call(player, player.media, 'timeupdate');
utils.dispatchEvent.call(player, player.media, 'durationchange');
// Reset timer
window.clearInterval(player.timers.buffering);
// Setup buffering
player.timers.buffering = window.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) {
window.clearInterval(player.timers.buffering);
// Trigger event
utils.dispatchEvent.call(player, player.media, 'canplaythrough');
}
}, 200);
},
onStateChange(event) {
// Get the instance
const instance = event.target;
// Reset timer
window.clearInterval(player.timers.playing);
// Handle events
// -1 Unstarted
// 0 Ended
// 1 Playing
// 2 Paused
// 3 Buffering
// 5 Video cued
switch (event.data) {
case 0:
// YouTube doesn't support loop for a single video, so mimick it.
if (player.config.loop.active) {
// YouTube needs a call to `stopVideo` before playing again
instance.stopVideo();
instance.playVideo();
break;
}
player.media.paused = true;
utils.dispatchEvent.call(player, player.media, 'ended');
break;
case 1:
player.media.paused = false;
// If we were seeking, fire seeked event
if (player.media.seeking) {
utils.dispatchEvent.call(player, player.media, 'seeked');
}
player.media.seeking = false;
utils.dispatchEvent.call(player, player.media, 'play');
utils.dispatchEvent.call(player, player.media, 'playing');
// Poll to get playback progress
player.timers.playing = window.setInterval(() => {
player.media.currentTime = instance.getCurrentTime();
utils.dispatchEvent.call(player, player.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 (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;

File diff suppressed because it is too large Load Diff

162
src/js/source.js Normal file
View File

@ -0,0 +1,162 @@
// ==========================================================================
// Plyr source update
// ==========================================================================
import types 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)) {
this.warn(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.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.htmlElement(this.elements.container)) {
this.elements.container.removeAttribute('class');
}
// Set the type
if ('type' in input) {
this.type = input.type;
// Get child type for video (it might be an embed)
if (this.type === 'video') {
const firstSource = input.sources[0];
if ('type' in firstSource && types.embed.includes(firstSource.type)) {
this.type = firstSource.type;
}
}
}
// Check for support
this.supported = support.check(this.type, this.config.inline);
// Create new markup
switch (this.type) {
case 'video':
this.media = utils.createElement('video');
break;
case 'audio':
this.media = utils.createElement('audio');
break;
case 'youtube':
case 'vimeo':
this.media = utils.createElement('div');
this.embedId = 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 hooks
utils.toggleClass(
this.elements.container,
this.config.classNames.captions.active,
this.supported.ui && this.captions.enabled
);
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);
}
},
true
);
},
};
export default source;

56
src/js/storage.js Normal file
View File

@ -0,0 +1,56 @@
// ==========================================================================
// Plyr storage
// ==========================================================================
import support from './support';
import utils from './utils';
// Save a value back to local storage
function set(value) {
// Bail if we don't have localStorage support or it's disabled
if (!support.storage || !this.config.storage.enabled) {
return;
}
// Update the working copy of the values
utils.extend(this.storage, value);
// Update storage
window.localStorage.setItem(this.config.storage.key, JSON.stringify(this.storage));
}
// Setup localStorage
function setup() {
let value = null;
let storage = {};
// Bail if we don't have localStorage support or it's disabled
if (!support.storage || !this.config.storage.enabled) {
return storage;
}
// 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(this.config.storage.key);
if (!value) {
// Key wasn't set (or had been cleared), move along
} else if (/^\d+(\.\d+)?$/.test(value)) {
// If value is a number, it's probably volume from an older
// version of this. See: https://github.com/sampotts/plyr/pull/313
// Update the key to be JSON
set({
volume: parseFloat(value),
});
} else {
// Assume it's JSON from this or a later version of plyr
storage = JSON.parse(value);
}
return storage;
}
export default { setup, set };

174
src/js/support.js Normal file
View File

@ -0,0 +1,174 @@
// ==========================================================================
// 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, inline) {
let api = false;
let ui = false;
const browser = utils.getBrowser();
const playsInline = browser.isIPhone && inline && support.inline;
switch (type) {
case 'video':
api = support.video;
ui = api && support.rangeInput && (!browser.isIPhone || playsInline);
break;
case 'audio':
api = support.audio;
ui = api && support.rangeInput;
break;
case 'youtube':
api = true;
ui = support.rangeInput && (!browser.isIPhone || playsInline);
break;
case 'vimeo':
api = true;
ui = support.rangeInput && !browser.isIPhone;
break;
default:
api = support.audio && support.video;
ui = api && support.rangeInput;
}
return {
api,
ui,
};
},
// Local storage
// We can't assume if local storage is present that we can use it
storage: (() => {
if (!('localStorage' in window)) {
return false;
}
// Try to use it (it might be disabled, e.g. user is in private/porn mode)
// see: https://github.com/sampotts/plyr/issues/131
const test = '___test';
try {
window.localStorage.setItem(test, test);
window.localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
})(),
// 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(player, type) {
const media = { player };
try {
// Bail if no checking function
if (!utils.is.function(media.canPlayType)) {
return false;
}
// Type specific checks
if (player.type === 'video') {
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 (player.type === 'audio') {
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.transitionEnd !== 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;

10
src/js/types.js Normal file
View File

@ -0,0 +1,10 @@
// ==========================================================================
// Plyr supported types
// ==========================================================================
const types = {
embed: ['youtube', 'vimeo'],
html5: ['video', 'audio'],
};
export default types;

381
src/js/ui.js Normal file
View File

@ -0,0 +1,381 @@
// ==========================================================================
// Plyr UI
// ==========================================================================
import utils from './utils';
import captions from './captions';
import controls from './controls';
import fullscreen from './fullscreen';
import listeners from './listeners';
import storage from './storage';
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) {
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
listeners.media.call(this);
// Don't setup interface if no support
if (!this.supported.ui) {
this.warn(`Basic support only for ${this.type}`);
// Remove controls
utils.removeElement.call(this, 'controls');
// Remove large play
utils.removeElement.call(this, 'buttons.play');
// Restore native controls
ui.toggleNativeControls.call(this, true);
// Bail
return;
}
// Inject custom controls if not present
if (!utils.is.htmlElement(this.elements.controls)) {
// Inject custom controls
controls.inject.call(this);
// Re-attach control listeners
listeners.controls.call(this);
}
// If there's no controls, bail
if (!utils.is.htmlElement(this.elements.controls)) {
return;
}
// Remove native controls
ui.toggleNativeControls.call(this);
// Setup fullscreen
fullscreen.setup.call(this);
// Captions
captions.setup.call(this);
// Set volume
this.volume = null;
ui.updateVolume.call(this);
// Set playback speed
this.speed = null;
// Set loop
// this.setLoop();
// Reset time display
ui.timeUpdate.call(this);
// Update the UI
ui.checkPlaying.call(this);
this.ready = true;
// Ready event at end of execution stack
utils.dispatchEvent.call(this, this.media, 'ready');
// Autoplay
if (this.config.autoplay) {
this.play();
}
},
// Show the duration on metadataloaded
displayDuration() {
if (!this.supported.ui) {
return;
}
// If there's only one time display, display duration there
if (!this.elements.display.duration && this.config.displayDuration && this.media.paused) {
ui.updateTimeDisplay.call(this, this.duration, this.elements.display.currentTime);
}
// If there's a duration element, update content
if (this.elements.display.duration) {
ui.updateTimeDisplay.call(this, this.duration, this.elements.display.duration);
}
// Update the tooltip (if visible)
ui.updateSeekTooltip.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 (this.supported.ui) {
if (utils.is.htmlElement(this.elements.buttons.play)) {
this.elements.buttons.play.setAttribute('aria-label', label);
}
if (utils.is.htmlElement(this.elements.buttons.playLarge)) {
this.elements.buttons.playLarge.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.htmlElement(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() {
utils.toggleClass(this.elements.container, this.config.classNames.playing, !this.media.paused);
utils.toggleClass(this.elements.container, this.config.classNames.stopped, this.media.paused);
this.toggleControls(this.media.paused);
},
// Update volume UI and storage
updateVolume() {
// Update the <input type="range"> if present
if (this.supported.ui) {
const value = this.media.muted ? 0 : this.media.volume;
if (this.elements.inputs.volume) {
ui.setRange.call(this, this.elements.inputs.volume, value);
}
}
// Update the volume in storage
storage.set.call(this, {
volume: this.media.volume,
});
// Toggle class if muted
utils.toggleClass(this.elements.container, this.config.classNames.muted, this.media.muted);
// Update checkbox for mute state
if (this.supported.ui && this.elements.buttons.mute) {
utils.toggleState(this.elements.buttons.mute, this.media.muted);
}
},
// Check if media is loading
checkLoading(event) {
this.loading = event.type === 'waiting';
// 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);
},
// Update seek value and lower fill
setRange(target, value) {
if (!utils.is.htmlElement(target)) {
return;
}
target.value = value;
// Webkit range fill
controls.updateRangeFill.call(this, target);
},
// Set <progress> value
setProgress(target, input) {
// Default to 0
const value = !utils.is.undefined(input) ? input : 0;
const progress = !utils.is.undefined(target) ? target : this.elements.display.buffer;
// Update value and label
if (utils.is.htmlElement(progress)) {
progress.value = value;
// Update text label inside
const label = progress.getElementsByTagName('span')[0];
if (utils.is.htmlElement(label)) {
label.childNodes[0].nodeValue = value;
}
}
},
// Update <progress> elements
updateProgress(event) {
if (!this.supported.ui) {
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':
value = (() => {
const { buffered } = this.media;
if (buffered && buffered.length) {
// HTML5
return utils.getPercentage(buffered.end(0), this.duration);
} else if (utils.is.number(buffered)) {
// YouTube returns between 0 and 1
return buffered * 100;
}
return 0;
})();
ui.setProgress.call(this, this.elements.display.buffer, value);
break;
default:
break;
}
}
},
// Update the displayed time
updateTimeDisplay(value, element) {
// Bail if there's no duration display
if (!utils.is.htmlElement(element)) {
return null;
}
// Fallback to 0
const time = !Number.isNaN(value) ? value : 0;
let secs = parseInt(time % 60, 10);
let mins = parseInt((time / 60) % 60, 10);
const hours = parseInt((time / 60 / 60) % 60, 10);
// Do we need to display hours?
const displayHours = parseInt((this.duration / 60 / 60) % 60, 10) > 0;
// Ensure it's two digits. For example, 03 rather than 3.
secs = `0${secs}`.slice(-2);
mins = `0${mins}`.slice(-2);
// Generate display
const display = `${(displayHours ? `${hours}:` : '') + mins}:${secs}`;
// Render
element.textContent = display;
// Return for looping
return display;
},
// Handle time change event
timeUpdate(event) {
// Duration
ui.updateTimeDisplay.call(this, this.currentTime, this.elements.display.currentTime);
// Ignore updates while seeking
if (event && event.type === 'timeupdate' && this.media.seeking) {
return;
}
// Playing progress
ui.updateProgress.call(this, event);
},
// Update hover tooltip for seeking
updateSeekTooltip(event) {
// Bail if setting not true
if (
!this.config.tooltips.seek ||
!utils.is.htmlElement(this.elements.inputs.seek) ||
!utils.is.htmlElement(this.elements.display.seekTooltip) ||
this.duration === 0
) {
return;
}
// Calculate percentage
const clientRect = this.elements.inputs.seek.getBoundingClientRect();
let percent = 0;
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 = this.elements.display.seekTooltip.style.left.replace('%', '');
} 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.duration / 100 * percent, this.elements.display.seekTooltip);
// 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');
}
},
};
export default ui;

667
src/js/utils.js Normal file
View File

@ -0,0 +1,667 @@
// ==========================================================================
// Plyr utils
// ==========================================================================
import support from './support';
const utils = {
// Check variable types
is: {
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.undefined(input) && Array.isArray(input);
},
nodeList(input) {
return !this.undefined(input) && input instanceof NodeList;
},
htmlElement(input) {
return !this.undefined(input) && input instanceof HTMLElement;
},
event(input) {
return !this.undefined(input) && input instanceof Event;
},
cue(input) {
return this.instanceOf(input, window.TextTrackCue) || this.instanceOf(input, window.VTTCue);
},
track(input) {
return (
!this.undefined(input) && (this.instanceOf(input, window.TextTrack) || typeof input.kind === 'string')
);
},
undefined(input) {
return input !== null && typeof input === 'undefined';
},
empty(input) {
return (
input === null ||
typeof input === 'undefined' ||
((this.string(input) || this.array(input) || this.nodeList(input)) && input.length === 0) ||
(this.object(input) && Object.keys(input).length === 0)
);
},
getConstructor(input) {
if (input === null || typeof input === 'undefined') {
return null;
}
return input.constructor;
},
instanceOf(input, constructor) {
return Boolean(input && constructor && input instanceof constructor);
},
},
// 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),
};
},
// Load an external script
loadScript(url) {
// Check script is not already referenced
if (document.querySelectorAll(`script[src="${url}"]`).length) {
return;
}
const tag = document.createElement('script');
tag.src = url;
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
},
// 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);
}
});
},
// Remove an element
removeElement(element) {
if (!utils.is.htmlElement(element) || !utils.is.htmlElement(element.parentNode)) {
return null;
}
element.parentNode.removeChild(element);
return element;
},
// Inaert an element after another
insertAfter(element, target) {
target.parentNode.insertBefore(element, target.nextSibling);
},
// 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;
},
// Insert a DocumentFragment
insertElement(type, parent, attributes, text) {
// Inject the new <element>
parent.appendChild(utils.createElement(type, attributes, text));
},
// Remove all child elements
emptyElement(element) {
let { length } = element.childNodes;
while (length > 0) {
element.removeChild(element.lastChild);
length -= 1;
}
},
// Set attributes
setAttributes(element, attributes) {
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.htmlElement(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.htmlElement(element) && element.classList.contains(className);
},
// 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.htmlElement(this.elements.progress)) {
this.elements.display.seekTooltip = this.elements.progress.querySelector(
`.${this.config.classNames.tooltip}`
);
}
return true;
} catch (error) {
// Log it
this.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() {
const tabbables = utils.getElements.call(this, 'input:not([disabled]), button:not([disabled])');
const first = tabbables[0];
const last = tabbables[tabbables.length - 1];
utils.on(
this.elements.container,
'keydown',
event => {
// If it is tab
if (event.which === 9 && this.fullscreen.active) {
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();
}
}
},
false
);
},
// Bind along with custom handler
proxy(element, eventName, customListener, defaultListener, passive, capture) {
utils.on(
element,
eventName,
event => {
if (customListener) {
customListener.apply(element, [event]);
}
defaultListener.apply(element, [event]);
},
passive,
capture
);
},
// Toggle event listener
toggleListener(elements, event, callback, toggle, passive, capture) {
// Bail if no elements
if (elements === null || utils.is.undefined(elements)) {
return;
}
// If a nodelist is passed, call itself on each node
if (elements instanceof NodeList) {
// 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, properties) {
// Bail if no element
if (!element || !type) {
return;
}
// Create and dispatch the event
const event = new CustomEvent(type, {
bubbles: utils.is.boolean(bubbles) ? bubbles : false,
detail: Object.assign({}, properties, {
plyr: this instanceof Plyr ? 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(target, state) {
// Bail if no target
if (!target) {
return null;
}
// Get state
const newState = utils.is.boolean(state) ? state : !target.getAttribute('aria-pressed');
// Set the attribute on target
target.setAttribute('aria-pressed', newState);
return newState;
},
// Get percentage
getPercentage(current, max) {
if (current === 0 || max === 0 || Number.isNaN(current) || Number.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)
extend(...objects) {
const { length } = objects;
// Bail if nothing to merge
if (!length) {
return null;
}
// Return first if specified but nothing to merge
if (length === 1) {
return objects[0];
}
// First object is the destination
let destination = Array.prototype.shift.call(objects);
if (!utils.is.object(destination)) {
destination = {};
}
// Loop through all objects to merge
objects.forEach(source => {
if (!utils.is.object(source)) {
return;
}
Object.keys(source).forEach(property => {
if (source[property] && source[property].constructor && source[property].constructor === Object) {
destination[property] = destination[property] || {};
utils.extend(destination[property], source[property]);
} else {
destination[property] = source[property];
}
});
});
return destination;
},
// Parse YouTube ID from URL
parseYouTubeId(url) {
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.number(Number(url))) {
return url;
}
const regex = /^.*(vimeo.com\/|video\/)(\d+).*/;
return url.match(regex) ? RegExp.$2 : url;
},
// Convert object to URL parameters
buildUrlParameters(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;
},
// Load an SVG sprite
loadSprite(url, id) {
if (typeof url !== 'string') {
return;
}
const prefix = 'cache-';
const hasId = typeof id === 'string';
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');
container.setAttribute('hidden', '');
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);
}
}
// ReSharper disable once InconsistentNaming
const xhr = new XMLHttpRequest();
// XHR for Chrome/Firefox/Opera/Safari
if ('withCredentials' in xhr) {
xhr.open('GET', url, true);
} else {
return;
}
// Once loaded, inject to container and body
xhr.onload = () => {
if (support.storage) {
window.localStorage.setItem(
prefix + id,
JSON.stringify({
content: xhr.responseText,
})
);
}
updateSprite.call(container, xhr.responseText);
};
xhr.send();
}
},
// Get the transition end event
transitionEnd: (() => {
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 typeof type === 'string' ? type : false;
})(),
};
export default utils;

View File

@ -7,7 +7,6 @@
position: relative;
max-width: 100%;
min-width: 200px;
overflow: hidden;
font-family: @plyr-font-family;
font-weight: @plyr-font-weight-normal;
direction: ltr;

View File

@ -58,7 +58,7 @@
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
color: @plyr-video-control-color;
transition: all 0.4s ease-in-out;
transition: opacity 0.4s ease-in-out, transform 0.4s ease-in-out;
.plyr__control {
svg {

View File

@ -16,6 +16,13 @@
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--full-ui .plyr__video-embed iframe {

View File

@ -46,10 +46,8 @@
// Microsoft
&::-ms-track {
height: @plyr-range-track-height;
background: transparent;
border: 0;
color: transparent;
.plyr-range-track();
}
&::-ms-fill-upper {

View File

@ -2,6 +2,10 @@
// Video styles
// --------------------------------------------------------------
.plyr--video {
overflow: hidden;
}
.plyr__video-wrapper {
position: relative;
background: #000;