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

View File

@ -1,9 +1,11 @@
{
"extends": ["eslint:recommended", "prettier"],
"parser": "babel-eslint",
"extends": ["airbnb", "prettier"],
"env": {
"browser": true
"browser": true,
"es6": true
},
"globals": {},
"globals": { "Plyr": true },
"rules": {
"no-const-assign": 1,
"no-this-before-super": 1,
@ -18,5 +20,8 @@
"eqeqeq": [2, "always"],
"one-var": [2, "never"],
"comma-dangle": [2, "always-multiline"]
},
"parserOptions": {
"sourceType": "module"
}
}

View File

@ -16,7 +16,7 @@
"error.css": "demo/src/less/bundles/error.less"
},
"js": {
"demo.js": ["demo/src/js/lib/classlist.js", "demo/src/js/lib/tab-focus.js", "demo/src/js/main.js"]
"demo.js": "demo/src/js/demo.js"
}
}
}

2
demo/dist/demo.css vendored

File diff suppressed because one or more lines are too long

4
demo/dist/demo.js vendored

File diff suppressed because one or more lines are too long

1
demo/dist/demo.js.map vendored Normal file

File diff suppressed because one or more lines are too long

1
demo/dist/demo.svg vendored
View File

@ -1 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg"><symbol id="icon-github" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 .2c-4.4 0-8 3.6-8 8 0 3.5 2.3 6.5 5.5 7.6.4.1.5-.2.5-.4V14c-2.2.5-2.7-1-2.7-1-.4-.9-.9-1.2-.9-1.2-.7-.5.1-.5.1-.5.8.1 1.2.8 1.2.8.7 1.3 1.9.9 2.3.7.1-.5.3-.9.5-1.1-1.8-.2-3.6-.9-3.6-4 0-.9.3-1.6.8-2.1-.1-.2-.4-1 .1-2.1 0 0 .7-.2 2.2.8.6-.2 1.3-.3 2-.3s1.4.1 2 .3c1.5-1 2.2-.8 2.2-.8.4 1.1.2 1.9.1 2.1.5.6.8 1.3.8 2.1 0 3.1-1.9 3.7-3.7 3.9.3.4.6.9.6 1.6v2.2c0 .2.1.5.6.4 3.2-1.1 5.5-4.1 5.5-7.6-.1-4.4-3.7-8-8.1-8z"/></symbol><symbol id="icon-twitter" viewBox="0 0 16 16"><title>Twitter</title><path d="M16 3c-.6.3-1.2.4-1.9.5.7-.4 1.2-1 1.4-1.8-.6.4-1.3.6-2.1.8-.6-.6-1.5-1-2.4-1-1.7 0-3.2 1.5-3.2 3.3 0 .3 0 .5.1.7-2.7-.1-5.2-1.4-6.8-3.4-.3.5-.4 1-.4 1.7 0 1.1.6 2.1 1.5 2.7-.5 0-1-.2-1.5-.4C.7 7.7 1.8 9 3.3 9.3c-.3.1-.6.1-.9.1-.2 0-.4 0-.6-.1.4 1.3 1.6 2.3 3.1 2.3-1.1.9-2.5 1.4-4.1 1.4H0c1.5.9 3.2 1.5 5 1.5 6 0 9.3-5 9.3-9.3v-.4C15 4.3 15.6 3.7 16 3z"/></symbol><symbol id="icon-vimeo" viewBox="0 0 16 16"><path d="M16 4.3c-.1 1.6-1.2 3.7-3.3 6.4-2.2 2.8-4 4.2-5.5 4.2-.9 0-1.7-.9-2.4-2.6C4 9.9 3.4 5 2 5c-.1 0-.5.3-1.2.8l-.8-1c.8-.7 3.5-3.4 4.7-3.5 1.2-.1 2 .7 2.3 2.5.3 2 .8 6.1 1.8 6.1.9 0 2.5-3.4 2.6-4 .1-.9-.3-1.9-2.3-1.1.8-2.6 2.3-3.8 4.5-3.8 1.7.1 2.5 1.2 2.4 3.3z"/></symbol><symbol id="icon-youtube" viewBox="0 0 16 16"><path d="M15.8 4.8c-.2-1.3-.8-2.2-2.2-2.4C11.4 2 8 2 8 2s-3.4 0-5.6.4C1 2.6.3 3.5.2 4.8 0 6.1 0 8 0 8s0 1.9.2 3.2c.2 1.3.8 2.2 2.2 2.4C4.6 14 8 14 8 14s3.4 0 5.6-.4c1.4-.3 2-1.1 2.2-2.4C16 9.9 16 8 16 8s0-1.9-.2-3.2zM6 11V5l5 3-5 3z"/></symbol></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -24,20 +24,26 @@
<div class="grid">
<header>
<h1>Plyr</h1>
<p>A simple, accessible HTML5
<button type="button" class="faux-link" data-source="video">Video</button>,
<button type="button" class="faux-link" data-source="audio">Audio</button>,
<p>A simple, accessible
<button type="button" class="faux-link" data-source="video">
<svg class="icon" title="HTML5">
<path d="M14.738.326C14.548.118 14.28 0 14 0H2c-.28 0-.55.118-.738.326S.98.81 1.004 1.09l1 11c.03.317.208.603.48.767l5 3c.16.095.338.143.516.143s.356-.048.515-.143l5-3c.273-.164.452-.45.48-.767l1-11c.026-.28-.067-.557-.257-.764zM12 4H6v2h6v5.72l-4 1.334-4-1.333V9h2v1.28l2 .666 2-.667V8H4V2h8v2z"></path>
</svg>Video</button>,
<button type="button" class="faux-link" data-source="audio">
<svg class="icon" title="HTML5">
<path d="M14.738.326C14.548.118 14.28 0 14 0H2c-.28 0-.55.118-.738.326S.98.81 1.004 1.09l1 11c.03.317.208.603.48.767l5 3c.16.095.338.143.516.143s.356-.048.515-.143l5-3c.273-.164.452-.45.48-.767l1-11c.026-.28-.067-.557-.257-.764zM12 4H6v2h6v5.72l-4 1.334-4-1.333V9h2v1.28l2 .666 2-.667V8H4V2h8v2z"></path>
</svg>Audio</button>,
<button type="button" class="faux-link" data-source="youtube">
<svg class="icon">
<svg class="icon" title="YouTube">
<path d="M15.8,4.8c-0.2-1.3-0.8-2.2-2.2-2.4C11.4,2,8,2,8,2S4.6,2,2.4,2.4C1,2.6,0.3,3.5,0.2,4.8C0,6.1,0,8,0,8
s0,1.9,0.2,3.2c0.2,1.3,0.8,2.2,2.2,2.4C4.6,14,8,14,8,14s3.4,0,5.6-0.4c1.4-0.3,2-1.1,2.2-2.4C16,9.9,16,8,16,8S16,6.1,15.8,4.8z
M6,11V5l5,3L6,11z" />
M6,11V5l5,3L6,11z"></path>
</svg>YouTube</button> and
<button type="button" class="faux-link" data-source="vimeo">
<svg class="icon">
<svg class="icon" title="Vimeo">
<path d="M16,4.3c-0.1,1.6-1.2,3.7-3.3,6.4c-2.2,2.8-4,4.2-5.5,4.2c-0.9,0-1.7-0.9-2.4-2.6C4,9.9,3.4,5,2,5
C1.9,5,1.5,5.3,0.8,5.8L0,4.8c0.8-0.7,3.5-3.4,4.7-3.5C5.9,1.2,6.7,2,7,3.8c0.3,2,0.8,6.1,1.8,6.1c0.9,0,2.5-3.4,2.6-4
c0.1-0.9-0.3-1.9-2.3-1.1c0.8-2.6,2.3-3.8,4.5-3.8C15.3,1.1,16.1,2.2,16,4.3z" />
c0.1-0.9-0.3-1.9-2.3-1.1c0.8-2.6,2.3-3.8,4.5-3.8C15.3,1.1,16.1,2.2,16,4.3z"></path>
</svg>Vimeo</button> media player.
</p>
@ -49,7 +55,7 @@
c0.8,0.1,1.2,0.8,1.2,0.8C4.4,13.4,5.6,13,6,12.8c0.1-0.5,0.3-0.9,0.5-1.1c-1.8-0.2-3.6-0.9-3.6-4c0-0.9,0.3-1.6,0.8-2.1
c-0.1-0.2-0.4-1,0.1-2.1c0,0,0.7-0.2,2.2,0.8c0.6-0.2,1.3-0.3,2-0.3c0.7,0,1.4,0.1,2,0.3c1.5-1,2.2-0.8,2.2-0.8
c0.4,1.1,0.2,1.9,0.1,2.1c0.5,0.6,0.8,1.3,0.8,2.1c0,3.1-1.9,3.7-3.7,3.9C9.7,12,10,12.5,10,13.2c0,1.1,0,1.9,0,2.2
c0,0.2,0.1,0.5,0.6,0.4c3.2-1.1,5.5-4.1,5.5-7.6C16,3.8,12.4,0.2,8,0.2z" />
c0,0.2,0.1,0.5,0.6,0.4c3.2-1.1,5.5-4.1,5.5-7.6C16,3.8,12.4,0.2,8,0.2z"></path>
</svg>
Download on GitHub
</a>
@ -72,22 +78,28 @@
<ul>
<li class="plyr__cite plyr__cite--video" hidden>
<small>
<a href="http://viewfromabluemoon.com/" target="_blank">View From A Blue Moon</a> &copy; Brainfarm
<a href="http://viewfromabluemoon.com/" target="_blank">
<svg class="icon" title="HTML5">
<path d="M14.738.326C14.548.118 14.28 0 14 0H2c-.28 0-.55.118-.738.326S.98.81 1.004 1.09l1 11c.03.317.208.603.48.767l5 3c.16.095.338.143.516.143s.356-.048.515-.143l5-3c.273-.164.452-.45.48-.767l1-11c.026-.28-.067-.557-.257-.764zM12 4H6v2h6v5.72l-4 1.334-4-1.333V9h2v1.28l2 .666 2-.667V8H4V2h8v2z"></path>
</svg>View From A Blue Moon</a> &copy; Brainfarm
</small>
</li>
<li class="plyr__cite plyr__cite--audio" hidden>
<small>
<a href="http://www.kishibashi.com/" target="_blank">Kishi Bashi &ndash; &ldquo;It All Began With A Burst&rdquo;</a> &copy; Kishi Bashi
<a href="http://www.kishibashi.com/" target="_blank">
<svg class="icon" title="HTML5">
<path d="M14.738.326C14.548.118 14.28 0 14 0H2c-.28 0-.55.118-.738.326S.98.81 1.004 1.09l1 11c.03.317.208.603.48.767l5 3c.16.095.338.143.516.143s.356-.048.515-.143l5-3c.273-.164.452-.45.48-.767l1-11c.026-.28-.067-.557-.257-.764zM12 4H6v2h6v5.72l-4 1.334-4-1.333V9h2v1.28l2 .666 2-.667V8H4V2h8v2z"></path>
</svg>Kishi Bashi &ndash; &ldquo;It All Began With A Burst&rdquo;</a> &copy; Kishi Bashi
</small>
</li>
<li class="plyr__cite plyr__cite--youtube" hidden>
<small>
<a href="https://www.youtube.com/watch?v=bTqVqk7FSmY" target="_blank">View From A Blue Moon</a> on
<span class="color--youtube">
<svg class="icon">
<svg class="icon" title="YouTube">
<path d="M15.8,4.8c-0.2-1.3-0.8-2.2-2.2-2.4C11.4,2,8,2,8,2S4.6,2,2.4,2.4C1,2.6,0.3,3.5,0.2,4.8C0,6.1,0,8,0,8
s0,1.9,0.2,3.2c0.2,1.3,0.8,2.2,2.2,2.4C4.6,14,8,14,8,14s3.4,0,5.6-0.4c1.4-0.3,2-1.1,2.2-2.4C16,9.9,16,8,16,8S16,6.1,15.8,4.8z
M6,11V5l5,3L6,11z" />
M6,11V5l5,3L6,11z"></path>
</svg>YouTube
</span>
</small>
@ -96,10 +108,10 @@
<small>
<a href="https://vimeo.com/ondemand/viewfromabluemoon4k" target="_blank">View From A Blue Moon</a> on
<span class="color--vimeo">
<svg class="icon">
<svg class="icon" title="Vimeo">
<path d="M16,4.3c-0.1,1.6-1.2,3.7-3.3,6.4c-2.2,2.8-4,4.2-5.5,4.2c-0.9,0-1.7-0.9-2.4-2.6C4,9.9,3.4,5,2,5
C1.9,5,1.5,5.3,0.8,5.8L0,4.8c0.8-0.7,3.5-3.4,4.7-3.5C5.9,1.2,6.7,2,7,3.8c0.3,2,0.8,6.1,1.8,6.1c0.9,0,2.5-3.4,2.6-4
c0.1-0.9-0.3-1.9-2.3-1.1c0.8-2.6,2.3-3.8,4.5-3.8C15.3,1.1,16.1,2.2,16,4.3z" />
c0.1-0.9-0.3-1.9-2.3-1.1c0.8-2.6,2.3-3.8,4.5-3.8C15.3,1.1,16.1,2.2,16,4.3z"></path>
</svg>Vimeo
</span>
</small>
@ -114,8 +126,7 @@
<path d="M16,3c-0.6,0.3-1.2,0.4-1.9,0.5c0.7-0.4,1.2-1,1.4-1.8c-0.6,0.4-1.3,0.6-2.1,0.8c-0.6-0.6-1.5-1-2.4-1
C9.3,1.5,7.8,3,7.8,4.8c0,0.3,0,0.5,0.1,0.7C5.2,5.4,2.7,4.1,1.1,2.1c-0.3,0.5-0.4,1-0.4,1.7c0,1.1,0.6,2.1,1.5,2.7
c-0.5,0-1-0.2-1.5-0.4c0,0,0,0,0,0c0,1.6,1.1,2.9,2.6,3.2C3,9.4,2.7,9.4,2.4,9.4c-0.2,0-0.4,0-0.6-0.1c0.4,1.3,1.6,2.3,3.1,2.3
c-1.1,0.9-2.5,1.4-4.1,1.4c-0.3,0-0.5,0-0.8,0c1.5,0.9,3.2,1.5,5,1.5c6,0,9.3-5,9.3-9.3c0-0.1,0-0.3,0-0.4C15,4.3,15.6,3.7,16,3z"
/>
c-1.1,0.9-2.5,1.4-4.1,1.4c-0.3,0-0.5,0-0.8,0c1.5,0.9,3.2,1.5,5,1.5c6,0,9.3-5,9.3-9.3c0-0.1,0-0.3,0-0.4C15,4.3,15.6,3.7,16,3z"></path>
</svg>
<p>If you think others would like Plyr,
<a href="https://twitter.com/intent/tweet?text=A+simple+HTML5+media+player+with+custom+controls+and+WebVTT+captions.&url=http%3A%2F%2Fplyr.io&via=Sam_Potts"
@ -123,8 +134,11 @@
</p>
</aside>
<!-- Polyfills -->
<script src="https://cdn.polyfill.io/v2/polyfill.min.js?features=es6,Array.prototype.includes,CustomEvent"></script>
<!-- Plyr core script -->
<script src="../src/js/plyr.js"></script>
<script src="../dist/plyr.js"></script>
<!-- Sharing libary (https://shr.one) -->
<script src="https://cdn.shr.one/1.0.1/shr.js"></script>

View File

@ -4,9 +4,7 @@
// Please see readme.md in the root or github.com/sampotts/plyr
// ==========================================================================
/*global Plyr*/
(function() {
document.addEventListener('DOMContentLoaded', () => {
if (window.shr) {
window.shr.setup({
count: {
@ -15,12 +13,33 @@
});
}
// Setup tab focus
const tabClassName = 'tab-focus';
// Remove class on blur
document.addEventListener('focusout', event => {
event.target.classList.remove(tabClassName);
});
// Add classname to tabbed elements
document.addEventListener('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(() => {
document.activeElement.classList.add(tabClassName);
}, 0);
});
/* document.body.addEventListener('ready', function(event) {
console.log(event);
}); */
// Setup the player
var player = new Plyr('#player', {
const player = new window.Plyr('#player', {
debug: true,
title: 'View From A Blue Moon',
iconUrl: '../dist/plyr.svg',
@ -28,70 +47,36 @@
controls: true,
},
captions: {
defaultActive: true,
active: true,
},
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'fullscreen', 'pip', 'airplay'],
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
'captions',
'settings',
'fullscreen',
'pip',
'airplay',
],
});
// Expose for testing
window.player = player;
// Setup type toggle
var buttons = document.querySelectorAll('[data-source]');
var types = {
const buttons = document.querySelectorAll('[data-source]');
const types = {
video: 'video',
audio: 'audio',
youtube: 'youtube',
vimeo: 'vimeo',
};
var currentType = window.location.hash.replace('#', '');
var historySupport = window.history && window.history.pushState;
// Bind to each button
[].forEach.call(buttons, function(button) {
button.addEventListener('click', function() {
var type = this.getAttribute('data-source');
newSource(type);
if (historySupport) {
history.pushState({ type: type }, '', '#' + type);
}
});
});
// List for backwards/forwards
window.addEventListener('popstate', function(event) {
if (event.state && 'type' in event.state) {
newSource(event.state.type);
}
});
// On load
if (historySupport) {
var video = !currentType.length;
// If there's no current type set, assume video
if (video) {
currentType = types.video;
}
// Replace current history state
if (currentType in types) {
history.replaceState(
{
type: currentType,
},
'',
video ? '' : '#' + currentType
);
}
// If it's not video, load the source
if (currentType !== types.video) {
newSource(currentType, true);
}
}
let currentType = window.location.hash.replace('#', '');
const historySupport = window.history && window.history.pushState;
// Toggle class on an element
function toggleClass(element, className, state) {
@ -109,7 +94,7 @@
switch (type) {
case types.video:
player.source({
player.src = {
type: 'video',
title: 'View From A Blue Moon',
sources: [
@ -134,11 +119,12 @@
src: 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt',
},
],
});
};
break;
case types.audio:
player.source({
player.src = {
type: 'audio',
title: 'Kishi Bashi &ndash; &ldquo;It All Began With A Burst&rdquo;',
sources: [
@ -151,11 +137,12 @@
type: 'audio/ogg',
},
],
});
};
break;
case types.youtube:
player.source({
player.src = {
type: 'video',
title: 'View From A Blue Moon',
sources: [
@ -164,11 +151,12 @@
type: 'youtube',
},
],
});
};
break;
case types.vimeo:
player.source({
player.src = {
type: 'video',
title: 'View From A Blue Moon',
sources: [
@ -177,7 +165,11 @@
type: 'vimeo',
},
],
});
};
break;
default:
break;
}
@ -185,23 +177,68 @@
currentType = type;
// Remove active classes
for (var x = buttons.length - 1; x >= 0; x--) {
toggleClass(buttons[x].parentElement, 'active', false);
}
Array.from(buttons).forEach(button => toggleClass(button.parentElement, 'active', false));
// Set active on parent
toggleClass(document.querySelector('[data-source="' + type + '"]'), 'active', true);
toggleClass(document.querySelector(`[data-source="${type}"]`), 'active', true);
// Show cite
[].forEach.call(document.querySelectorAll('.plyr__cite'), function(cite) {
Array.from(document.querySelectorAll('.plyr__cite')).forEach(cite => {
cite.setAttribute('hidden', '');
});
document.querySelector('.plyr__cite--' + type).removeAttribute('hidden');
document.querySelector(`.plyr__cite--${type}`).removeAttribute('hidden');
}
})();
// Bind to each button
Array.from(buttons).forEach(button => {
button.addEventListener('click', () => {
const type = button.getAttribute('data-source');
newSource(type);
if (historySupport) {
window.history.pushState({ type }, '', `#${type}`);
}
});
});
// List for backwards/forwards
window.addEventListener('popstate', event => {
if (event.state && 'type' in event.state) {
newSource(event.state.type);
}
});
// On load
if (historySupport) {
const video = !currentType.length;
// If there's no current type set, assume video
if (video) {
currentType = types.video;
}
// Replace current history state
if (currentType in types) {
window.history.replaceState(
{
type: currentType,
},
'',
video ? '' : `#${currentType}`
);
}
// If it's not video, load the source
if (currentType !== types.video) {
newSource(currentType, true);
}
}
});
// Google analytics
// For demo site (https://plyr.io) only
/* eslint-disable */
if (window.location.host === 'plyr.io') {
(function(i, s, o, g, r, a, m) {
i.GoogleAnalyticsObject = r;
@ -220,3 +257,4 @@ if (window.location.host === 'plyr.io') {
window.ga('create', 'UA-40881672-11', 'auto');
window.ga('send', 'pageview');
}
/* eslint-enable */

View File

@ -1,237 +0,0 @@
/*
* classList.js: Cross-browser full element.classList implementation.
* 1.1.20150312
*
* By Eli Grey, http://eligrey.com
* License: Dedicated to the public domain.
* See https://github.com/eligrey/classList.js/blob/master/LICENSE.md
*/
/*global self, document, DOMException */
/*! @source http://purl.eligrey.com/github/classList.js/blob/master/classList.js */
if ("document" in self) {
// Full polyfill for browsers with no classList support
if (!("classList" in document.createElement("_"))) {
(function (view) {
"use strict";
if (!('Element' in view)) return;
var
classListProp = "classList"
, protoProp = "prototype"
, elemCtrProto = view.Element[protoProp]
, objCtr = Object
, strTrim = String[protoProp].trim || function () {
return this.replace(/^\s+|\s+$/g, "");
}
, arrIndexOf = Array[protoProp].indexOf || function (item) {
var
i = 0
, len = this.length
;
for (; i < len; i++) {
if (i in this && this[i] === item) {
return i;
}
}
return -1;
}
// Vendors: please allow content code to instantiate DOMExceptions
, DOMEx = function (type, message) {
this.name = type;
this.code = DOMException[type];
this.message = message;
}
, checkTokenAndGetIndex = function (classList, token) {
if (token === "") {
throw new DOMEx(
"SYNTAX_ERR"
, "An invalid or illegal string was specified"
);
}
if (/\s/.test(token)) {
throw new DOMEx(
"INVALID_CHARACTER_ERR"
, "String contains an invalid character"
);
}
return arrIndexOf.call(classList, token);
}
, ClassList = function (elem) {
var
trimmedClasses = strTrim.call(elem.getAttribute("class") || "")
, classes = trimmedClasses ? trimmedClasses.split(/\s+/) : []
, i = 0
, len = classes.length
;
for (; i < len; i++) {
this.push(classes[i]);
}
this._updateClassName = function () {
elem.setAttribute("class", this.toString());
};
}
, classListProto = ClassList[protoProp] = []
, classListGetter = function () {
return new ClassList(this);
}
;
// Most DOMException implementations don't allow calling DOMException's toString()
// on non-DOMExceptions. Error's toString() is sufficient here.
DOMEx[protoProp] = Error[protoProp];
classListProto.item = function (i) {
return this[i] || null;
};
classListProto.contains = function (token) {
token += "";
return checkTokenAndGetIndex(this, token) !== -1;
};
classListProto.add = function () {
var
tokens = arguments
, i = 0
, l = tokens.length
, token
, updated = false
;
do {
token = tokens[i] + "";
if (checkTokenAndGetIndex(this, token) === -1) {
this.push(token);
updated = true;
}
}
while (++i < l);
if (updated) {
this._updateClassName();
}
};
classListProto.remove = function () {
var
tokens = arguments
, i = 0
, l = tokens.length
, token
, updated = false
, index
;
do {
token = tokens[i] + "";
index = checkTokenAndGetIndex(this, token);
while (index !== -1) {
this.splice(index, 1);
updated = true;
index = checkTokenAndGetIndex(this, token);
}
}
while (++i < l);
if (updated) {
this._updateClassName();
}
};
classListProto.toggle = function (token, force) {
token += "";
var
result = this.contains(token)
, method = result ?
force !== true && "remove"
:
force !== false && "add"
;
if (method) {
this[method](token);
}
if (force === true || force === false) {
return force;
} else {
return !result;
}
};
classListProto.toString = function () {
return this.join(" ");
};
if (objCtr.defineProperty) {
var classListPropDesc = {
get: classListGetter
, enumerable: true
, configurable: true
};
try {
objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
} catch (ex) { // IE 8 doesn't support enumerable:true
if (ex.number === -0x7FF5EC54) {
classListPropDesc.enumerable = false;
objCtr.defineProperty(elemCtrProto, classListProp, classListPropDesc);
}
}
} else if (objCtr[protoProp].__defineGetter__) {
elemCtrProto.__defineGetter__(classListProp, classListGetter);
}
}(self));
} else {
// There is full or partial native classList support, so just check if we need
// to normalize the add/remove and toggle APIs.
(function () {
"use strict";
var testElement = document.createElement("_");
testElement.classList.add("c1", "c2");
// Polyfill for IE 10/11 and Firefox <26, where classList.add and
// classList.remove exist but support only one argument at a time.
if (!testElement.classList.contains("c2")) {
var createMethod = function(method) {
var original = DOMTokenList.prototype[method];
DOMTokenList.prototype[method] = function(token) {
var i, len = arguments.length;
for (i = 0; i < len; i++) {
token = arguments[i];
original.call(this, token);
}
};
};
createMethod('add');
createMethod('remove');
}
testElement.classList.toggle("c3", false);
// Polyfill for IE 10 and Firefox <24, where classList.toggle does not
// support the second argument.
if (testElement.classList.contains("c3")) {
var _toggle = DOMTokenList.prototype.toggle;
DOMTokenList.prototype.toggle = function(token, force) {
if (1 in arguments && !this.contains(token) === !force) {
return force;
} else {
return _toggle.call(this, token);
}
};
}
testElement = null;
}());
}
}

View File

@ -1,26 +0,0 @@
// ==========================================================================
// tab-focus.js
// Detect keyboard tabbing
// ==========================================================================
(function() {
var className = 'tab-focus';
// Remove class on blur
document.addEventListener('focusout', function(event) {
event.target.classList.remove(className);
});
// Add classname to tabbed elements
document.addEventListener('keydown', function(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(function() {
document.activeElement.classList.add(className);
}, 0);
});
})();

View File

@ -7,6 +7,7 @@
fill: currentColor;
width: @icon-size;
height: @icon-size;
vertical-align: -0.15em;
}
// Within elements
@ -18,5 +19,5 @@ label svg {
a .icon,
.btn .icon {
margin-right: (@spacing-base / 2);
margin-right: (@spacing-base / 4);
}

View File

@ -2,12 +2,21 @@
// Core
// ==========================================================================
html {
background: @page-background;
background-attachment: fixed;
*,
*::after,
*::before {
box-sizing: border-box;
}
html,
body {
display: flex;
width: 100%;
}
html {
background: @page-background;
background-attachment: fixed;
height: 100%;
}

2
dist/plyr.css vendored

File diff suppressed because one or more lines are too long

4
dist/plyr.js vendored

File diff suppressed because one or more lines are too long

1
dist/plyr.js.map vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -4,27 +4,34 @@
/* global require, __dirname */
/* eslint no-console: "off" */
var fs = require('fs');
var path = require('path');
var gulp = require('gulp');
var gutil = require('gulp-util');
var concat = require('gulp-concat');
var uglify = require('gulp-uglify');
var less = require('gulp-less');
var sass = require('gulp-sass');
var cleanCSS = require('gulp-clean-css');
var run = require('run-sequence');
var prefix = require('gulp-autoprefixer');
var svgstore = require('gulp-svgstore');
var svgmin = require('gulp-svgmin');
var rename = require('gulp-rename');
var s3 = require('gulp-s3');
var replace = require('gulp-replace');
var open = require('gulp-open');
var size = require('gulp-size');
var root = __dirname;
const fs = require('fs');
const path = require('path');
const gulp = require('gulp');
const gutil = require('gulp-util');
const concat = require('gulp-concat');
const less = require('gulp-less');
const sass = require('gulp-sass');
const cleancss = require('gulp-clean-css');
const run = require('run-sequence');
const prefix = require('gulp-autoprefixer');
const svgstore = require('gulp-svgstore');
const svgmin = require('gulp-svgmin');
const rename = require('gulp-rename');
const s3 = require('gulp-s3');
const replace = require('gulp-replace');
const open = require('gulp-open');
const size = require('gulp-size');
const rollup = require('gulp-better-rollup');
const babel = require('rollup-plugin-babel');
const sourcemaps = require('gulp-sourcemaps');
const uglify = require('rollup-plugin-uglify');
const { minify } = require('uglify-es');
const commonjs = require('rollup-plugin-commonjs');
const resolve = require('rollup-plugin-node-resolve');
var paths = {
const root = __dirname;
const paths = {
plyr: {
// Source paths
src: {
@ -54,82 +61,117 @@ var paths = {
};
// Task arrays
var tasks = {
const tasks = {
less: [],
scss: [],
js: [],
sprite: [],
};
// Fetch bundles from JSON
var bundles = loadJSON(path.join(root, 'bundles.json'));
var package = loadJSON(path.join(root, 'package.json'));
// Load json
function loadJSON(path) {
function loadJSON(pathname) {
try {
return JSON.parse(fs.readFileSync(path));
return JSON.parse(fs.readFileSync(pathname));
} catch (err) {
return {};
}
}
var build = {
js: function(files, bundle) {
Object.keys(files).forEach(function(key) {
var name = 'js-' + key;
// Fetch bundles from JSON
const bundles = loadJSON(path.join(root, 'bundles.json'));
const pkg = loadJSON(path.join(root, 'package.json'));
const sizeOptions = { showFiles: true, gzip: true };
// Browserlist
const browsers = ['> 1%', 'last 2 versions'];
// Babel config
const babelrc = {
presets: [
[
'env',
{
targets: {
browsers,
},
useBuiltIns: true,
modules: false,
},
],
],
plugins: ['external-helpers'],
babelrc: false,
exclude: 'node_modules/**',
};
const build = {
js(files, bundle, options) {
Object.keys(files).forEach(key => {
const name = `js-${key}`;
tasks.js.push(name);
gulp.task(name, function() {
return gulp
gulp.task(name, () =>
gulp
.src(bundles[bundle].js[key])
.pipe(concat(key))
.pipe(uglify())
.pipe(gulp.dest(paths[bundle].output));
});
.pipe(sourcemaps.init())
.pipe(
rollup(
{
plugins: [resolve(), commonjs(), babel(babelrc), uglify({}, minify)],
},
options
)
)
.pipe(size(sizeOptions))
.pipe(sourcemaps.write(''))
.pipe(gulp.dest(paths[bundle].output))
);
});
},
less: function(files, bundle) {
Object.keys(files).forEach(function(key) {
var name = 'less-' + key;
less(files, bundle) {
Object.keys(files).forEach(key => {
const name = `less-${key}`;
tasks.less.push(name);
gulp.task(name, function() {
return gulp
gulp.task(name, () =>
gulp
.src(bundles[bundle].less[key])
.pipe(less())
.on('error', gutil.log)
.pipe(concat(key))
.pipe(prefix(['last 2 versions'], { cascade: false }))
.pipe(cleanCSS())
.pipe(gulp.dest(paths[bundle].output));
});
.pipe(prefix(browsers, { cascade: false }))
.pipe(cleancss())
.pipe(size(sizeOptions))
.pipe(gulp.dest(paths[bundle].output))
);
});
},
scss: function(files, bundle) {
Object.keys(files).forEach(function(key) {
var name = 'scss-' + key;
scss(files, bundle) {
Object.keys(files).forEach(key => {
const name = `scss-${key}`;
tasks.scss.push(name);
gulp.task(name, function() {
return gulp
gulp.task(name, () =>
gulp
.src(bundles[bundle].scss[key])
.pipe(sass())
.on('error', gutil.log)
.pipe(concat(key))
.pipe(prefix(['last 2 versions'], { cascade: false }))
.pipe(cleanCSS())
.pipe(gulp.dest(paths[bundle].output));
});
.pipe(prefix(browsers, { cascade: false }))
.pipe(cleancss())
.pipe(size(sizeOptions))
.pipe(gulp.dest(paths[bundle].output))
);
});
},
sprite: function(bundle) {
var name = 'sprite-' + bundle;
sprite(bundle) {
const name = `sprite-${bundle}`;
tasks.sprite.push(name);
// Process Icons
gulp.task(name, function() {
return gulp
gulp.task(name, () =>
gulp
.src(paths[bundle].src.sprite)
.pipe(
svgmin({
@ -142,33 +184,35 @@ var build = {
)
.pipe(svgstore())
.pipe(rename({ basename: bundle }))
.pipe(gulp.dest(paths[bundle].output));
});
.pipe(size(sizeOptions))
.pipe(gulp.dest(paths[bundle].output))
);
},
};
// Plyr core files
build.js(bundles.plyr.js, 'plyr');
build.js(bundles.plyr.js, 'plyr', { name: 'Plyr', format: 'umd' });
build.less(bundles.plyr.less, 'plyr');
build.scss(bundles.plyr.scss, 'plyr');
build.sprite('plyr');
// Demo files
build.less(bundles.demo.less, 'demo');
build.js(bundles.demo.js, 'demo');
build.js(bundles.demo.js, 'demo', { format: 'es' });
// Build all JS
gulp.task('js', function() {
gulp.task('js', () => {
run(tasks.js);
});
// Build SCSS (for testing, default is LESS)
gulp.task('scss', function() {
gulp.task('scss', () => {
run(tasks.scss);
});
// Watch for file changes
gulp.task('watch', function() {
gulp.task('watch', () => {
// Plyr core
gulp.watch(paths.plyr.src.js, tasks.js);
gulp.watch(paths.plyr.src.less, tasks.less);
@ -180,7 +224,7 @@ gulp.task('watch', function() {
});
// Default gulp task
gulp.task('default', function() {
gulp.task('default', () => {
run(tasks.js, tasks.less, tasks.sprite, 'watch');
});
@ -188,13 +232,13 @@ gulp.task('default', function() {
// --------------------------------------------
// Some options
var aws = loadJSON(path.join(root, 'aws.json'));
var version = package.version;
var maxAge = 31536000; // 1 year
var options = {
const aws = loadJSON(path.join(root, 'aws.json'));
const { version } = pkg;
const maxAge = 31536000; // 1 year
const options = {
cdn: {
headers: {
'Cache-Control': 'max-age=' + maxAge,
'Cache-Control': `max-age=${maxAge}`,
Vary: 'Accept-Encoding',
},
},
@ -204,11 +248,11 @@ var options = {
Vary: 'Accept-Encoding',
},
},
symlinks: function(version, filename) {
symlinks(ver, filename) {
return {
headers: {
// http://stackoverflow.com/questions/2272835/amazon-s3-object-redirect
'x-amz-website-redirect-location': '/' + version + '/' + filename,
'x-amz-website-redirect-location': `/${ver}/${filename}`,
'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0',
},
};
@ -217,17 +261,16 @@ var options = {
// If aws is setup
if ('cdn' in aws) {
var regex =
const regex =
'(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*).(?:0|[1-9][0-9]*)(?:-[\\da-z\\-]+(?:.[\\da-z\\-]+)*)?(?:\\+[\\da-z\\-]+(?:.[\\da-z\\-]+)*)?';
var cdnpath = new RegExp(aws.cdn.domain + '/' + regex, 'gi');
var semver = new RegExp('v' + regex, 'gi');
var localPath = new RegExp('(../)?dist', 'gi');
var versionPath = 'https://' + aws.cdn.domain + '/' + version;
}
const cdnpath = new RegExp(`${aws.cdn.domain}/${regex}`, 'gi');
const semver = new RegExp(`v${regex}`, 'gi');
const localPath = new RegExp('(../)?dist', 'gi');
const versionPath = `https://${aws.cdn.domain}/${version}`;
// Publish version to CDN bucket
gulp.task('cdn', function() {
console.log('Uploading ' + version + ' to ' + aws.cdn.domain + '...');
gulp.task('cdn', () => {
console.log(`Uploading ${version} to ${aws.cdn.domain}...`);
// Upload to CDN
return gulp
@ -239,8 +282,8 @@ gulp.task('cdn', function() {
})
)
.pipe(
rename(function(path) {
path.dirname = path.dirname.replace('.', version);
rename(p => {
p.dirname = path.dirname.replace('.', version);
})
)
.pipe(replace(localPath, versionPath))
@ -248,37 +291,37 @@ gulp.task('cdn', function() {
});
// Publish to demo bucket
gulp.task('demo', function() {
console.log('Uploading ' + version + ' demo to ' + aws.demo.domain + '...');
gulp.task('demo', () => {
console.log(`Uploading ${version} demo to ${aws.demo.domain}...`);
// Replace versioned files in readme.md
gulp
.src([root + '/readme.md'])
.pipe(replace(cdnpath, aws.cdn.domain + '/' + version))
.src([`${root}/readme.md`])
.pipe(replace(cdnpath, `${aws.cdn.domain}/${version}`))
.pipe(gulp.dest(root));
// Replace versioned files in plyr.js
gulp
.src(path.join(root, 'src/js/plyr.js'))
.pipe(replace(semver, 'v' + version))
.pipe(replace(cdnpath, aws.cdn.domain + '/' + version))
.pipe(replace(semver, `v${version}`))
.pipe(replace(cdnpath, `${aws.cdn.domain}/${version}`))
.pipe(gulp.dest(path.join(root, 'src/js/')));
// Replace local file paths with remote paths in demo HTML
// e.g. "../dist/plyr.js" to "https://cdn.plyr.io/x.x.x/plyr.js"
gulp
.src([paths.demo.root + '*.html'])
.src([`${paths.demo.root}*.html`])
.pipe(replace(localPath, versionPath))
.pipe(s3(aws.demo, options.demo));
// Upload error.html to cdn (as well as demo site)
return gulp
.src([paths.demo.root + 'error.html'])
.src([`${paths.demo.root}error.html`])
.pipe(replace(localPath, versionPath))
.pipe(s3(aws.cdn, options.demo));
});
// Open the demo site to check it's sweet
// Update symlinks for latest
/* gulp.task("symlinks", function () {
console.log("Updating symlinks...");
@ -302,20 +345,21 @@ gulp.task('demo', function() {
}); */
// Open the demo site to check it's sweet
gulp.task('open', function() {
console.log('Opening ' + aws.demo.domain + '...');
gulp.task('open', () => {
console.log(`Opening ${aws.demo.domain}...`);
// A file must be specified or gulp will skip the task
// Doesn't matter which file since we set the URL above
// Weird, I know...
return gulp.src([paths.demo.root + 'index.html']).pipe(
return gulp.src([`${paths.demo.root}index.html`]).pipe(
open('', {
url: 'http://' + aws.demo.domain,
url: `http://${aws.demo.domain}`,
})
);
});
// Do everything
gulp.task('publish', function() {
gulp.task('publish', () => {
run(tasks.js, tasks.less, tasks.sprite, 'cdn', 'demo');
});
}

10122
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,12 +4,20 @@
"description": "A simple, accessible and customizable HTML5, YouTube and Vimeo media player",
"homepage": "http://plyr.io",
"main": "src/js/plyr.js",
"dependencies": {},
"devDependencies": {
"eslint": "^4.9.0",
"eslint-config-prettier": "^2.6.0",
"babel-core": "^6.26.0",
"babel-eslint": "^8.0.1",
"babel-plugin-external-helpers": "^6.22.0",
"babel-preset-env": "^1.6.1",
"eslint": "^4.10.0",
"eslint-config-airbnb": "^16.1.0",
"eslint-config-prettier": "^2.7.0",
"eslint-plugin-import": "^2.8.0",
"eslint-plugin-jsx-a11y": "^6.0.2",
"eslint-plugin-react": "^7.4.0",
"gulp": "^3.9.1",
"gulp-autoprefixer": "^4.0.0",
"gulp-better-rollup": "^2.0.0",
"gulp-clean-css": "^3.9.0",
"gulp-concat": "^2.6.1",
"gulp-less": "^3.3.2",
@ -19,15 +27,20 @@
"gulp-s3": "^0.11.0",
"gulp-sass": "^3.1.0",
"gulp-size": "^2.1.0",
"gulp-sourcemaps": "^2.6.1",
"gulp-svgmin": "^1.2.4",
"gulp-svgstore": "^6.1.0",
"gulp-uglify": "^3.0.0",
"gulp-util": "^3.0.8",
"rollup-plugin-babel": "^3.0.2",
"rollup-plugin-commonjs": "^8.2.6",
"rollup-plugin-node-resolve": "^3.0.0",
"rollup-plugin-uglify": "^2.0.1",
"run-sequence": "^2.2.0",
"stylelint": "^8.2.0",
"stylelint-config-prettier": "^1.0.2",
"stylelint-config-standard": "^17.0.0",
"through2": "^2.0.3"
"through2": "^2.0.3",
"uglify-es": "^3.1.6"
},
"keywords": [
"HTML5 Video",

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;