Merge branch 'develop' into a11y-improvements

# Conflicts:
#	dist/plyr.js
#	dist/plyr.js.map
#	dist/plyr.min.js
#	dist/plyr.min.js.map
#	dist/plyr.polyfilled.js
#	dist/plyr.polyfilled.js.map
#	dist/plyr.polyfilled.min.js
#	dist/plyr.polyfilled.min.js.map
#	src/js/controls.js
#	src/js/fullscreen.js
#	src/js/plyr.js
#	src/js/ui.js
#	src/js/utils.js
This commit is contained in:
Sam Potts
2018-06-17 01:26:24 +10:00
51 changed files with 27362 additions and 27557 deletions

View File

@ -6,7 +6,21 @@
import controls from './controls';
import i18n from './i18n';
import support from './support';
import utils from './utils';
import { dedupe } from './utils/arrays';
import browser from './utils/browser';
import {
createElement,
emptyElement,
getAttributesFromSelector,
insertAfter,
removeElement,
toggleClass,
} from './utils/elements';
import { on, triggerEvent } from './utils/events';
import fetch from './utils/fetch';
import is from './utils/is';
import { getHTML } from './utils/strings';
import { parseUrl } from './utils/urls';
const captions = {
// Setup captions
@ -19,7 +33,11 @@ const captions = {
// Only Vimeo and HTML5 video supported at this point
if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) {
// Clear menu and hide
if (utils.is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
if (
is.array(this.config.controls) &&
this.config.controls.includes('settings') &&
this.config.settings.includes('captions')
) {
controls.setCaptionsMenu.call(this);
}
@ -27,15 +45,12 @@ const captions = {
}
// Inject the container
if (!utils.is.element(this.elements.captions)) {
this.elements.captions = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.captions));
if (!is.element(this.elements.captions)) {
this.elements.captions = createElement('div', getAttributesFromSelector(this.config.selectors.captions));
utils.insertAfter(this.elements.captions, this.elements.wrapper);
insertAfter(this.elements.captions, this.elements.wrapper);
}
// Get browser info
const browser = utils.getBrowser();
// Fix IE captions if CORS is used
// Fetch captions and inject as blobs instead (data URIs not supported!)
if (browser.isIE && window.URL) {
@ -43,84 +58,96 @@ const captions = {
Array.from(elements).forEach(track => {
const src = track.getAttribute('src');
const href = utils.parseUrl(src);
const url = parseUrl(src);
if (href.hostname !== window.location.href.hostname && [
'http:',
'https:',
].includes(href.protocol)) {
utils
.fetch(src, 'blob')
if (
url !== null &&
url.hostname !== window.location.href.hostname &&
['http:', 'https:'].includes(url.protocol)
) {
fetch(src, 'blob')
.then(blob => {
track.setAttribute('src', window.URL.createObjectURL(blob));
})
.catch(() => {
utils.removeElement(track);
removeElement(track);
});
}
});
}
// Try to load the value from storage
let active = this.storage.get('captions');
// Get and set initial data
// The "preferred" options are not realized unless / until the wanted language has a match
// * languages: Array of user's browser languages.
// * language: The language preferred by user settings or config
// * active: The state preferred by user settings or config
// * toggled: The real captions state
// Otherwise fall back to the default config
if (!utils.is.boolean(active)) {
const languages = dedupe(
Array.from(navigator.languages || navigator.userLanguage).map(language => language.split('-')[0]),
);
let language = (this.storage.get('language') || this.config.captions.language || 'auto').toLowerCase();
// Use first browser language when language is 'auto'
if (language === 'auto') {
[language] = languages;
}
let active = this.storage.get('captions');
if (!is.boolean(active)) {
({ active } = this.config.captions);
}
// Get language from storage, fallback to config
let language = this.storage.get('language') || this.config.captions.language;
if (language === 'auto') {
[ language ] = (navigator.language || navigator.userLanguage).split('-');
}
// Set language and show if active
captions.setLanguage.call(this, language, active);
Object.assign(this.captions, {
toggled: false,
active,
language,
languages,
});
// Watch changes to textTracks and update captions menu
if (this.isHTML5) {
const trackEvents = this.config.captions.update ? 'addtrack removetrack' : 'removetrack';
utils.on(this.media.textTracks, trackEvents, captions.update.bind(this));
on.call(this, this.media.textTracks, trackEvents, captions.update.bind(this));
}
// Update available languages in list next tick (the event must not be triggered before the listeners)
setTimeout(captions.update.bind(this), 0);
},
// Update available language options in settings based on tracks
update() {
const tracks = captions.getTracks.call(this, true);
// Get the wanted language
const { language, meta } = this.captions;
const { active, language, meta, currentTrackNode } = this.captions;
const languageExists = Boolean(tracks.find(track => track.language === language));
// Handle tracks (add event listener and "pseudo"-default)
if (this.isHTML5 && this.isVideo) {
tracks
.filter(track => !meta.get(track))
.forEach(track => {
this.debug.log('Track added', track);
// Attempt to store if the original dom element was "default"
meta.set(track, {
default: track.mode === 'showing',
});
// Turn off native caption rendering to avoid double captions
track.mode = 'hidden';
// Add event listener for cue changes
utils.on(track, 'cuechange', () => captions.updateCues.call(this));
tracks.filter(track => !meta.get(track)).forEach(track => {
this.debug.log('Track added', track);
// Attempt to store if the original dom element was "default"
meta.set(track, {
default: track.mode === 'showing',
});
// Turn off native caption rendering to avoid double captions
track.mode = 'hidden';
// Add event listener for cue changes
on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
});
}
const trackRemoved = !tracks.find(track => track === this.captions.currentTrackNode);
const firstMatch = this.language !== language && tracks.find(track => track.language === language);
// Update language if removed or first matching track added
if (trackRemoved || firstMatch) {
captions.setLanguage.call(this, language, this.config.captions.active);
// Update language first time it matches, or if the previous matching track was removed
if ((languageExists && this.language !== language) || !tracks.includes(currentTrackNode)) {
captions.setLanguage.call(this, language);
captions.toggle.call(this, active && languageExists);
}
// Enable or disable captions based on track length
utils.toggleClass(this.elements.container, this.config.classNames.captions.enabled, !utils.is.empty(tracks));
toggleClass(this.elements.container, this.config.classNames.captions.enabled, !is.empty(tracks));
// Update available languages in list
if ((this.config.controls || []).includes('settings') && this.config.settings.includes('captions')) {
@ -128,16 +155,70 @@ const captions = {
}
},
set(index, setLanguage = true, show = true) {
// Toggle captions display
// Used internally for the toggleCaptions method, with the passive option forced to false
toggle(input, passive = true) {
// If there's no full support
if (!this.supported.ui) {
return;
}
const { toggled } = this.captions; // Current state
const activeClass = this.config.classNames.captions.active;
// Get the next state
// If the method is called without parameter, toggle based on current value
const active = is.nullOrUndefined(input) ? !toggled : input;
// Update state and trigger event
if (active !== toggled) {
// When passive, don't override user preferences
if (!passive) {
this.captions.active = active;
this.storage.set({ captions: active });
}
// Force language if the call isn't passive and there is no matching language to toggle to
if (!this.language && active && !passive) {
const tracks = captions.getTracks.call(this);
const track = captions.findTrack.call(this, [this.captions.language, ...this.captions.languages], true);
// Override user preferences to avoid switching languages if a matching track is added
this.captions.language = track.language;
// Set caption, but don't store in localStorage as user preference
captions.set.call(this, tracks.indexOf(track));
return;
}
// Toggle state
this.elements.buttons.captions.pressed = active;
// Add class hook
toggleClass(this.elements.container, activeClass, active);
this.captions.toggled = active;
// Update settings menu
controls.updateSetting.call(this, 'captions');
// Trigger event (not used internally)
triggerEvent.call(this, this.media, active ? 'captionsenabled' : 'captionsdisabled');
}
},
// Set captions by track index
// Used internally for the currentTrack setter with the passive option forced to false
set(index, passive = true) {
const tracks = captions.getTracks.call(this);
// Disable captions if setting to -1
if (index === -1) {
this.toggleCaptions(false);
captions.toggle.call(this, false, passive);
return;
}
if (!utils.is.number(index)) {
if (!is.number(index)) {
this.debug.warn('Invalid caption argument', index);
return;
}
@ -149,15 +230,19 @@ const captions = {
if (this.captions.currentTrack !== index) {
this.captions.currentTrack = index;
const track = captions.getCurrentTrack.call(this);
const track = tracks[index];
const { language } = track || {};
// Store reference to node for invalidation on remove
this.captions.currentTrackNode = track;
// Prevent setting language in some cases, since it can violate user's intentions
if (setLanguage) {
// Update settings menu
controls.updateSetting.call(this, 'captions');
// When passive, don't override user preferences
if (!passive) {
this.captions.language = language;
this.storage.set({ language });
}
// Handle Vimeo captions
@ -166,32 +251,33 @@ const captions = {
}
// Trigger event
utils.dispatchEvent.call(this, this.media, 'languagechange');
triggerEvent.call(this, this.media, 'languagechange');
}
// Show captions
captions.toggle.call(this, true, passive);
if (this.isHTML5 && this.isVideo) {
// If we change the active track while a cue is already displayed we need to update it
captions.updateCues.call(this);
}
// Show captions
if (show) {
this.toggleCaptions(true);
}
},
setLanguage(language, show = true) {
if (!utils.is.string(language)) {
this.debug.warn('Invalid language argument', language);
// Set captions by language
// Used internally for the language setter with the passive option forced to false
setLanguage(input, passive = true) {
if (!is.string(input)) {
this.debug.warn('Invalid language argument', input);
return;
}
// Normalize
this.captions.language = language.toLowerCase();
const language = input.toLowerCase();
this.captions.language = language;
// Set currentTrack
const tracks = captions.getTracks.call(this);
const track = captions.getCurrentTrack.call(this, true);
captions.set.call(this, tracks.indexOf(track), false, show);
const track = captions.findTrack.call(this, [language]);
captions.set.call(this, tracks.indexOf(track), passive);
},
// Get current valid caption tracks
@ -204,34 +290,42 @@ const captions = {
// Filter out removed tracks and tracks that aren't captions/subtitles (for example metadata)
return tracks
.filter(track => !this.isHTML5 || update || this.captions.meta.has(track))
.filter(track => [
'captions',
'subtitles',
].includes(track.kind));
.filter(track => ['captions', 'subtitles'].includes(track.kind));
},
// Get the current track for the current language
getCurrentTrack(fromLanguage = false) {
// Match tracks based on languages and get the first
findTrack(languages, force = false) {
const tracks = captions.getTracks.call(this);
const sortIsDefault = track => Number((this.captions.meta.get(track) || {}).default);
const sorted = Array.from(tracks).sort((a, b) => sortIsDefault(b) - sortIsDefault(a));
return (!fromLanguage && tracks[this.currentTrack]) || sorted.find(track => track.language === this.captions.language) || sorted[0];
let track;
languages.every(language => {
track = sorted.find(track => track.language === language);
return !track; // Break iteration if there is a match
});
// If no match is found but is required, get first
return track || (force ? sorted[0] : undefined);
},
// Get the current track
getCurrentTrack() {
return captions.getTracks.call(this)[this.currentTrack];
},
// Get UI label for track
getLabel(track) {
let currentTrack = track;
if (!utils.is.track(currentTrack) && support.textTracks && this.captions.active) {
if (!is.track(currentTrack) && support.textTracks && this.captions.toggled) {
currentTrack = captions.getCurrentTrack.call(this);
}
if (utils.is.track(currentTrack)) {
if (!utils.is.empty(currentTrack.label)) {
if (is.track(currentTrack)) {
if (!is.empty(currentTrack.label)) {
return currentTrack.label;
}
if (!utils.is.empty(currentTrack.language)) {
if (!is.empty(currentTrack.language)) {
return track.language.toUpperCase();
}
@ -249,13 +343,13 @@ const captions = {
return;
}
if (!utils.is.element(this.elements.captions)) {
if (!is.element(this.elements.captions)) {
this.debug.warn('No captions element to render to');
return;
}
// Only accept array or empty input
if (!utils.is.nullOrUndefined(input) && !Array.isArray(input)) {
if (!is.nullOrUndefined(input) && !Array.isArray(input)) {
this.debug.warn('updateCues: Invalid input', input);
return;
}
@ -267,7 +361,7 @@ const captions = {
const track = captions.getCurrentTrack.call(this);
cues = Array.from((track || {}).activeCues || [])
.map(cue => cue.getCueAsHTML())
.map(utils.getHTML);
.map(getHTML);
}
// Set new caption text
@ -276,13 +370,13 @@ const captions = {
if (changed) {
// Empty the container and create a new child element
utils.emptyElement(this.elements.captions);
const caption = utils.createElement('span', utils.getAttributesFromSelector(this.config.selectors.caption));
emptyElement(this.elements.captions);
const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));
caption.innerHTML = content;
this.elements.captions.appendChild(caption);
// Trigger event
utils.dispatchEvent.call(this, this.media, 'cuechange');
triggerEvent.call(this, this.media, 'cuechange');
}
},
};

View File

@ -93,15 +93,7 @@ const defaults = {
// Speed default and options to display
speed: {
selected: 1,
options: [
0.5,
0.75,
1,
1.25,
1.5,
1.75,
2,
],
options: [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
},
// Keyboard shortcut settings
@ -155,11 +147,7 @@ const defaults = {
'airplay',
'fullscreen',
],
settings: [
'captions',
'quality',
'speed',
],
settings: ['captions', 'quality', 'speed'],
// Localisation
i18n: {
@ -215,7 +203,8 @@ const defaults = {
},
youtube: {
sdk: 'https://www.youtube.com/iframe_api',
api: 'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title))&part=snippet',
api:
'https://www.googleapis.com/youtube/v3/videos?id={0}&key={1}&fields=items(snippet(title))&part=snippet',
},
googleIMA: {
sdk: 'https://imasdk.googleapis.com/js/sdkloader/ima3.js',

View File

@ -13,4 +13,22 @@ export const types = {
video: 'video',
};
/**
* Get provider by URL
* @param {string} url
*/
export function getProviderByUrl(url) {
// YouTube
if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/.test(url)) {
return providers.youtube;
}
// Vimeo
if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) {
return providers.vimeo;
}
return null;
}
export default { providers, types };

478
src/js/controls.js vendored

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,10 @@
// https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API#prefixing
// ==========================================================================
import utils from './utils';
const browser = utils.getBrowser();
import browser from './utils/browser';
import { hasClass, toggleClass, trapFocus } from './utils/elements';
import { on, triggerEvent } from './utils/events';
import is from './utils/is';
function onChange() {
if (!this.enabled) {
@ -14,16 +15,16 @@ function onChange() {
// Update toggle button
const button = this.player.elements.buttons.fullscreen;
if (utils.is.element(button)) {
if (is.element(button)) {
button.pressed = this.active;
}
// Trigger an event
utils.dispatchEvent.call(this.player, this.target, this.active ? 'enterfullscreen' : 'exitfullscreen', true);
triggerEvent.call(this.player, this.target, this.active ? 'enterfullscreen' : 'exitfullscreen', true);
// Trap focus in container
if (!browser.isIos) {
utils.trapFocus.call(this.player, this.target, this.active);
trapFocus.call(this.player, this.target, this.active);
}
}
@ -42,7 +43,7 @@ function toggleFallback(toggle = false) {
document.body.style.overflow = toggle ? 'hidden' : '';
// Toggle class hook
utils.toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle);
toggleClass(this.target, this.player.config.classNames.fullscreen.fallback, toggle);
// Toggle button and fire events
onChange.call(this);
@ -62,15 +63,20 @@ class Fullscreen {
// Register event listeners
// Handle event (incase user presses escape etc)
utils.on(document, this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`, () => {
on.call(
this.player,
document,
this.prefix === 'ms' ? 'MSFullscreenChange' : `${this.prefix}fullscreenchange`,
() => {
// TODO: Filter for target??
onChange.call(this);
});
},
);
// Fullscreen toggle on double click
utils.on(this.player.elements.container, 'dblclick', event => {
on.call(this.player, this.player.elements.container, 'dblclick', event => {
// Ignore double click in controls
if (utils.is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) {
if (is.element(this.player.elements.controls) && this.player.elements.controls.contains(event.target)) {
return;
}
@ -83,26 +89,27 @@ class Fullscreen {
// Determine if native supported
static get native() {
return !!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled);
return !!(
document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.mozFullScreenEnabled ||
document.msFullscreenEnabled
);
}
// Get the prefix for handlers
static get prefix() {
// No prefix
if (utils.is.function(document.exitFullscreen)) {
if (is.function(document.exitFullscreen)) {
return '';
}
// Check for fullscreen support by vendor prefix
let value = '';
const prefixes = [
'webkit',
'moz',
'ms',
];
const prefixes = ['webkit', 'moz', 'ms'];
prefixes.some(pre => {
if (utils.is.function(document[`${pre}ExitFullscreen`]) || utils.is.function(document[`${pre}CancelFullScreen`])) {
if (is.function(document[`${pre}ExitFullscreen`]) || is.function(document[`${pre}CancelFullScreen`])) {
value = pre;
return true;
}
@ -135,7 +142,7 @@ class Fullscreen {
// Fallback using classname
if (!Fullscreen.native) {
return utils.hasClass(this.target, this.player.config.classNames.fullscreen.fallback);
return hasClass(this.target, this.player.config.classNames.fullscreen.fallback);
}
const element = !this.prefix ? document.fullscreenElement : document[`${this.prefix}${this.property}Element`];
@ -145,7 +152,9 @@ class Fullscreen {
// Get target element
get target() {
return browser.isIos && this.player.config.fullscreen.iosNative ? this.player.media : this.player.elements.container;
return browser.isIos && this.player.config.fullscreen.iosNative
? this.player.media
: this.player.elements.container;
}
// Update UI
@ -157,7 +166,7 @@ class Fullscreen {
}
// Add styling hook to show button
utils.toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.enabled);
toggleClass(this.player.elements.container, this.player.config.classNames.fullscreen.enabled, this.enabled);
}
// Make an element fullscreen
@ -175,7 +184,7 @@ class Fullscreen {
toggleFallback.call(this, true);
} else if (!this.prefix) {
this.target.requestFullscreen();
} else if (!utils.is.empty(this.prefix)) {
} else if (!is.empty(this.prefix)) {
this.target[`${this.prefix}Request${this.property}`]();
}
}
@ -194,7 +203,7 @@ class Fullscreen {
toggleFallback.call(this, false);
} else if (!this.prefix) {
(document.cancelFullScreen || document.exitFullscreen).call(document);
} else if (!utils.is.empty(this.prefix)) {
} else if (!is.empty(this.prefix)) {
const action = this.prefix === 'moz' ? 'Cancel' : 'Exit';
document[`${this.prefix}${action}${this.property}`]();
}

View File

@ -3,40 +3,28 @@
// ==========================================================================
import support from './support';
import utils from './utils';
import { removeElement } from './utils/elements';
import { triggerEvent } from './utils/events';
const html5 = {
getSources() {
if (!this.isHTML5) {
return null;
return [];
}
return this.media.querySelectorAll('source');
const sources = Array.from(this.media.querySelectorAll('source'));
// Filter out unsupported sources
return sources.filter(source => support.mime.call(this, source.getAttribute('type')));
},
// Get quality levels
getQualityOptions() {
if (!this.isHTML5) {
return null;
}
// Get sources
const sources = html5.getSources.call(this);
if (utils.is.empty(sources)) {
return null;
}
// Get <source> with size attribute
const sizes = Array.from(sources).filter(source => !utils.is.empty(source.getAttribute('size')));
// If none, bail
if (utils.is.empty(sizes)) {
return null;
}
// Reduce to unique list
return utils.dedupe(sizes.map(source => Number(source.getAttribute('size'))));
// Get sizes from <source> elements
return html5.getSources
.call(this)
.map(source => Number(source.getAttribute('size')))
.filter(Boolean);
},
extend() {
@ -51,60 +39,34 @@ const html5 = {
get() {
// Get sources
const sources = html5.getSources.call(player);
const [source] = sources.filter(source => source.getAttribute('src') === player.source);
if (utils.is.empty(sources)) {
return null;
}
const matches = Array.from(sources).filter(source => source.getAttribute('src') === player.source);
if (utils.is.empty(matches)) {
return null;
}
return Number(matches[0].getAttribute('size'));
// Return size, if match is found
return source && Number(source.getAttribute('size'));
},
set(input) {
// Get sources
const sources = html5.getSources.call(player);
if (utils.is.empty(sources)) {
// Get first match for requested size
const source = sources.find(source => Number(source.getAttribute('size')) === input);
// No matching source found
if (!source) {
return;
}
// Get matches for requested size
const matches = Array.from(sources).filter(source => Number(source.getAttribute('size')) === input);
// No matches for requested size
if (utils.is.empty(matches)) {
return;
}
// Get supported sources
const supported = matches.filter(source => support.mime.call(player, source.getAttribute('type')));
// No supported sources
if (utils.is.empty(supported)) {
return;
}
// Trigger change event
utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, {
quality: input,
});
// Get current state
const { currentTime, playing } = player;
// Set new source
player.media.src = supported[0].getAttribute('src');
player.media.src = source.getAttribute('src');
// Restore time
const onLoadedMetaData = () => {
player.currentTime = currentTime;
player.off('loadedmetadata', onLoadedMetaData);
};
player.on('loadedmetadata', onLoadedMetaData);
player.once('loadedmetadata', onLoadedMetaData);
// Load new source
player.media.load();
@ -115,7 +77,7 @@ const html5 = {
}
// Trigger change event
utils.dispatchEvent.call(player, player.media, 'qualitychange', false, {
triggerEvent.call(player, player.media, 'qualitychange', false, {
quality: input,
});
},
@ -130,7 +92,7 @@ const html5 = {
}
// Remove child sources
utils.removeElement(html5.getSources());
removeElement(html5.getSources.call(this));
// Set blank video src attribute
// This is to prevent a MEDIA_ERR_SRC_NOT_SUPPORTED error

View File

@ -2,17 +2,19 @@
// Plyr internationalization
// ==========================================================================
import utils from './utils';
import is from './utils/is';
import { getDeep } from './utils/objects';
import { replaceAll } from './utils/strings';
const i18n = {
get(key = '', config = {}) {
if (utils.is.empty(key) || utils.is.empty(config)) {
if (is.empty(key) || is.empty(config)) {
return '';
}
let string = utils.getDeep(config.i18n, key);
let string = getDeep(config.i18n, key);
if (utils.is.empty(string)) {
if (is.empty(string)) {
return '';
}
@ -21,11 +23,8 @@ const i18n = {
'{title}': config.title,
};
Object.entries(replace).forEach(([
key,
value,
]) => {
string = utils.replaceAll(string, key, value);
Object.entries(replace).forEach(([key, value]) => {
string = replaceAll(string, key, value);
});
return string;

View File

@ -4,10 +4,10 @@
import controls from './controls';
import ui from './ui';
import utils from './utils';
// Sniff out the browser
const browser = utils.getBrowser();
import browser from './utils/browser';
import { getElement, getElements, getFocusElement, matches, toggleClass, toggleHidden } from './utils/elements';
import { on, once, toggleListener, triggerEvent } from './utils/events';
import is from './utils/is';
class Listeners {
constructor(player) {
@ -32,7 +32,7 @@ class Listeners {
// If the event is bubbled from the media element
// Firefox doesn't get the keycode for whatever reason
if (!utils.is.number(code)) {
if (!is.number(code)) {
return;
}
@ -46,37 +46,16 @@ class Listeners {
// 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 preventDefault = [32, 37, 38, 39, 40, 48, 49, 50, 51, 52, 53, 54, 56, 57, 67, 70, 73, 75, 76, 77, 79];
// Check focused element
// and if the focused element is not editable (e.g. text input)
// and any that accept key input http://webaim.org/techniques/keyboard/
const focused = utils.getFocusElement();
if (utils.is.element(focused) && (
focused !== this.player.elements.inputs.seek &&
utils.matches(focused, this.player.config.selectors.editable))
const focused = getFocusElement();
if (
is.element(focused) &&
(focused !== this.player.elements.inputs.seek &&
matches(focused, this.player.config.selectors.editable))
) {
return;
}
@ -195,41 +174,37 @@ class Listeners {
this.player.touch = true;
// Add touch class
utils.toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true);
// Clean up
utils.off(document.body, 'touchstart', this.firstTouch);
toggleClass(this.player.elements.container, this.player.config.classNames.isTouch, true);
}
// Global window & document listeners
global(toggle = true) {
// Keyboard shortcuts
if (this.player.config.keyboard.global) {
utils.toggleListener(window, 'keydown keyup', this.handleKey, toggle, false);
toggleListener.call(this.player, window, 'keydown keyup', this.handleKey, toggle, false);
}
// Click anywhere closes menu
utils.toggleListener(document.body, 'click', this.toggleMenu, toggle);
toggleListener.call(this.player, document.body, 'click', this.toggleMenu, toggle);
// Detect touch by events
utils.on(document.body, 'touchstart', this.firstTouch);
once.call(this.player, document.body, 'touchstart', this.firstTouch);
}
// Container listeners
container() {
// Keyboard shortcuts
if (!this.player.config.keyboard.global && this.player.config.keyboard.focused) {
utils.on(this.player.elements.container, 'keydown keyup', this.handleKey, false);
on.call(this.player, this.player.elements.container, 'keydown keyup', this.handleKey, false);
}
// Detect tab focus
// Remove class on blur/focusout
utils.on(this.player.elements.container, 'focusout', event => {
utils.toggleClass(event.target, this.player.config.classNames.tabFocus, false);
on.call(this.player, this.player.elements.container, 'focusout', event => {
toggleClass(event.target, this.player.config.classNames.tabFocus, false);
});
// Add classname to tabbed elements
utils.on(this.player.elements.container, 'keydown', event => {
on.call(this.player, this.player.elements.container, 'keydown', event => {
if (event.keyCode !== 9) {
return;
}
@ -237,59 +212,64 @@ class Listeners {
// Delay the adding of classname until the focus has changed
// This event fires before the focusin event
setTimeout(() => {
utils.toggleClass(utils.getFocusElement(), this.player.config.classNames.tabFocus, true);
toggleClass(getFocusElement(), this.player.config.classNames.tabFocus, true);
}, 0);
});
// Toggle controls on mouse events and entering fullscreen
utils.on(this.player.elements.container, 'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen', event => {
const { controls } = this.player.elements;
on.call(
this.player,
this.player.elements.container,
'mousemove mouseleave touchstart touchmove enterfullscreen exitfullscreen',
event => {
const { controls } = this.player.elements;
// Remove button states for fullscreen
if (event.type === 'enterfullscreen') {
controls.pressed = false;
controls.hover = false;
}
// Remove button states for fullscreen
if (event.type === 'enterfullscreen') {
controls.pressed = false;
controls.hover = false;
}
// Show, then hide after a timeout unless another control event occurs
const show = [
'touchstart',
'touchmove',
'mousemove',
].includes(event.type);
// Show, then hide after a timeout unless another control event occurs
const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type);
let delay = 0;
let delay = 0;
if (show) {
ui.toggleControls.call(this.player, true);
// Use longer timeout for touch devices
delay = this.player.touch ? 3000 : 2000;
}
if (show) {
ui.toggleControls.call(this.player, true);
// Use longer timeout for touch devices
delay = this.player.touch ? 3000 : 2000;
}
// Clear timer
clearTimeout(this.player.timers.controls);
// Timer to prevent flicker when seeking
this.player.timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay);
});
// Clear timer
clearTimeout(this.player.timers.controls);
// Timer to prevent flicker when seeking
this.player.timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay);
},
);
}
// Listen for media events
media() {
// Time change on media
utils.on(this.player.media, 'timeupdate seeking seeked', event => controls.timeUpdate.call(this.player, event));
on.call(this.player, this.player.media, 'timeupdate seeking seeked', event =>
controls.timeUpdate.call(this.player, event),
);
// Display duration
utils.on(this.player.media, 'durationchange loadeddata loadedmetadata', event => controls.durationUpdate.call(this.player, event));
on.call(this.player, this.player.media, 'durationchange loadeddata loadedmetadata', event =>
controls.durationUpdate.call(this.player, event),
);
// Check for audio tracks on load
// We can't use `loadedmetadata` as it doesn't seem to have audio tracks at that point
utils.on(this.player.media, 'loadeddata', () => {
utils.toggleHidden(this.player.elements.volume, !this.player.hasAudio);
utils.toggleHidden(this.player.elements.buttons.mute, !this.player.hasAudio);
on.call(this.player, this.player.media, 'canplay', () => {
toggleHidden(this.player.elements.volume, !this.player.hasAudio);
toggleHidden(this.player.elements.buttons.mute, !this.player.hasAudio);
});
// Handle the media finishing
utils.on(this.player.media, 'ended', () => {
on.call(this.player, this.player.media, 'ended', () => {
// Show poster on end
if (this.player.isHTML5 && this.player.isVideo && this.player.config.resetOnEnd) {
// Restart
@ -298,20 +278,28 @@ class Listeners {
});
// Check for buffer progress
utils.on(this.player.media, 'progress playing seeking seeked', event => controls.updateProgress.call(this.player, event));
on.call(this.player, this.player.media, 'progress playing seeking seeked', event =>
controls.updateProgress.call(this.player, event),
);
// Handle volume changes
utils.on(this.player.media, 'volumechange', event => controls.updateVolume.call(this.player, event));
on.call(this.player, this.player.media, 'volumechange', event =>
controls.updateVolume.call(this.player, event),
);
// Handle play/pause
utils.on(this.player.media, 'playing play pause ended emptied timeupdate', event => ui.checkPlaying.call(this.player, event));
on.call(this.player, this.player.media, 'playing play pause ended emptied timeupdate', event =>
ui.checkPlaying.call(this.player, event),
);
// Loading state
utils.on(this.player.media, 'waiting canplay seeked playing', event => ui.checkLoading.call(this.player, event));
on.call(this.player, this.player.media, 'waiting canplay seeked playing', event =>
ui.checkLoading.call(this.player, event),
);
// If autoplay, then load advertisement if required
// TODO: Show some sort of loading state while the ad manager loads else there's a delay before ad shows
utils.on(this.player.media, 'playing', () => {
on.call(this.player, this.player.media, 'playing', () => {
if (!this.player.ads) {
return;
}
@ -326,15 +314,15 @@ class Listeners {
// Click video
if (this.player.supported.ui && this.player.config.clickToPlay && !this.player.isAudio) {
// Re-fetch the wrapper
const wrapper = utils.getElement.call(this.player, `.${this.player.config.classNames.video}`);
const wrapper = getElement.call(this.player, `.${this.player.config.classNames.video}`);
// Bail if there's no wrapper (this should never happen)
if (!utils.is.element(wrapper)) {
if (!is.element(wrapper)) {
return;
}
// On click play, pause ore restart
utils.on(wrapper, 'click', () => {
on.call(this.player, wrapper, 'click', () => {
// Touch devices will just show controls (if we're hiding controls)
if (this.player.config.hideControls && this.player.touch && !this.player.paused) {
return;
@ -353,7 +341,8 @@ class Listeners {
// Disable right click
if (this.player.supported.ui && this.player.config.disableContextMenu) {
utils.on(
on.call(
this.player,
this.player.elements.wrapper,
'contextmenu',
event => {
@ -364,13 +353,13 @@ class Listeners {
}
// Volume change
utils.on(this.player.media, 'volumechange', () => {
on.call(this.player, this.player.media, 'volumechange', () => {
// Save to storage
this.player.storage.set({ volume: this.player.volume, muted: this.player.muted });
});
// Speed change
utils.on(this.player.media, 'ratechange', () => {
on.call(this.player, this.player.media, 'ratechange', () => {
// Update UI
controls.updateSetting.call(this.player, 'speed');
@ -379,49 +368,29 @@ class Listeners {
});
// Quality request
utils.on(this.player.media, 'qualityrequested', event => {
on.call(this.player, this.player.media, 'qualityrequested', event => {
// Save to storage
this.player.storage.set({ quality: event.detail.quality });
});
// Quality change
utils.on(this.player.media, 'qualitychange', event => {
on.call(this.player, this.player.media, 'qualitychange', event => {
// Update UI
controls.updateSetting.call(this.player, 'quality', null, event.detail.quality);
});
// Caption language change
utils.on(this.player.media, 'languagechange', () => {
// Update UI
controls.updateSetting.call(this.player, 'captions');
// Save to storage
this.player.storage.set({ language: this.player.language });
});
// Captions toggle
utils.on(this.player.media, 'captionsenabled captionsdisabled', () => {
// Update UI
controls.updateSetting.call(this.player, 'captions');
// Save to storage
this.player.storage.set({ captions: this.player.captions.active });
});
// Proxy events to container
// Bubble up key events for Edge
utils.on(this.player.media, this.player.config.events.concat([
'keyup',
'keydown',
]).join(' '), event => {
let {detail = {}} = event;
const proxyEvents = this.player.config.events.concat(['keyup', 'keydown']).join(' ');
on.call(this.player, this.player.media, proxyEvents, event => {
let { detail = {} } = event;
// Get error details from media
if (event.type === 'error') {
detail = this.player.media.error;
}
utils.dispatchEvent.call(this.player, this.player.elements.container, event.type, true, detail);
triggerEvent.call(this.player, this.player.elements.container, event.type, true, detail);
});
}
@ -433,7 +402,7 @@ class Listeners {
// Run default and custom handlers
const proxy = (event, defaultHandler, customHandlerKey) => {
const customHandler = this.player.config.listeners[customHandlerKey];
const hasCustomHandler = utils.is.function(customHandler);
const hasCustomHandler = is.function(customHandler);
let returned = true;
// Execute custom handler
@ -442,33 +411,41 @@ class Listeners {
}
// Only call default handler if not prevented in custom handler
if (returned && utils.is.function(defaultHandler)) {
if (returned && is.function(defaultHandler)) {
defaultHandler.call(this.player, event);
}
};
// Trigger custom and default handlers
const on = (element, type, defaultHandler, customHandlerKey, passive = true) => {
const bind = (element, type, defaultHandler, customHandlerKey, passive = true) => {
const customHandler = this.player.config.listeners[customHandlerKey];
const hasCustomHandler = utils.is.function(customHandler);
const hasCustomHandler = is.function(customHandler);
utils.on(element, type, event => proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler);
on.call(
this.player,
element,
type,
event => proxy(event, defaultHandler, customHandlerKey),
passive && !hasCustomHandler,
);
};
// Play/pause toggle
on(this.player.elements.buttons.play, 'click', this.player.togglePlay, 'play');
Array.from(this.player.elements.buttons.play).forEach(button => {
bind(button, 'click', this.player.togglePlay, 'play');
});
// Pause
on(this.player.elements.buttons.restart, 'click', this.player.restart, 'restart');
bind(this.player.elements.buttons.restart, 'click', this.player.restart, 'restart');
// Rewind
on(this.player.elements.buttons.rewind, 'click', this.player.rewind, 'rewind');
bind(this.player.elements.buttons.rewind, 'click', this.player.rewind, 'rewind');
// Rewind
on(this.player.elements.buttons.fastForward, 'click', this.player.forward, 'fastForward');
bind(this.player.elements.buttons.fastForward, 'click', this.player.forward, 'fastForward');
// Mute toggle
on(
bind(
this.player.elements.buttons.mute,
'click',
() => {
@ -478,10 +455,10 @@ class Listeners {
);
// Captions toggle
on(this.player.elements.buttons.captions, 'click', this.player.toggleCaptions);
bind(this.player.elements.buttons.captions, 'click', () => this.player.toggleCaptions());
// Fullscreen toggle
on(
bind(
this.player.elements.buttons.fullscreen,
'click',
() => {
@ -491,7 +468,7 @@ class Listeners {
);
// Picture-in-Picture
on(
bind(
this.player.elements.buttons.pip,
'click',
() => {
@ -501,15 +478,15 @@ class Listeners {
);
// Airplay
on(this.player.elements.buttons.airplay, 'click', this.player.airplay, 'airplay');
bind(this.player.elements.buttons.airplay, 'click', this.player.airplay, 'airplay');
// Settings menu
on(this.player.elements.buttons.settings, 'click', event => {
bind(this.player.elements.buttons.settings, 'click', event => {
controls.toggleMenu.call(this.player, event);
});
// Settings menu
on(this.player.elements.settings.form, 'click', event => {
bind(this.player.elements.settings.form, 'click', event => {
event.stopPropagation();
// Go back to home tab on click
@ -519,7 +496,7 @@ class Listeners {
};
// Settings menu items - use event delegation as items are added/removed
if (utils.matches(event.target, this.player.config.selectors.inputs.language)) {
if (matches(event.target, this.player.config.selectors.inputs.language)) {
proxy(
event,
() => {
@ -528,7 +505,7 @@ class Listeners {
},
'language',
);
} else if (utils.matches(event.target, this.player.config.selectors.inputs.quality)) {
} else if (matches(event.target, this.player.config.selectors.inputs.quality)) {
proxy(
event,
() => {
@ -537,7 +514,7 @@ class Listeners {
},
'quality',
);
} else if (utils.matches(event.target, this.player.config.selectors.inputs.speed)) {
} else if (matches(event.target, this.player.config.selectors.inputs.speed)) {
proxy(
event,
() => {
@ -553,14 +530,14 @@ class Listeners {
});
// Set range input alternative "value", which matches the tooltip time (#954)
on(this.player.elements.inputs.seek, 'mousedown mousemove', event => {
bind(this.player.elements.inputs.seek, 'mousedown mousemove', event => {
const clientRect = this.player.elements.progress.getBoundingClientRect();
const percent = 100 / clientRect.width * (event.pageX - clientRect.left);
event.currentTarget.setAttribute('seek-value', percent);
});
// Pause while seeking
on(this.player.elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => {
bind(this.player.elements.inputs.seek, 'mousedown mouseup keydown keyup touchstart touchend', event => {
const seek = event.currentTarget;
const code = event.keyCode ? event.keyCode : event.which;
@ -573,11 +550,7 @@ class Listeners {
const play = seek.hasAttribute('play-on-seeked');
// Done seeking
const done = [
'mouseup',
'touchend',
'keyup',
].includes(event.type);
const done = ['mouseup', 'touchend', 'keyup'].includes(event.type);
// If we're done seeking and it was playing, resume playback
if (play && done) {
@ -590,7 +563,7 @@ class Listeners {
});
// Seek
on(
bind(
this.player.elements.inputs.seek,
inputEvent,
event => {
@ -599,7 +572,7 @@ class Listeners {
// If it exists, use seek-value instead of "value" for consistency with tooltip time (#954)
let seekTo = seek.getAttribute('seek-value');
if (utils.is.empty(seekTo)) {
if (is.empty(seekTo)) {
seekTo = seek.value;
}
@ -612,8 +585,8 @@ class Listeners {
// Current time invert
// Only if one time element is used for both currentTime and duration
if (this.player.config.toggleInvert && !utils.is.element(this.player.elements.display.duration)) {
on(this.player.elements.display.currentTime, 'click', () => {
if (this.player.config.toggleInvert && !is.element(this.player.elements.display.duration)) {
bind(this.player.elements.display.currentTime, 'click', () => {
// Do nothing if we're at the start
if (this.player.currentTime === 0) {
return;
@ -626,7 +599,7 @@ class Listeners {
}
// Volume
on(
bind(
this.player.elements.inputs.volume,
inputEvent,
event => {
@ -637,33 +610,32 @@ class Listeners {
// Polyfill for lower fill in <input type="range"> for webkit
if (browser.isWebkit) {
on(utils.getElements.call(this.player, 'input[type="range"]'), 'input', event => {
controls.updateRangeFill.call(this.player, event.target);
Array.from(getElements.call(this.player, 'input[type="range"]')).forEach(element => {
bind(element, 'input', event => controls.updateRangeFill.call(this.player, event.target));
});
}
// Seek tooltip
on(this.player.elements.progress, 'mouseenter mouseleave mousemove', event => controls.updateSeekTooltip.call(this.player, event));
bind(this.player.elements.progress, 'mouseenter mouseleave mousemove', event =>
controls.updateSeekTooltip.call(this.player, event),
);
// Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)
on(this.player.elements.controls, 'mouseenter mouseleave', event => {
bind(this.player.elements.controls, 'mouseenter mouseleave', event => {
this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter';
});
// Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)
on(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
this.player.elements.controls.pressed = [
'mousedown',
'touchstart',
].includes(event.type);
bind(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
this.player.elements.controls.pressed = ['mousedown', 'touchstart'].includes(event.type);
});
// Focus in/out on controls
on(this.player.elements.controls, 'focusin focusout', event => {
bind(this.player.elements.controls, 'focusin focusout', event => {
const { config, elements, timers } = this.player;
// Skip transition to prevent focus from scrolling the parent element
utils.toggleClass(elements.controls, config.classNames.noTransition, event.type === 'focusin');
toggleClass(elements.controls, config.classNames.noTransition, event.type === 'focusin');
// Toggle
ui.toggleControls.call(this.player, event.type === 'focusin');
@ -672,7 +644,7 @@ class Listeners {
if (event.type === 'focusin') {
// Restore transition
setTimeout(() => {
utils.toggleClass(elements.controls, config.classNames.noTransition, false);
toggleClass(elements.controls, config.classNames.noTransition, false);
}, 0);
// Delay a little more for keyboard users
@ -686,7 +658,7 @@ class Listeners {
});
// Mouse wheel for volume
on(
bind(
this.player.elements.inputs.volume,
'wheel',
event => {
@ -719,7 +691,10 @@ class Listeners {
}
// Don't break page scrolling at max and min
if ((direction === 1 && this.player.media.volume < 1) || (direction === -1 && this.player.media.volume > 0)) {
if (
(direction === 1 && this.player.media.volume < 1) ||
(direction === -1 && this.player.media.volume > 0)
) {
event.preventDefault();
}
},
@ -727,11 +702,6 @@ class Listeners {
false,
);
}
// Reset on destroy
clear() {
this.global(false);
}
}
export default Listeners;

View File

@ -5,7 +5,7 @@
import html5 from './html5';
import vimeo from './plugins/vimeo';
import youtube from './plugins/youtube';
import utils from './utils';
import { createElement, toggleClass, wrap } from './utils/elements';
const media = {
// Setup media
@ -17,50 +17,41 @@ const media = {
}
// Add type class
utils.toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true);
toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', this.type), true);
// Add provider class
utils.toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true);
toggleClass(this.elements.container, this.config.classNames.provider.replace('{0}', this.provider), true);
// Add video class for embeds
// This will require changes if audio embeds are added
if (this.isEmbed) {
utils.toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true);
toggleClass(this.elements.container, this.config.classNames.type.replace('{0}', 'video'), true);
}
// Inject the player wrapper
if (this.isVideo) {
// Create the wrapper div
this.elements.wrapper = utils.createElement('div', {
this.elements.wrapper = createElement('div', {
class: this.config.classNames.video,
});
// Wrap the video in a container
utils.wrap(this.media, this.elements.wrapper);
wrap(this.media, this.elements.wrapper);
// Faux poster container
this.elements.poster = utils.createElement('div', {
this.elements.poster = createElement('div', {
class: this.config.classNames.poster,
});
this.elements.wrapper.appendChild(this.elements.poster);
}
if (this.isEmbed) {
switch (this.provider) {
case 'youtube':
youtube.setup.call(this);
break;
case 'vimeo':
vimeo.setup.call(this);
break;
default:
break;
}
} else if (this.isHTML5) {
if (this.isHTML5) {
html5.extend.call(this);
} else if (this.isYouTube) {
youtube.setup.call(this);
} else if (this.isVimeo) {
vimeo.setup.call(this);
}
},
};

View File

@ -7,7 +7,12 @@
/* global google */
import i18n from '../i18n';
import utils from '../utils';
import { createElement } from './../utils/elements';
import { triggerEvent } from './../utils/events';
import is from './../utils/is';
import loadScript from './../utils/loadScript';
import { formatTime } from './../utils/time';
import { buildUrlParams } from './../utils/urls';
class Ads {
/**
@ -44,7 +49,7 @@ class Ads {
}
get enabled() {
return this.player.isVideo && this.player.config.ads.enabled && !utils.is.empty(this.publisherId);
return this.player.isVideo && this.player.config.ads.enabled && !is.empty(this.publisherId);
}
/**
@ -53,9 +58,8 @@ class Ads {
load() {
if (this.enabled) {
// Check if the Google IMA3 SDK is loaded or load it ourselves
if (!utils.is.object(window.google) || !utils.is.object(window.google.ima)) {
utils
.loadScript(this.player.config.urls.googleIMA.sdk)
if (!is.object(window.google) || !is.object(window.google.ima)) {
loadScript(this.player.config.urls.googleIMA.sdk)
.then(() => {
this.ready();
})
@ -103,7 +107,7 @@ class Ads {
const base = 'https://go.aniview.com/api/adserver6/vast/';
return `${base}?${utils.buildUrlParams(params)}`;
return `${base}?${buildUrlParams(params)}`;
}
/**
@ -116,7 +120,7 @@ class Ads {
*/
setupIMA() {
// Create the container for our advertisements
this.elements.container = utils.createElement('div', {
this.elements.container = createElement('div', {
class: this.player.config.classNames.ads,
});
this.player.elements.container.appendChild(this.elements.container);
@ -146,7 +150,11 @@ class Ads {
this.loader = new google.ima.AdsLoader(this.elements.displayContainer);
// Listen and respond to ads loaded and error events
this.loader.addEventListener(google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED, event => this.onAdsManagerLoaded(event), false);
this.loader.addEventListener(
google.ima.AdsManagerLoadedEvent.Type.ADS_MANAGER_LOADED,
event => this.onAdsManagerLoaded(event),
false,
);
this.loader.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false);
// Request video ads
@ -184,7 +192,7 @@ class Ads {
}
const update = () => {
const time = utils.formatTime(Math.max(this.manager.getRemainingTime(), 0));
const time = formatTime(Math.max(this.manager.getRemainingTime(), 0));
const label = `${i18n.get('advertisement', this.player.config)} - ${time}`;
this.elements.container.setAttribute('data-badge-text', label);
};
@ -212,14 +220,14 @@ class Ads {
this.cuePoints = this.manager.getCuePoints();
// Add advertisement cue's within the time line if available
if (!utils.is.empty(this.cuePoints)) {
if (!is.empty(this.cuePoints)) {
this.cuePoints.forEach(cuePoint => {
if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < this.player.duration) {
const seekElement = this.player.elements.progress;
if (utils.is.element(seekElement)) {
if (is.element(seekElement)) {
const cuePercentage = 100 / this.player.duration * cuePoint;
const cue = utils.createElement('span', {
const cue = createElement('span', {
class: this.player.config.classNames.cues,
});
@ -266,7 +274,7 @@ class Ads {
// Proxy event
const dispatchEvent = type => {
const event = `ads${type.replace(/_/g, '').toLowerCase()}`;
utils.dispatchEvent.call(this.player, this.player.media, event);
triggerEvent.call(this.player, this.player.media, event);
};
switch (event.type) {
@ -393,7 +401,7 @@ class Ads {
this.player.on('seeked', () => {
const seekedTime = this.player.currentTime;
if (utils.is.empty(this.cuePoints)) {
if (is.empty(this.cuePoints)) {
return;
}
@ -530,9 +538,9 @@ class Ads {
trigger(event, ...args) {
const handlers = this.events[event];
if (utils.is.array(handlers)) {
if (is.array(handlers)) {
handlers.forEach(handler => {
if (utils.is.function(handler)) {
if (is.function(handler)) {
handler.apply(this, args);
}
});
@ -546,7 +554,7 @@ class Ads {
* @return {Ads}
*/
on(event, callback) {
if (!utils.is.array(this.events[event])) {
if (!is.array(this.events[event])) {
this.events[event] = [];
}
@ -577,7 +585,7 @@ class Ads {
* @param {string} from
*/
clearSafetyTimer(from) {
if (!utils.is.nullOrUndefined(this.safetyTimer)) {
if (!is.nullOrUndefined(this.safetyTimer)) {
this.player.debug.log(`Safety timer cleared from: ${from}`);
clearTimeout(this.safetyTimer);

View File

@ -5,7 +5,34 @@
import captions from './../captions';
import controls from './../controls';
import ui from './../ui';
import utils from './../utils';
import { createElement, replaceElement, toggleClass } from './../utils/elements';
import { triggerEvent } from './../utils/events';
import fetch from './../utils/fetch';
import is from './../utils/is';
import loadScript from './../utils/loadScript';
import { format, stripHTML } from './../utils/strings';
import { buildUrlParams } from './../utils/urls';
// Parse Vimeo ID from URL
function parseId(url) {
if (is.empty(url)) {
return null;
}
if (is.number(Number(url))) {
return url;
}
const regex = /^.*(vimeo.com\/|video\/)(\d+).*/;
return url.match(regex) ? RegExp.$2 : url;
}
// Get aspect ratio for dimensions
function getAspectRatio(width, height) {
const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h));
const ratio = getRatio(width, height);
return `${width / ratio}:${height / ratio}`;
}
// Set playback state and trigger change (only on actual change)
function assurePlaybackState(play) {
@ -14,22 +41,21 @@ function assurePlaybackState(play) {
}
if (this.media.paused === play) {
this.media.paused = !play;
utils.dispatchEvent.call(this, this.media, play ? 'play' : 'pause');
triggerEvent.call(this, this.media, play ? 'play' : 'pause');
}
}
const vimeo = {
setup() {
// Add embed class for responsive
utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
// Set intial ratio
vimeo.setAspectRatio.call(this);
// Load the API if not already
if (!utils.is.object(window.Vimeo)) {
utils
.loadScript(this.config.urls.vimeo.sdk)
if (!is.object(window.Vimeo)) {
loadScript(this.config.urls.vimeo.sdk)
.then(() => {
vimeo.ready.call(this);
})
@ -44,8 +70,8 @@ const vimeo = {
// Set aspect ratio
// For Vimeo we have an extra 300% height <div> to hide the standard controls and UI
setAspectRatio(input) {
const ratio = utils.is.string(input) ? input.split(':') : this.config.ratio.split(':');
const padding = 100 / ratio[0] * ratio[1];
const [x, y] = (is.string(input) ? input : this.config.ratio).split(':');
const padding = 100 / x * y;
this.elements.wrapper.style.paddingBottom = `${padding}%`;
if (this.supported.ui) {
@ -73,34 +99,37 @@ const vimeo = {
gesture: 'media',
playsinline: !this.config.fullscreen.iosNative,
};
const params = utils.buildUrlParams(options);
const params = buildUrlParams(options);
// Get the source URL or ID
let source = player.media.getAttribute('src');
// Get from <div> if needed
if (utils.is.empty(source)) {
if (is.empty(source)) {
source = player.media.getAttribute(player.config.attributes.embed.id);
}
const id = utils.parseVimeoId(source);
const id = parseId(source);
// Build an iframe
const iframe = utils.createElement('iframe');
const src = utils.format(player.config.urls.vimeo.iframe, id, params);
const iframe = createElement('iframe');
const src = format(player.config.urls.vimeo.iframe, id, params);
iframe.setAttribute('src', src);
iframe.setAttribute('allowfullscreen', '');
iframe.setAttribute('allowtransparency', '');
iframe.setAttribute('allow', 'autoplay');
// Get poster, if already set
const { poster } = player;
// Inject the package
const wrapper = utils.createElement('div', { class: player.config.classNames.embedContainer });
const wrapper = createElement('div', { poster, class: player.config.classNames.embedContainer });
wrapper.appendChild(iframe);
player.media = utils.replaceElement(wrapper, player.media);
player.media = replaceElement(wrapper, player.media);
// Get poster image
utils.fetch(utils.format(player.config.urls.vimeo.api, id), 'json').then(response => {
if (utils.is.empty(response)) {
fetch(format(player.config.urls.vimeo.api, id), 'json').then(response => {
if (is.empty(response)) {
return;
}
@ -111,7 +140,7 @@ const vimeo = {
url.pathname = `${url.pathname.split('_')[0]}.jpg`;
// Set and show poster
ui.setPoster.call(player, url.href);
ui.setPoster.call(player, url.href).catch(() => {});
});
// Setup instance
@ -160,7 +189,7 @@ const vimeo = {
// Set seeking state and trigger event
media.seeking = true;
utils.dispatchEvent.call(player, media, 'seeking');
triggerEvent.call(player, media, 'seeking');
// If paused, mute until seek is complete
Promise.resolve(restorePause && embed.setVolume(0))
@ -187,7 +216,7 @@ const vimeo = {
.setPlaybackRate(input)
.then(() => {
speed = input;
utils.dispatchEvent.call(player, player.media, 'ratechange');
triggerEvent.call(player, player.media, 'ratechange');
})
.catch(error => {
// Hide menu item (and menu if empty)
@ -207,7 +236,7 @@ const vimeo = {
set(input) {
player.embed.setVolume(input).then(() => {
volume = input;
utils.dispatchEvent.call(player, player.media, 'volumechange');
triggerEvent.call(player, player.media, 'volumechange');
});
},
});
@ -219,11 +248,11 @@ const vimeo = {
return muted;
},
set(input) {
const toggle = utils.is.boolean(input) ? input : false;
const toggle = is.boolean(input) ? input : false;
player.embed.setVolume(toggle ? 0 : player.config.volume).then(() => {
muted = toggle;
utils.dispatchEvent.call(player, player.media, 'volumechange');
triggerEvent.call(player, player.media, 'volumechange');
});
},
});
@ -235,7 +264,7 @@ const vimeo = {
return loop;
},
set(input) {
const toggle = utils.is.boolean(input) ? input : player.config.loop.active;
const toggle = is.boolean(input) ? input : player.config.loop.active;
player.embed.setLoop(toggle).then(() => {
loop = toggle;
@ -268,11 +297,8 @@ const vimeo = {
});
// Set aspect ratio based on video size
Promise.all([
player.embed.getVideoWidth(),
player.embed.getVideoHeight(),
]).then(dimensions => {
const ratio = utils.getAspectRatio(dimensions[0], dimensions[1]);
Promise.all([player.embed.getVideoWidth(), player.embed.getVideoHeight()]).then(dimensions => {
const ratio = getAspectRatio(dimensions[0], dimensions[1]);
vimeo.setAspectRatio.call(this, ratio);
});
@ -290,13 +316,13 @@ const vimeo = {
// Get current time
player.embed.getCurrentTime().then(value => {
currentTime = value;
utils.dispatchEvent.call(player, player.media, 'timeupdate');
triggerEvent.call(player, player.media, 'timeupdate');
});
// Get duration
player.embed.getDuration().then(value => {
player.media.duration = value;
utils.dispatchEvent.call(player, player.media, 'durationchange');
triggerEvent.call(player, player.media, 'durationchange');
});
// Get captions
@ -306,7 +332,7 @@ const vimeo = {
});
player.embed.on('cuechange', ({ cues = [] }) => {
const strippedCues = cues.map(cue => utils.stripHTML(cue.text));
const strippedCues = cues.map(cue => stripHTML(cue.text));
captions.updateCues.call(player, strippedCues);
});
@ -315,11 +341,11 @@ const vimeo = {
player.embed.getPaused().then(paused => {
assurePlaybackState.call(player, !paused);
if (!paused) {
utils.dispatchEvent.call(player, player.media, 'playing');
triggerEvent.call(player, player.media, 'playing');
}
});
if (utils.is.element(player.embed.element) && player.supported.ui) {
if (is.element(player.embed.element) && player.supported.ui) {
const frame = player.embed.element;
// Fix keyboard focus issues
@ -330,7 +356,7 @@ const vimeo = {
player.embed.on('play', () => {
assurePlaybackState.call(player, true);
utils.dispatchEvent.call(player, player.media, 'playing');
triggerEvent.call(player, player.media, 'playing');
});
player.embed.on('pause', () => {
@ -340,16 +366,16 @@ const vimeo = {
player.embed.on('timeupdate', data => {
player.media.seeking = false;
currentTime = data.seconds;
utils.dispatchEvent.call(player, player.media, 'timeupdate');
triggerEvent.call(player, player.media, 'timeupdate');
});
player.embed.on('progress', data => {
player.media.buffered = data.percent;
utils.dispatchEvent.call(player, player.media, 'progress');
triggerEvent.call(player, player.media, 'progress');
// Check all loaded
if (parseInt(data.percent, 10) === 1) {
utils.dispatchEvent.call(player, player.media, 'canplaythrough');
triggerEvent.call(player, player.media, 'canplaythrough');
}
// Get duration as if we do it before load, it gives an incorrect value
@ -357,24 +383,24 @@ const vimeo = {
player.embed.getDuration().then(value => {
if (value !== player.media.duration) {
player.media.duration = value;
utils.dispatchEvent.call(player, player.media, 'durationchange');
triggerEvent.call(player, player.media, 'durationchange');
}
});
});
player.embed.on('seeked', () => {
player.media.seeking = false;
utils.dispatchEvent.call(player, player.media, 'seeked');
triggerEvent.call(player, player.media, 'seeked');
});
player.embed.on('ended', () => {
player.media.paused = true;
utils.dispatchEvent.call(player, player.media, 'ended');
triggerEvent.call(player, player.media, 'ended');
});
player.embed.on('error', detail => {
player.media.error = detail;
utils.dispatchEvent.call(player, player.media, 'error');
triggerEvent.call(player, player.media, 'error');
});
// Rebuild UI

View File

@ -4,64 +4,54 @@
import controls from './../controls';
import ui from './../ui';
import utils from './../utils';
import { dedupe } from './../utils/arrays';
import { createElement, replaceElement, toggleClass } from './../utils/elements';
import { triggerEvent } from './../utils/events';
import fetch from './../utils/fetch';
import is from './../utils/is';
import loadImage from './../utils/loadImage';
import loadScript from './../utils/loadScript';
import { format, generateId } from './../utils/strings';
// Parse YouTube ID from URL
function parseId(url) {
if (is.empty(url)) {
return null;
}
const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
return url.match(regex) ? RegExp.$2 : url;
}
// Standardise YouTube quality unit
function mapQualityUnit(input) {
switch (input) {
case 'hd2160':
return 2160;
const qualities = {
hd2160: 2160,
hd1440: 1440,
hd1080: 1080,
hd720: 720,
large: 480,
medium: 360,
small: 240,
tiny: 144,
};
case 2160:
return 'hd2160';
const entry = Object.entries(qualities).find(entry => entry.includes(input));
case 'hd1440':
return 1440;
case 1440:
return 'hd1440';
case 'hd1080':
return 1080;
case 1080:
return 'hd1080';
case 'hd720':
return 720;
case 720:
return 'hd720';
case 'large':
return 480;
case 480:
return 'large';
case 'medium':
return 360;
case 360:
return 'medium';
case 'small':
return 240;
case 240:
return 'small';
default:
return 'default';
if (entry) {
// Get the match corresponding to the input
return entry.find(value => value !== input);
}
return 'default';
}
function mapQualityUnits(levels) {
if (utils.is.empty(levels)) {
if (is.empty(levels)) {
return levels;
}
return utils.dedupe(levels.map(level => mapQualityUnit(level)));
return dedupe(levels.map(level => mapQualityUnit(level)));
}
// Set playback state and trigger change (only on actual change)
@ -71,24 +61,24 @@ function assurePlaybackState(play) {
}
if (this.media.paused === play) {
this.media.paused = !play;
utils.dispatchEvent.call(this, this.media, play ? 'play' : 'pause');
triggerEvent.call(this, this.media, play ? 'play' : 'pause');
}
}
const youtube = {
setup() {
// Add embed class for responsive
utils.toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
toggleClass(this.elements.wrapper, this.config.classNames.embed, true);
// Set aspect ratio
youtube.setAspectRatio.call(this);
// Setup API
if (utils.is.object(window.YT) && utils.is.function(window.YT.Player)) {
if (is.object(window.YT) && is.function(window.YT.Player)) {
youtube.ready.call(this);
} else {
// Load the API
utils.loadScript(this.config.urls.youtube.sdk).catch(error => {
loadScript(this.config.urls.youtube.sdk).catch(error => {
this.debug.warn('YouTube API failed to load', error);
});
@ -115,10 +105,10 @@ const youtube = {
// Try via undocumented API method first
// This method disappears now and then though...
// https://github.com/sampotts/plyr/issues/709
if (utils.is.function(this.embed.getVideoData)) {
if (is.function(this.embed.getVideoData)) {
const { title } = this.embed.getVideoData();
if (utils.is.empty(title)) {
if (is.empty(title)) {
this.config.title = title;
ui.setTitle.call(this);
return;
@ -127,13 +117,12 @@ const youtube = {
// Or via Google API
const key = this.config.keys.google;
if (utils.is.string(key) && !utils.is.empty(key)) {
const url = utils.format(this.config.urls.youtube.api, videoId, key);
if (is.string(key) && !is.empty(key)) {
const url = format(this.config.urls.youtube.api, videoId, key);
utils
.fetch(url)
fetch(url)
.then(result => {
if (utils.is.object(result)) {
if (is.object(result)) {
this.config.title = result.items[0].snippet.title;
ui.setTitle.call(this);
}
@ -154,7 +143,7 @@ const youtube = {
// Ignore already setup (race condition)
const currentId = player.media.getAttribute('id');
if (!utils.is.empty(currentId) && currentId.startsWith('youtube-')) {
if (!is.empty(currentId) && currentId.startsWith('youtube-')) {
return;
}
@ -162,30 +151,36 @@ const youtube = {
let source = player.media.getAttribute('src');
// Get from <div> if needed
if (utils.is.empty(source)) {
if (is.empty(source)) {
source = player.media.getAttribute(this.config.attributes.embed.id);
}
// Replace the <iframe> with a <div> due to YouTube API issues
const videoId = utils.parseYouTubeId(source);
const id = utils.generateId(player.provider);
const container = utils.createElement('div', { id });
player.media = utils.replaceElement(container, player.media);
const videoId = parseId(source);
const id = generateId(player.provider);
// Set poster image
// Get poster, if already set
const { poster } = player;
// Replace media element
const container = createElement('div', { id, poster });
player.media = replaceElement(container, player.media);
// Id to poster wrapper
const posterSrc = format => `https://img.youtube.com/vi/${videoId}/${format}default.jpg`;
// Check thumbnail images in order of quality, but reject fallback thumbnails (120px wide)
utils.loadImage(posterSrc('maxres'), 121) // Higest quality and unpadded
.catch(() => utils.loadImage(posterSrc('sd'), 121)) // 480p padded 4:3
.catch(() => utils.loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists
loadImage(posterSrc('maxres'), 121) // Higest quality and unpadded
.catch(() => loadImage(posterSrc('sd'), 121)) // 480p padded 4:3
.catch(() => loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists
.then(image => ui.setPoster.call(player, image.src))
.then(posterSrc => {
// If the image is padded, use background-size "cover" instead (like youtube does too with their posters)
if (!posterSrc.includes('maxres')) {
player.elements.poster.style.backgroundSize = 'cover';
}
});
})
.catch(() => {});
// Setup instance
// https://developers.google.com/youtube/iframe_api_reference
@ -211,49 +206,26 @@ const youtube = {
},
events: {
onError(event) {
// If we've already fired an error, don't do it again
// YouTube fires onError twice
if (utils.is.object(player.media.error)) {
return;
// YouTube may fire onError twice, so only handle it once
if (!player.media.error) {
const code = event.data;
// Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError
const message =
{
2: 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.',
5: 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.',
100: 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.',
101: 'The owner of the requested video does not allow it to be played in embedded players.',
150: 'The owner of the requested video does not allow it to be played in embedded players.',
}[code] || 'An unknown error occured';
player.media.error = { code, message };
triggerEvent.call(player, player.media, 'error');
}
const detail = {
code: event.data,
};
// Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError
switch (event.data) {
case 2:
detail.message =
'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.';
break;
case 5:
detail.message =
'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.';
break;
case 100:
detail.message =
'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.';
break;
case 101:
case 150:
detail.message = 'The owner of the requested video does not allow it to be played in embedded players.';
break;
default:
detail.message = 'An unknown error occured';
break;
}
player.media.error = detail;
utils.dispatchEvent.call(player, player.media, 'error');
},
onPlaybackQualityChange() {
utils.dispatchEvent.call(player, player.media, 'qualitychange', false, {
triggerEvent.call(player, player.media, 'qualitychange', false, {
quality: player.media.quality,
});
},
@ -264,7 +236,7 @@ const youtube = {
// Get current speed
player.media.playbackRate = instance.getPlaybackRate();
utils.dispatchEvent.call(player, player.media, 'ratechange');
triggerEvent.call(player, player.media, 'ratechange');
},
onReady(event) {
// Get the instance
@ -305,7 +277,7 @@ const youtube = {
// Set seeking state and trigger event
player.media.seeking = true;
utils.dispatchEvent.call(player, player.media, 'seeking');
triggerEvent.call(player, player.media, 'seeking');
// Seek after events sent
instance.seekTo(time);
@ -328,15 +300,7 @@ const youtube = {
return mapQualityUnit(instance.getPlaybackQuality());
},
set(input) {
const quality = input;
// Set via API
instance.setPlaybackQuality(mapQualityUnit(quality));
// Trigger request event
utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, {
quality,
});
instance.setPlaybackQuality(mapQualityUnit(input));
},
});
@ -349,7 +313,7 @@ const youtube = {
set(input) {
volume = input;
instance.setVolume(volume * 100);
utils.dispatchEvent.call(player, player.media, 'volumechange');
triggerEvent.call(player, player.media, 'volumechange');
},
});
@ -360,10 +324,10 @@ const youtube = {
return muted;
},
set(input) {
const toggle = utils.is.boolean(input) ? input : muted;
const toggle = is.boolean(input) ? input : muted;
muted = toggle;
instance[toggle ? 'mute' : 'unMute']();
utils.dispatchEvent.call(player, player.media, 'volumechange');
triggerEvent.call(player, player.media, 'volumechange');
},
});
@ -389,8 +353,8 @@ const youtube = {
player.media.setAttribute('tabindex', -1);
}
utils.dispatchEvent.call(player, player.media, 'timeupdate');
utils.dispatchEvent.call(player, player.media, 'durationchange');
triggerEvent.call(player, player.media, 'timeupdate');
triggerEvent.call(player, player.media, 'durationchange');
// Reset timer
clearInterval(player.timers.buffering);
@ -402,7 +366,7 @@ const youtube = {
// 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');
triggerEvent.call(player, player.media, 'progress');
}
// Set last buffer point
@ -413,7 +377,7 @@ const youtube = {
clearInterval(player.timers.buffering);
// Trigger event
utils.dispatchEvent.call(player, player.media, 'canplaythrough');
triggerEvent.call(player, player.media, 'canplaythrough');
}
}, 200);
@ -427,15 +391,12 @@ const youtube = {
// Reset timer
clearInterval(player.timers.playing);
const seeked = player.media.seeking && [
1,
2,
].includes(event.data);
const seeked = player.media.seeking && [1, 2].includes(event.data);
if (seeked) {
// Unset seeking and fire seeked event
player.media.seeking = false;
utils.dispatchEvent.call(player, player.media, 'seeked');
triggerEvent.call(player, player.media, 'seeked');
}
// Handle events
@ -448,11 +409,11 @@ const youtube = {
switch (event.data) {
case -1:
// Update scrubber
utils.dispatchEvent.call(player, player.media, 'timeupdate');
triggerEvent.call(player, player.media, 'timeupdate');
// Get loaded % from YouTube
player.media.buffered = instance.getVideoLoadedFraction();
utils.dispatchEvent.call(player, player.media, 'progress');
triggerEvent.call(player, player.media, 'progress');
break;
@ -465,7 +426,7 @@ const youtube = {
instance.stopVideo();
instance.playVideo();
} else {
utils.dispatchEvent.call(player, player.media, 'ended');
triggerEvent.call(player, player.media, 'ended');
}
break;
@ -477,11 +438,11 @@ const youtube = {
} else {
assurePlaybackState.call(player, true);
utils.dispatchEvent.call(player, player.media, 'playing');
triggerEvent.call(player, player.media, 'playing');
// Poll to get playback progress
player.timers.playing = setInterval(() => {
utils.dispatchEvent.call(player, player.media, 'timeupdate');
triggerEvent.call(player, player.media, 'timeupdate');
}, 50);
// Check duration again due to YouTube bug
@ -489,11 +450,14 @@ const youtube = {
// 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');
triggerEvent.call(player, player.media, 'durationchange');
}
// Get quality
controls.setQualityMenu.call(player, mapQualityUnits(instance.getAvailableQualityLevels()));
controls.setQualityMenu.call(
player,
mapQualityUnits(instance.getAvailableQualityLevels()),
);
}
break;
@ -511,7 +475,7 @@ const youtube = {
break;
}
utils.dispatchEvent.call(player, player.elements.container, 'statechange', false, {
triggerEvent.call(player, player.elements.container, 'statechange', false, {
code: event.data,
});
},

View File

@ -6,9 +6,10 @@
// ==========================================================================
import captions from './captions';
import defaults from './config/defaults';
import { getProviderByUrl, providers, types } from './config/types';
import Console from './console';
import controls from './controls';
import defaults from './defaults';
import Fullscreen from './fullscreen';
import Listeners from './listeners';
import media from './media';
@ -16,9 +17,14 @@ import Ads from './plugins/ads';
import source from './source';
import Storage from './storage';
import support from './support';
import { providers, types } from './types';
import ui from './ui';
import utils from './utils';
import { closest } from './utils/arrays';
import { createElement, hasClass, removeElement, replaceElement, toggleClass, wrap } from './utils/elements';
import { off, on, once, triggerEvent, unbindListeners } from './utils/events';
import is from './utils/is';
import loadSprite from './utils/loadSprite';
import { cloneDeep, extend } from './utils/objects';
import { parseUrl } from './utils/urls';
// Private properties
// TODO: Use a WeakMap for private globals
@ -41,18 +47,18 @@ class Plyr {
this.media = target;
// String selector passed
if (utils.is.string(this.media)) {
if (is.string(this.media)) {
this.media = document.querySelectorAll(this.media);
}
// jQuery, NodeList or Array passed, use first element
if ((window.jQuery && this.media instanceof jQuery) || utils.is.nodeList(this.media) || utils.is.array(this.media)) {
if ((window.jQuery && this.media instanceof jQuery) || is.nodeList(this.media) || is.array(this.media)) {
// eslint-disable-next-line
this.media = this.media[0];
}
// Set config
this.config = utils.extend(
this.config = extend(
{},
defaults,
Plyr.defaults,
@ -108,7 +114,7 @@ class Plyr {
this.debug.log('Support', support);
// We need an element to setup
if (utils.is.nullOrUndefined(this.media) || !utils.is.element(this.media)) {
if (is.nullOrUndefined(this.media) || !is.element(this.media)) {
this.debug.error('Setup failed: no suitable element passed');
return;
}
@ -144,7 +150,6 @@ class Plyr {
// Embed properties
let iframe = null;
let url = null;
let params = null;
// Different setup based on type
switch (type) {
@ -153,10 +158,10 @@ class Plyr {
iframe = this.media.querySelector('iframe');
// <iframe> type
if (utils.is.element(iframe)) {
if (is.element(iframe)) {
// Detect provider
url = iframe.getAttribute('src');
this.provider = utils.getProviderByUrl(url);
url = parseUrl(iframe.getAttribute('src'));
this.provider = getProviderByUrl(url.toString());
// Rework elements
this.elements.container = this.media;
@ -166,24 +171,20 @@ class Plyr {
this.elements.container.className = '';
// Get attributes from URL and set config
params = utils.getUrlParams(url);
if (!utils.is.empty(params)) {
const truthy = [
'1',
'true',
];
if (url.searchParams.length) {
const truthy = ['1', 'true'];
if (truthy.includes(params.autoplay)) {
if (truthy.includes(url.searchParams.get('autoplay'))) {
this.config.autoplay = true;
}
if (truthy.includes(params.loop)) {
if (truthy.includes(url.searchParams.get('loop'))) {
this.config.loop.active = true;
}
// TODO: replace fullscreen.iosNative with this playsinline config option
// YouTube requires the playsinline in the URL
if (this.isYouTube) {
this.config.playsinline = truthy.includes(params.playsinline);
this.config.playsinline = truthy.includes(url.searchParams.get('playsinline'));
} else {
this.config.playsinline = true;
}
@ -197,7 +198,7 @@ class Plyr {
}
// Unsupported or missing provider
if (utils.is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) {
if (is.empty(this.provider) || !Object.keys(providers).includes(this.provider)) {
this.debug.error('Setup failed: Invalid provider');
return;
}
@ -245,6 +246,8 @@ class Plyr {
return;
}
this.eventListeners = [];
// Create listeners
this.listeners = new Listeners(this);
@ -255,9 +258,9 @@ class Plyr {
this.media.plyr = this;
// Wrap media
if (!utils.is.element(this.elements.container)) {
this.elements.container = utils.createElement('div');
utils.wrap(this.media, this.elements.container);
if (!is.element(this.elements.container)) {
this.elements.container = createElement('div');
wrap(this.media, this.elements.container);
}
// Add style hook
@ -268,7 +271,7 @@ class Plyr {
// Listen for events if debugging
if (this.config.debug) {
utils.on(this.elements.container, this.config.events.join(' '), event => {
on.call(this, this.elements.container, this.config.events.join(' '), event => {
this.debug.log(`event: ${event.type}`);
});
}
@ -327,7 +330,7 @@ class Plyr {
* Play the media, or play the advertisement (if they are not blocked)
*/
play() {
if (!utils.is.function(this.media.play)) {
if (!is.function(this.media.play)) {
return null;
}
@ -339,7 +342,7 @@ class Plyr {
* Pause the media
*/
pause() {
if (!this.playing || !utils.is.function(this.media.pause)) {
if (!this.playing || !is.function(this.media.pause)) {
return;
}
@ -380,7 +383,7 @@ class Plyr {
*/
togglePlay(input) {
// Toggle based on current state if nothing passed
const toggle = utils.is.boolean(input) ? input : !this.playing;
const toggle = is.boolean(input) ? input : !this.playing;
if (toggle) {
this.play();
@ -396,7 +399,7 @@ class Plyr {
if (this.isHTML5) {
this.pause();
this.restart();
} else if (utils.is.function(this.media.stop)) {
} else if (is.function(this.media.stop)) {
this.media.stop();
}
}
@ -413,7 +416,7 @@ class Plyr {
* @param {number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime
*/
rewind(seekTime) {
this.currentTime = this.currentTime - (utils.is.number(seekTime) ? seekTime : this.config.seekTime);
this.currentTime = this.currentTime - (is.number(seekTime) ? seekTime : this.config.seekTime);
}
/**
@ -421,7 +424,7 @@ class Plyr {
* @param {number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime
*/
forward(seekTime) {
this.currentTime = this.currentTime + (utils.is.number(seekTime) ? seekTime : this.config.seekTime);
this.currentTime = this.currentTime + (is.number(seekTime) ? seekTime : this.config.seekTime);
}
/**
@ -435,7 +438,7 @@ class Plyr {
}
// Validate input
const inputIsValid = utils.is.number(input) && input > 0;
const inputIsValid = is.number(input) && input > 0;
// Set
this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;
@ -458,7 +461,7 @@ class Plyr {
const { buffered } = this.media;
// YouTube / Vimeo return a float between 0-1
if (utils.is.number(buffered)) {
if (is.number(buffered)) {
return buffered;
}
@ -502,17 +505,17 @@ class Plyr {
const max = 1;
const min = 0;
if (utils.is.string(volume)) {
if (is.string(volume)) {
volume = Number(volume);
}
// Load volume from storage if no value specified
if (!utils.is.number(volume)) {
if (!is.number(volume)) {
volume = this.storage.get('volume');
}
// Use config if all else fails
if (!utils.is.number(volume)) {
if (!is.number(volume)) {
({ volume } = this.config);
}
@ -532,7 +535,7 @@ class Plyr {
this.media.volume = volume;
// If muted, and we're increasing volume manually, reset muted state
if (!utils.is.empty(value) && this.muted && volume > 0) {
if (!is.empty(value) && this.muted && volume > 0) {
this.muted = false;
}
}
@ -550,7 +553,7 @@ class Plyr {
*/
increaseVolume(step) {
const volume = this.media.muted ? 0 : this.volume;
this.volume = volume + (utils.is.number(step) ? step : 1);
this.volume = volume + (is.number(step) ? step : 1);
}
/**
@ -559,7 +562,7 @@ class Plyr {
*/
decreaseVolume(step) {
const volume = this.media.muted ? 0 : this.volume;
this.volume = volume - (utils.is.number(step) ? step : 1);
this.volume = volume - (is.number(step) ? step : 1);
}
/**
@ -570,12 +573,12 @@ class Plyr {
let toggle = mute;
// Load muted state from storage
if (!utils.is.boolean(toggle)) {
if (!is.boolean(toggle)) {
toggle = this.storage.get('muted');
}
// Use config if all else fails
if (!utils.is.boolean(toggle)) {
if (!is.boolean(toggle)) {
toggle = this.config.muted;
}
@ -621,15 +624,15 @@ class Plyr {
set speed(input) {
let speed = null;
if (utils.is.number(input)) {
if (is.number(input)) {
speed = input;
}
if (!utils.is.number(speed)) {
if (!is.number(speed)) {
speed = this.storage.get('speed');
}
if (!utils.is.number(speed)) {
if (!is.number(speed)) {
speed = this.config.speed.selected;
}
@ -666,36 +669,31 @@ class Plyr {
* @param {number} input - Quality level
*/
set quality(input) {
let quality = null;
const config = this.config.quality;
const options = this.options.quality;
if (!utils.is.empty(input)) {
quality = Number(input);
}
if (!utils.is.number(quality)) {
quality = this.storage.get('quality');
}
if (!utils.is.number(quality)) {
quality = this.config.quality.selected;
}
if (!utils.is.number(quality)) {
quality = this.config.quality.default;
}
if (!this.options.quality.length) {
if (!options.length) {
return;
}
if (!this.options.quality.includes(quality)) {
const closest = utils.closest(this.options.quality, quality);
this.debug.warn(`Unsupported quality option: ${quality}, using ${closest} instead`);
quality = closest;
let quality = [
!is.empty(input) && Number(input),
this.storage.get('quality'),
config.selected,
config.default,
].find(is.number);
if (!options.includes(quality)) {
const value = closest(options, quality);
this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);
quality = value;
}
// Trigger request event
triggerEvent.call(this, this.media, 'qualityrequested', false, { quality });
// Update config
this.config.quality.selected = quality;
config.selected = quality;
// Set quality
this.media.quality = quality;
@ -714,7 +712,7 @@ class Plyr {
* @param {boolean} input - Whether to loop or not
*/
set loop(input) {
const toggle = utils.is.boolean(input) ? input : this.config.loop.active;
const toggle = is.boolean(input) ? input : this.config.loop.active;
this.config.loop.active = toggle;
this.media.loop = toggle;
@ -794,7 +792,7 @@ class Plyr {
return;
}
ui.setPoster.call(this, input);
ui.setPoster.call(this, input, false).catch(() => {});
}
/**
@ -813,7 +811,7 @@ class Plyr {
* @param {boolean} input - Whether to autoplay or not
*/
set autoplay(input) {
const toggle = utils.is.boolean(input) ? input : this.config.autoplay;
const toggle = is.boolean(input) ? input : this.config.autoplay;
this.config.autoplay = toggle;
}
@ -829,41 +827,23 @@ class Plyr {
* @param {boolean} input - Whether to enable captions
*/
toggleCaptions(input) {
// If there's no full support
if (!this.supported.ui) {
return;
captions.toggle.call(this, input, false);
}
// If the method is called without parameter, toggle based on current value
const active = utils.is.boolean(input) ? input : !this.elements.container.classList.contains(this.config.classNames.captions.active);
// Toggle state
this.elements.buttons.captions.pressed = active;
// Add class hook
utils.toggleClass(this.elements.container, this.config.classNames.captions.active, active);
// Update state and trigger event
if (active !== this.captions.active) {
this.captions.active = active;
utils.dispatchEvent.call(this, this.media, this.captions.active ? 'captionsenabled' : 'captionsdisabled');
}
}
/**
* Set the caption track by index
* @param {number} - Caption index
*/
set currentTrack(input) {
captions.set.call(this, input);
captions.set.call(this, input, false);
}
/**
* Get the current caption track index (-1 if disabled)
*/
get currentTrack() {
const { active, currentTrack } = this.captions;
return active ? currentTrack : -1;
const { toggled, currentTrack } = this.captions;
return toggled ? currentTrack : -1;
}
/**
@ -872,7 +852,7 @@ class Plyr {
* @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc)
*/
set language(input) {
captions.setLanguage.call(this, input);
captions.setLanguage.call(this, input, false);
}
/**
@ -899,7 +879,7 @@ class Plyr {
}
// Toggle based on current state if not passed
const toggle = utils.is.boolean(input) ? input : this.pip === states.inline;
const toggle = is.boolean(input) ? input : this.pip === states.inline;
// Toggle based on current state
this.media.webkitSetPresentationMode(toggle ? states.pip : states.inline);
@ -935,22 +915,22 @@ class Plyr {
// Don't toggle if missing UI support or if it's audio
if (this.supported.ui && !this.isAudio) {
// Get state before change
const isHidden = utils.hasClass(this.elements.container, this.config.classNames.hideControls);
const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls);
// Negate the argument if not undefined since adding the class to hides the controls
const force = typeof toggle === 'undefined' ? undefined : !toggle;
// Apply and get updated state
const hiding = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, force);
const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);
// Close menu
if (hiding && this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) {
if (hiding && this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
controls.toggleMenu.call(this, false);
}
// Trigger event on change
if (hiding !== isHidden) {
const eventName = hiding ? 'controlshidden' : 'controlsshown';
utils.dispatchEvent.call(this, this.media, eventName);
triggerEvent.call(this, this.media, eventName);
}
return !hiding;
}
@ -963,16 +943,23 @@ class Plyr {
* @param {function} callback - Callback for when event occurs
*/
on(event, callback) {
utils.on(this.elements.container, event, callback);
on.call(this, this.elements.container, event, callback);
}
/**
* Add event listeners once
* @param {string} event - Event type
* @param {function} callback - Callback for when event occurs
*/
once(event, callback) {
once.call(this, this.elements.container, event, callback);
}
/**
* Remove event listeners
* @param {string} event - Event type
* @param {function} callback - Callback for when event occurs
*/
off(event, callback) {
utils.off(this.elements.container, event, callback);
off(this.elements.container, event, callback);
}
/**
@ -998,10 +985,10 @@ class Plyr {
if (soft) {
if (Object.keys(this.elements).length) {
// Remove elements
utils.removeElement(this.elements.buttons.play);
utils.removeElement(this.elements.captions);
utils.removeElement(this.elements.controls);
utils.removeElement(this.elements.wrapper);
removeElement(this.elements.buttons.play);
removeElement(this.elements.captions);
removeElement(this.elements.controls);
removeElement(this.elements.wrapper);
// Clear for GC
this.elements.buttons.play = null;
@ -1011,21 +998,21 @@ class Plyr {
}
// Callback
if (utils.is.function(callback)) {
if (is.function(callback)) {
callback();
}
} else {
// Unbind listeners
this.listeners.clear();
unbindListeners.call(this);
// Replace the container with the original element provided
utils.replaceElement(this.elements.original, this.elements.container);
replaceElement(this.elements.original, this.elements.container);
// Event
utils.dispatchEvent.call(this, this.elements.original, 'destroyed', true);
triggerEvent.call(this, this.elements.original, 'destroyed', true);
// Callback
if (utils.is.function(callback)) {
if (is.function(callback)) {
callback.call(this.elements.original);
}
@ -1043,10 +1030,8 @@ class Plyr {
// Stop playback
this.stop();
// Type specific stuff
switch (`${this.provider}:${this.type}`) {
case 'html5:video':
case 'html5:audio':
// Provider specific stuff
if (this.isHTML5) {
// Clear timeout
clearTimeout(this.timers.loading);
@ -1055,25 +1040,19 @@ class Plyr {
// Clean up
done();
break;
case 'youtube:video':
} else if (this.isYouTube) {
// Clear timers
clearInterval(this.timers.buffering);
clearInterval(this.timers.playing);
// Destroy YouTube API
if (this.embed !== null && utils.is.function(this.embed.destroy)) {
if (this.embed !== null && is.function(this.embed.destroy)) {
this.embed.destroy();
}
// Clean up
done();
break;
case 'vimeo:video':
} else if (this.isVimeo) {
// Destroy Vimeo API
// then clean up (wait, to prevent postmessage errors)
if (this.embed !== null) {
@ -1082,11 +1061,6 @@ class Plyr {
// Vimeo does not always return
setTimeout(done, 200);
break;
default:
break;
}
}
@ -1114,7 +1088,7 @@ class Plyr {
* @param {string} [id] - Unique ID
*/
static loadSprite(url, id) {
return utils.loadSprite(url, id);
return loadSprite(url, id);
}
/**
@ -1125,15 +1099,15 @@ class Plyr {
static setup(selector, options = {}) {
let targets = null;
if (utils.is.string(selector)) {
if (is.string(selector)) {
targets = Array.from(document.querySelectorAll(selector));
} else if (utils.is.nodeList(selector)) {
} else if (is.nodeList(selector)) {
targets = Array.from(selector);
} else if (utils.is.array(selector)) {
targets = selector.filter(utils.is.element);
} else if (is.array(selector)) {
targets = selector.filter(is.element);
}
if (utils.is.empty(targets)) {
if (is.empty(targets)) {
return null;
}
@ -1141,6 +1115,6 @@ class Plyr {
}
}
Plyr.defaults = utils.cloneDeep(defaults);
Plyr.defaults = cloneDeep(defaults);
export default Plyr;

View File

@ -2,23 +2,25 @@
// Plyr source update
// ==========================================================================
import { providers } from './config/types';
import html5 from './html5';
import media from './media';
import support from './support';
import { providers } from './types';
import ui from './ui';
import utils from './utils';
import { createElement, insertElement, removeElement } from './utils/elements';
import is from './utils/is';
import { getDeep } from './utils/objects';
const source = {
// Add elements to HTML5 media (source, tracks, etc)
insertElements(type, attributes) {
if (utils.is.string(attributes)) {
utils.insertElement(type, this.media, {
if (is.string(attributes)) {
insertElement(type, this.media, {
src: attributes,
});
} else if (utils.is.array(attributes)) {
} else if (is.array(attributes)) {
attributes.forEach(attribute => {
utils.insertElement(type, this.media, attribute);
insertElement(type, this.media, attribute);
});
}
},
@ -26,7 +28,7 @@ const source = {
// Update source
// Sources are not checked for support so be careful
change(input) {
if (!utils.is.object(input) || !('sources' in input) || !input.sources.length) {
if (!getDeep(input, 'sources.length')) {
this.debug.warn('Invalid source format');
return;
}
@ -42,47 +44,34 @@ const source = {
this.options.quality = [];
// Remove elements
utils.removeElement(this.media);
removeElement(this.media);
this.media = null;
// Reset class name
if (utils.is.element(this.elements.container)) {
if (is.element(this.elements.container)) {
this.elements.container.removeAttribute('class');
}
// Set the type and provider
this.type = input.type;
this.provider = !utils.is.empty(input.sources[0].provider) ? input.sources[0].provider : providers.html5;
const { sources, type } = input;
const [{ provider = providers.html5, src }] = sources;
const tagName = provider === 'html5' ? type : 'div';
const attributes = provider === 'html5' ? {} : { src };
// Check for support
this.supported = support.check(this.type, this.provider, this.config.playsinline);
// Create new markup
switch (`${this.provider}:${this.type}`) {
case 'html5:video':
this.media = utils.createElement('video');
break;
case 'html5:audio':
this.media = utils.createElement('audio');
break;
case 'youtube:video':
case 'vimeo:video':
this.media = utils.createElement('div', {
src: input.sources[0].src,
});
break;
default:
break;
}
Object.assign(this, {
provider,
type,
// Check for support
supported: support.check(type, provider, this.config.playsinline),
// Create new element
media: createElement(tagName, attributes),
});
// Inject the new element
this.elements.container.appendChild(this.media);
// Autoplay the new source?
if (utils.is.boolean(input.autoplay)) {
if (is.boolean(input.autoplay)) {
this.config.autoplay = input.autoplay;
}
@ -94,7 +83,7 @@ const source = {
if (this.config.autoplay) {
this.media.setAttribute('autoplay', '');
}
if (!utils.is.empty(input.poster)) {
if (!is.empty(input.poster)) {
this.poster = input.poster;
}
if (this.config.loop.active) {
@ -113,7 +102,7 @@ const source = {
// Set new sources for html5
if (this.isHTML5) {
source.insertElements.call(this, 'source', input.sources);
source.insertElements.call(this, 'source', sources);
}
// Set video title

View File

@ -2,7 +2,8 @@
// Plyr storage
// ==========================================================================
import utils from './utils';
import is from './utils/is';
import { extend } from './utils/objects';
class Storage {
constructor(player) {
@ -37,13 +38,13 @@ class Storage {
const store = window.localStorage.getItem(this.key);
if (utils.is.empty(store)) {
if (is.empty(store)) {
return null;
}
const json = JSON.parse(store);
return utils.is.string(key) && key.length ? json[key] : json;
return is.string(key) && key.length ? json[key] : json;
}
set(object) {
@ -53,7 +54,7 @@ class Storage {
}
// Can only store objectst
if (!utils.is.object(object)) {
if (!is.object(object)) {
return;
}
@ -61,12 +62,12 @@ class Storage {
let storage = this.get();
// Default to empty object
if (utils.is.empty(storage)) {
if (is.empty(storage)) {
storage = {};
}
// Update the working copy of the values
utils.extend(storage, object);
extend(storage, object);
// Update storage
window.localStorage.setItem(this.key, JSON.stringify(storage));

View File

@ -2,7 +2,19 @@
// Plyr support checks
// ==========================================================================
import utils from './utils';
import { transitionEndEvent } from './utils/animation';
import browser from './utils/browser';
import { createElement } from './utils/elements';
import is from './utils/is';
// Default codecs for checking mimetype support
const defaultCodecs = {
'audio/ogg': 'vorbis',
'audio/wav': '1',
'video/webm': 'vp8, vorbis',
'video/mp4': 'avc1.42E01E, mp4a.40.2',
'video/ogg': 'theora',
};
// Check for feature support
const support = {
@ -13,32 +25,9 @@ const support = {
// Check for support
// Basic functionality vs full UI
check(type, provider, playsinline) {
let api = false;
let ui = false;
const browser = utils.getBrowser();
const canPlayInline = browser.isIPhone && playsinline && support.playsinline;
switch (`${provider}:${type}`) {
case 'html5:video':
api = support.video;
ui = api && support.rangeInput && (!browser.isIPhone || canPlayInline);
break;
case 'html5:audio':
api = support.audio;
ui = api && support.rangeInput;
break;
case 'youtube:video':
case 'vimeo:video':
api = true;
ui = support.rangeInput && (!browser.isIPhone || canPlayInline);
break;
default:
api = support.audio && support.video;
ui = api && support.rangeInput;
}
const api = support[type] || provider !== 'html5';
const ui = api && support.rangeInput && (type !== 'video' || !browser.isIPhone || canPlayInline);
return {
api,
@ -48,14 +37,11 @@ const support = {
// Picture-in-picture support
// Safari only currently
pip: (() => {
const browser = utils.getBrowser();
return !browser.isIPhone && utils.is.function(utils.createElement('video').webkitSetPresentationMode);
})(),
pip: (() => !browser.isIPhone && is.function(createElement('video').webkitSetPresentationMode))(),
// Airplay support
// Safari only currently
airplay: utils.is.function(window.WebKitPlaybackTargetAvailabilityEvent),
airplay: is.function(window.WebKitPlaybackTargetAvailabilityEvent),
// Inline playback support
// https://webkit.org/blog/6784/new-video-policies-for-ios/
@ -64,83 +50,34 @@ const support = {
// Check for mime type support against a player instance
// Credits: http://diveintohtml5.info/everything.html
// Related: http://www.leanbackplayer.com/test/h5mt.html
mime(type) {
const { media } = this;
try {
// Bail if no checking function
if (!this.isHTML5 || !utils.is.function(media.canPlayType)) {
return false;
}
// Check directly if codecs specified
if (type.includes('codecs=')) {
return media.canPlayType(type).replace(/no/, '');
}
// Type specific checks
if (this.isVideo) {
switch (type) {
case 'video/webm':
return media.canPlayType('video/webm; codecs="vp8, vorbis"').replace(/no/, '');
case 'video/mp4':
return media.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace(/no/, '');
case 'video/ogg':
return media.canPlayType('video/ogg; codecs="theora"').replace(/no/, '');
default:
return false;
}
} else if (this.isAudio) {
switch (type) {
case 'audio/mpeg':
return media.canPlayType('audio/mpeg;').replace(/no/, '');
case 'audio/ogg':
return media.canPlayType('audio/ogg; codecs="vorbis"').replace(/no/, '');
case 'audio/wav':
return media.canPlayType('audio/wav; codecs="1"').replace(/no/, '');
default:
return false;
}
}
} catch (e) {
mime(inputType) {
const [mediaType] = inputType.split('/');
if (!this.isHTML5 || mediaType !== this.type) {
return false;
}
// If we got this far, we're stuffed
return false;
let type;
if (inputType && inputType.includes('codecs=')) {
// Use input directly
type = inputType;
} else if (inputType === 'audio/mpeg') {
// Skip codec
type = 'audio/mpeg;';
} else if (inputType in defaultCodecs) {
// Use codec
type = `${inputType}; codecs="${defaultCodecs[inputType]}"`;
}
try {
return Boolean(type && this.media.canPlayType(type).replace(/no/, ''));
} catch (err) {
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);
window.removeEventListener('test', null, options);
} catch (e) {
// Do nothing
}
return supported;
})(),
// <input type="range"> Sliders
rangeInput: (() => {
const range = document.createElement('input');
@ -153,7 +90,7 @@ const support = {
touch: 'ontouchstart' in document.documentElement,
// Detect transitions support
transitions: utils.transitionEndEvent !== false,
transitions: transitionEndEvent !== false,
// Reduced motion iOS & MacOS setting
// https://webkit.org/blog/7551/responsive-design-for-motion/

View File

@ -6,15 +6,16 @@ import captions from './captions';
import controls from './controls';
import i18n from './i18n';
import support from './support';
import utils from './utils';
// Sniff out the browser
const browser = utils.getBrowser();
import browser from './utils/browser';
import { getElement, toggleClass } from './utils/elements';
import { ready, triggerEvent } from './utils/events';
import is from './utils/is';
import loadImage from './utils/loadImage';
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);
toggleClass(this.elements.container, this.config.selectors.container.replace('.', ''), true);
toggleClass(this.elements.container, this.config.classNames.uiSupported, this.supported.ui);
},
// Toggle native HTML5 media controls
@ -44,7 +45,7 @@ const ui = {
}
// Inject custom controls if not present
if (!utils.is.element(this.elements.controls)) {
if (!is.element(this.elements.controls)) {
// Inject custom controls
controls.inject.call(this);
@ -85,31 +86,35 @@ const ui = {
ui.checkPlaying.call(this);
// Check for picture-in-picture support
utils.toggleClass(this.elements.container, this.config.classNames.pip.supported, support.pip && this.isHTML5 && this.isVideo);
toggleClass(
this.elements.container,
this.config.classNames.pip.supported,
support.pip && this.isHTML5 && this.isVideo,
);
// Check for airplay support
utils.toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);
toggleClass(this.elements.container, this.config.classNames.airplay.supported, support.airplay && this.isHTML5);
// Add iOS class
utils.toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos);
toggleClass(this.elements.container, this.config.classNames.isIos, browser.isIos);
// Add touch class
utils.toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch);
toggleClass(this.elements.container, this.config.classNames.isTouch, this.touch);
// Ready for API calls
this.ready = true;
// Ready event at end of execution stack
setTimeout(() => {
utils.dispatchEvent.call(this, this.media, 'ready');
triggerEvent.call(this, this.media, 'ready');
}, 0);
// Set the title
ui.setTitle.call(this);
// Assure the poster image is set, if the property was added before the element was created
if (this.poster && this.elements.poster && !this.elements.poster.style.backgroundImage) {
ui.setPoster.call(this, this.poster);
if (this.poster) {
ui.setPoster.call(this, this.poster, false).catch(() => {});
}
// Manually set the duration if user has overridden it.
@ -125,12 +130,12 @@ const ui = {
let label = i18n.get('play', this.config);
// 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)) {
if (is.string(this.config.title) && !is.empty(this.config.title)) {
label += `, ${this.config.title}`;
}
// If there's a play button, set label
if (utils.is.nodeList(this.elements.buttons.play)) {
if (is.nodeList(this.elements.buttons.play)) {
Array.from(this.elements.buttons.play).forEach(button => {
button.setAttribute('aria-label', label);
});
@ -139,14 +144,14 @@ const ui = {
// Set iframe title
// https://github.com/sampotts/plyr/issues/124
if (this.isEmbed) {
const iframe = utils.getElement.call(this, 'iframe');
const iframe = getElement.call(this, 'iframe');
if (!utils.is.element(iframe)) {
if (!is.element(iframe)) {
return;
}
// Default to media type
const title = !utils.is.empty(this.config.title) ? this.config.title : 'video';
const title = !is.empty(this.config.title) ? this.config.title : 'video';
const format = i18n.get('frameTitle', this.config);
iframe.setAttribute('title', format.replace('{title}', title));
@ -155,44 +160,58 @@ const ui = {
// Toggle poster
togglePoster(enable) {
utils.toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);
toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);
},
// Set the poster image (async)
setPoster(poster) {
// Set property regardless of validity
this.media.setAttribute('poster', poster);
// Bail if element is missing
if (!utils.is.element(this.elements.poster)) {
return Promise.reject();
// Used internally for the poster setter, with the passive option forced to false
setPoster(poster, passive = true) {
// Don't override if call is passive
if (passive && this.poster) {
return Promise.reject(new Error('Poster already set'));
}
// Load the image, and set poster if successful
const loadPromise = utils.loadImage(poster).then(() => {
this.elements.poster.style.backgroundImage = `url('${poster}')`;
Object.assign(this.elements.poster.style, {
backgroundImage: `url('${poster}')`,
// Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube)
backgroundSize: '',
});
ui.togglePoster.call(this, true);
return poster;
});
// Set property synchronously to respect the call order
this.media.setAttribute('poster', poster);
// Hide the element if the poster can't be loaded (otherwise it will just be a black element covering the video)
loadPromise.catch(() => ui.togglePoster.call(this, false));
// Return the promise so the caller can use it as well
return loadPromise;
// Wait until ui is ready
return (
ready
.call(this)
// Load image
.then(() => loadImage(poster))
.catch(err => {
// Hide poster on error unless it's been set by another call
if (poster === this.poster) {
ui.togglePoster.call(this, false);
}
// Rethrow
throw err;
})
.then(() => {
// Prevent race conditions
if (poster !== this.poster) {
throw new Error('setPoster cancelled by later call to setPoster');
}
})
.then(() => {
Object.assign(this.elements.poster.style, {
backgroundImage: `url('${poster}')`,
// Reset backgroundSize as well (since it can be set to "cover" for padded thumbnails for youtube)
backgroundSize: '',
});
ui.togglePoster.call(this, true);
return poster;
})
);
},
// Check playing state
checkPlaying(event) {
// Class hooks
utils.toggleClass(this.elements.container, this.config.classNames.playing, this.playing);
utils.toggleClass(this.elements.container, this.config.classNames.paused, this.paused);
utils.toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped);
toggleClass(this.elements.container, this.config.classNames.playing, this.playing);
toggleClass(this.elements.container, this.config.classNames.paused, this.paused);
toggleClass(this.elements.container, this.config.classNames.stopped, this.stopped);
// Set state
Array.from(this.elements.buttons.play).forEach(target => {
@ -200,7 +219,7 @@ const ui = {
});
// Only update controls on non timeupdate events
if (utils.is.event(event) && event.type === 'timeupdate') {
if (is.event(event) && event.type === 'timeupdate') {
return;
}
@ -210,10 +229,7 @@ const ui = {
// Check if media is loading
checkLoading(event) {
this.loading = [
'stalled',
'waiting',
].includes(event.type);
this.loading = ['stalled', 'waiting'].includes(event.type);
// Clear timer
clearTimeout(this.timers.loading);
@ -221,7 +237,7 @@ const ui = {
// Timer to prevent flicker when seeking
this.timers.loading = setTimeout(() => {
// Update progress bar loading class state
utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
// Update controls visibility
ui.toggleControls.call(this);

View File

@ -1,853 +0,0 @@
// ==========================================================================
// Plyr utils
// ==========================================================================
import loadjs from 'loadjs';
import Storage from './storage';
import support from './support';
import { providers } from './types';
const utils = {
// Check variable types
is: {
object(input) {
return utils.getConstructor(input) === Object;
},
number(input) {
return utils.getConstructor(input) === Number && !Number.isNaN(input);
},
string(input) {
return utils.getConstructor(input) === String;
},
boolean(input) {
return utils.getConstructor(input) === Boolean;
},
function(input) {
return utils.getConstructor(input) === Function;
},
array(input) {
return !utils.is.nullOrUndefined(input) && Array.isArray(input);
},
weakMap(input) {
return utils.is.instanceof(input, WeakMap);
},
nodeList(input) {
return utils.is.instanceof(input, NodeList);
},
element(input) {
return utils.is.instanceof(input, Element);
},
textNode(input) {
return utils.getConstructor(input) === Text;
},
event(input) {
return utils.is.instanceof(input, Event);
},
cue(input) {
return utils.is.instanceof(input, window.TextTrackCue) || utils.is.instanceof(input, window.VTTCue);
},
track(input) {
return utils.is.instanceof(input, TextTrack) || (!utils.is.nullOrUndefined(input) && utils.is.string(input.kind));
},
url(input) {
return !utils.is.nullOrUndefined(input) && /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input);
},
nullOrUndefined(input) {
return input === null || typeof input === 'undefined';
},
empty(input) {
return (
utils.is.nullOrUndefined(input) ||
((utils.is.string(input) || utils.is.array(input) || utils.is.nodeList(input)) && !input.length) ||
(utils.is.object(input) && !Object.keys(input).length)
);
},
instanceof(input, constructor) {
return Boolean(input && constructor && input instanceof constructor);
},
},
getConstructor(input) {
return !utils.is.nullOrUndefined(input) ? input.constructor : null;
},
// Unfortunately, due to mixed support, UA sniffing is required
getBrowser() {
return {
isIE: /* @cc_on!@ */ false || !!document.documentMode,
isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent),
isIPhone: /(iPhone|iPod)/gi.test(navigator.platform),
isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform),
};
},
// Fetch wrapper
// Using XHR to avoid issues with older browsers
fetch(url, responseType = 'text') {
return new Promise((resolve, reject) => {
try {
const request = new XMLHttpRequest();
// Check for CORS support
if (!('withCredentials' in request)) {
return;
}
request.addEventListener('load', () => {
if (responseType === 'text') {
try {
resolve(JSON.parse(request.responseText));
} catch (e) {
resolve(request.responseText);
}
} else {
resolve(request.response);
}
});
request.addEventListener('error', () => {
throw new Error(request.statusText);
});
request.open('GET', url, true);
// Set the required response type
request.responseType = responseType;
request.send();
} catch (e) {
reject(e);
}
});
},
// Load image avoiding xhr/fetch CORS issues
// Server status can't be obtained this way unfortunately, so this uses "naturalWidth" to determine if the image has loaded.
// By default it checks if it is at least 1px, but you can add a second argument to change this.
loadImage(src, minWidth = 1) {
return new Promise((resolve, reject) => {
const image = new Image();
const handler = () => {
delete image.onload;
delete image.onerror;
(image.naturalWidth >= minWidth ? resolve : reject)(image);
};
Object.assign(image, {onload: handler, onerror: handler, src});
});
},
// Load an external script
loadScript(url) {
return new Promise((resolve, reject) => {
loadjs(url, {
success: resolve,
error: reject,
});
});
},
// Load an external SVG sprite
loadSprite(url, id) {
if (!utils.is.string(url)) {
return;
}
const prefix = 'cache';
const hasId = utils.is.string(id);
let isCached = false;
const exists = () => document.getElementById(id) !== null;
const update = (container, data) => {
container.innerHTML = data;
// Check again incase of race condition
if (hasId && exists()) {
return;
}
// Inject the SVG to the body
document.body.insertAdjacentElement('afterbegin', container);
};
// Only load once if ID set
if (!hasId || !exists()) {
const useStorage = Storage.supported;
// Create container
const container = document.createElement('div');
utils.toggleHidden(container, true);
if (hasId) {
container.setAttribute('id', id);
}
// Check in cache
if (useStorage) {
const cached = window.localStorage.getItem(`${prefix}-${id}`);
isCached = cached !== null;
if (isCached) {
const data = JSON.parse(cached);
update(container, data.content);
}
}
// Get the sprite
utils
.fetch(url)
.then(result => {
if (utils.is.empty(result)) {
return;
}
if (useStorage) {
window.localStorage.setItem(
`${prefix}-${id}`,
JSON.stringify({
content: result,
}),
);
}
update(container, result);
})
.catch(() => {});
}
},
// Generate a random ID
generateId(prefix) {
return `${prefix}-${Math.floor(Math.random() * 10000)}`;
},
// Wrap an element
wrap(elements, wrapper) {
// Convert `elements` to an array, if necessary.
const targets = elements.length ? elements : [elements];
// Loops backwards to prevent having to clone the wrapper on the
// first element (see `child` below).
Array.from(targets)
.reverse()
.forEach((element, index) => {
const child = index > 0 ? wrapper.cloneNode(true) : wrapper;
// Cache the current parent and sibling.
const parent = element.parentNode;
const sibling = element.nextSibling;
// Wrap the element (is automatically removed from its current
// parent).
child.appendChild(element);
// If the element had a sibling, insert the wrapper before
// the sibling to maintain the HTML structure; otherwise, just
// append it to the parent.
if (sibling) {
parent.insertBefore(child, sibling);
} else {
parent.appendChild(child);
}
});
},
// Create a DocumentFragment
createElement(type, attributes, text) {
// Create a new <element>
const element = document.createElement(type);
// Set all passed attributes
if (utils.is.object(attributes)) {
utils.setAttributes(element, attributes);
}
// Add text node
if (utils.is.string(text)) {
element.innerText = text;
}
// Return built element
return element;
},
// Inaert an element after another
insertAfter(element, target) {
target.parentNode.insertBefore(element, target.nextSibling);
},
// Insert a DocumentFragment
insertElement(type, parent, attributes, text) {
// Inject the new <element>
parent.appendChild(utils.createElement(type, attributes, text));
},
// Remove element(s)
removeElement(element) {
if (utils.is.nodeList(element) || utils.is.array(element)) {
Array.from(element).forEach(utils.removeElement);
return;
}
if (!utils.is.element(element) || !utils.is.element(element.parentNode)) {
return;
}
element.parentNode.removeChild(element);
},
// Remove all child elements
emptyElement(element) {
let { length } = element.childNodes;
while (length > 0) {
element.removeChild(element.lastChild);
length -= 1;
}
},
// Replace element
replaceElement(newChild, oldChild) {
if (!utils.is.element(oldChild) || !utils.is.element(oldChild.parentNode) || !utils.is.element(newChild)) {
return null;
}
oldChild.parentNode.replaceChild(newChild, oldChild);
return newChild;
},
// Set attributes
setAttributes(element, attributes) {
if (!utils.is.element(element) || utils.is.empty(attributes)) {
return;
}
Object.entries(attributes).forEach(([
key,
value,
]) => {
element.setAttribute(key, value);
});
},
// 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 hidden
toggleHidden(element, hidden) {
if (!utils.is.element(element)) {
return;
}
let hide = hidden;
if (!utils.is.boolean(hide)) {
hide = !element.hasAttribute('hidden');
}
if (hide) {
element.setAttribute('hidden', '');
} else {
element.removeAttribute('hidden');
}
},
// Mirror Element.classList.toggle, with IE compatibility for "force" argument
toggleClass(element, className, force) {
if (utils.is.element(element)) {
let method = 'toggle';
if (typeof force !== 'undefined') {
method = force ? 'add' : 'remove';
}
element.classList[method](className);
return element.classList.contains(className);
}
return null;
},
// Has class name
hasClass(element, className) {
return utils.is.element(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);
},
// Get the focused element
getFocusElement() {
let focused = document.activeElement;
if (!focused || focused === document.body) {
focused = null;
} else {
focused = document.querySelector(':focus');
}
return focused;
},
// Trap focus inside container
trapFocus(element = null, toggle = false) {
if (!utils.is.element(element)) {
return;
}
const focusable = utils.getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
const first = focusable[0];
const last = focusable[focusable.length - 1];
const trap = event => {
// Bail if not tab key or not fullscreen
if (event.key !== 'Tab' || event.keyCode !== 9) {
return;
}
// Get the current focused element
const focused = utils.getFocusElement();
if (focused === last && !event.shiftKey) {
// Move focus to first element that can be tabbed if Shift isn't used
first.focus();
event.preventDefault();
} else if (focused === first && event.shiftKey) {
// Move focus to last element that can be tabbed if Shift is used
last.focus();
event.preventDefault();
}
};
if (toggle) {
utils.on(this.elements.container, 'keydown', trap, false);
} else {
utils.off(this.elements.container, 'keydown', trap, false);
}
},
// Toggle event listener
toggleListener(elements, event, callback, toggle = false, passive = true, capture = false) {
// Bail if no elemetns, event, or callback
if (utils.is.empty(elements) || utils.is.empty(event) || !utils.is.function(callback)) {
return;
}
// If a nodelist is passed, call itself on each node
if (utils.is.nodeList(elements) || utils.is.array(elements)) {
// Create listener for each node
Array.from(elements).forEach(element => {
if (element instanceof Node) {
utils.toggleListener.call(null, element, event, callback, toggle, passive, capture);
}
});
return;
}
// Allow multiple events
const events = event.split(' ');
// Build options
// Default to just the capture boolean for browsers with no passive listener support
let options = capture;
// If passive events listeners are supported
if (support.passiveListeners) {
options = {
// Whether the listener can be passive (i.e. default never prevented)
passive,
// Whether the listener is a capturing listener or not
capture,
};
}
// 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 = true, capture = false) {
utils.toggleListener(element, events, callback, true, passive, capture);
},
// Unbind event handler
off(element, events = '', callback, passive = true, capture = false) {
utils.toggleListener(element, events, callback, false, passive, capture);
},
// Trigger event
dispatchEvent(element, type = '', bubbles = false, detail = {}) {
// Bail if no element
if (!utils.is.element(element) || utils.is.empty(type)) {
return;
}
// Create and dispatch the event
const event = new CustomEvent(type, {
bubbles,
detail: Object.assign({}, detail, {
plyr: this,
}),
});
// Dispatch the event
element.dispatchEvent(event);
},
// Format string
format(input, ...args) {
if (utils.is.empty(input)) {
return input;
}
return input.toString().replace(/{(\d+)}/g, (match, i) => (utils.is.string(args[i]) ? args[i] : ''));
},
// Get percentage
getPercentage(current, max) {
if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {
return 0;
}
return (current / max * 100).toFixed(2);
},
// Time helpers
getHours(value) {
return parseInt((value / 60 / 60) % 60, 10);
},
getMinutes(value) {
return parseInt((value / 60) % 60, 10);
},
getSeconds(value) {
return parseInt(value % 60, 10);
},
// Format time to UI friendly string
formatTime(time = 0, displayHours = false, inverted = false) {
// Bail if the value isn't a number
if (!utils.is.number(time)) {
return utils.formatTime(null, displayHours, inverted);
}
// Format time component to add leading zero
const format = value => `0${value}`.slice(-2);
// Breakdown to hours, mins, secs
let hours = utils.getHours(time);
const mins = utils.getMinutes(time);
const secs = utils.getSeconds(time);
// Do we need to display hours?
if (displayHours || hours > 0) {
hours = `${hours}:`;
} else {
hours = '';
}
// Render
return `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;
},
// Replace all occurances of a string in a string
replaceAll(input = '', find = '', replace = '') {
return input.replace(new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'), replace.toString());
},
// Convert to title case
toTitleCase(input = '') {
return input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.substr(1).toLowerCase());
},
// Convert string to pascalCase
toPascalCase(input = '') {
let string = input.toString();
// Convert kebab case
string = utils.replaceAll(string, '-', ' ');
// Convert snake case
string = utils.replaceAll(string, '_', ' ');
// Convert to title case
string = utils.toTitleCase(string);
// Convert to pascal case
return utils.replaceAll(string, ' ', '');
},
// Convert string to pascalCase
toCamelCase(input = '') {
let string = input.toString();
// Convert to pascal case
string = utils.toPascalCase(string);
// Convert first character to lowercase
return string.charAt(0).toLowerCase() + string.slice(1);
},
// Deep extend destination object with N more objects
extend(target = {}, ...sources) {
if (!sources.length) {
return target;
}
const source = sources.shift();
if (!utils.is.object(source)) {
return target;
}
Object.keys(source).forEach(key => {
if (utils.is.object(source[key])) {
if (!Object.keys(target).includes(key)) {
Object.assign(target, { [key]: {} });
}
utils.extend(target[key], source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
});
return utils.extend(target, ...sources);
},
// Remove duplicates in an array
dedupe(array) {
if (!utils.is.array(array)) {
return array;
}
return array.filter((item, index) => array.indexOf(item) === index);
},
// Clone nested objects
cloneDeep(object) {
return JSON.parse(JSON.stringify(object));
},
// Get a nested value in an object
getDeep(object, path) {
return path.split('.').reduce((obj, key) => obj && obj[key], object);
},
// Get the closest value in an array
closest(array, value) {
if (!utils.is.array(array) || !array.length) {
return null;
}
return array.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
},
// Get the provider for a given URL
getProviderByUrl(url) {
// YouTube
if (/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.?be)\/.+$/.test(url)) {
return providers.youtube;
}
// Vimeo
if (/^https?:\/\/player.vimeo.com\/video\/\d{0,9}(?=\b|\/)/.test(url)) {
return providers.vimeo;
}
return null;
},
// Parse YouTube ID from URL
parseYouTubeId(url) {
if (utils.is.empty(url)) {
return null;
}
const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
return url.match(regex) ? RegExp.$2 : url;
},
// Parse Vimeo ID from URL
parseVimeoId(url) {
if (utils.is.empty(url)) {
return null;
}
if (utils.is.number(Number(url))) {
return url;
}
const regex = /^.*(vimeo.com\/|video\/)(\d+).*/;
return url.match(regex) ? RegExp.$2 : url;
},
// Convert a URL to a location object
parseUrl(url) {
const parser = document.createElement('a');
parser.href = url;
return parser;
},
// Get URL query parameters
getUrlParams(input) {
let search = input;
// Parse URL if needed
if (input.startsWith('http://') || input.startsWith('https://')) {
({ search } = utils.parseUrl(input));
}
if (utils.is.empty(search)) {
return null;
}
const hashes = search.slice(search.indexOf('?') + 1).split('&');
return hashes.reduce((params, hash) => {
const [
key,
val,
] = hash.split('=');
return Object.assign(params, { [key]: decodeURIComponent(val) });
}, {});
},
// Convert object to URL parameters
buildUrlParams(input) {
if (!utils.is.object(input)) {
return '';
}
return Object.keys(input)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(input[key])}`)
.join('&');
},
// Remove HTML from a string
stripHTML(source) {
const fragment = document.createDocumentFragment();
const element = document.createElement('div');
fragment.appendChild(element);
element.innerHTML = source;
return fragment.firstChild.innerText;
},
// Like outerHTML, but also works for DocumentFragment
getHTML(element) {
const wrapper = document.createElement('div');
wrapper.appendChild(element);
return wrapper.innerHTML;
},
// Get aspect ratio for dimensions
getAspectRatio(width, height) {
const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h));
const ratio = getRatio(width, height);
return `${width / ratio}:${height / ratio}`;
},
// Get the transition end event
get transitionEndEvent() {
const element = document.createElement('span');
const events = {
WebkitTransition: 'webkitTransitionEnd',
MozTransition: 'transitionend',
OTransition: 'oTransitionEnd otransitionend',
transition: 'transitionend',
};
const type = Object.keys(events).find(event => element.style[event] !== undefined);
return utils.is.string(type) ? events[type] : false;
},
// Force repaint of element
repaint(element) {
setTimeout(() => {
utils.toggleHidden(element, true);
element.offsetHeight; // eslint-disable-line
utils.toggleHidden(element, false);
}, 0);
},
};
export default utils;

30
src/js/utils/animation.js Normal file
View File

@ -0,0 +1,30 @@
// ==========================================================================
// Animation utils
// ==========================================================================
import { toggleHidden } from './elements';
import is from './is';
export const transitionEndEvent = (() => {
const element = document.createElement('span');
const events = {
WebkitTransition: 'webkitTransitionEnd',
MozTransition: 'transitionend',
OTransition: 'oTransitionEnd otransitionend',
transition: 'transitionend',
};
const type = Object.keys(events).find(event => element.style[event] !== undefined);
return is.string(type) ? events[type] : false;
})();
// Force repaint of element
export function repaint(element) {
setTimeout(() => {
toggleHidden(element, true);
element.offsetHeight; // eslint-disable-line
toggleHidden(element, false);
}, 0);
}

23
src/js/utils/arrays.js Normal file
View File

@ -0,0 +1,23 @@
// ==========================================================================
// Array utils
// ==========================================================================
import is from './is';
// Remove duplicates in an array
export function dedupe(array) {
if (!is.array(array)) {
return array;
}
return array.filter((item, index) => array.indexOf(item) === index);
}
// Get the closest value in an array
export function closest(array, value) {
if (!is.array(array) || !array.length) {
return null;
}
return array.reduce((prev, curr) => (Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev));
}

13
src/js/utils/browser.js Normal file
View File

@ -0,0 +1,13 @@
// ==========================================================================
// Browser sniffing
// Unfortunately, due to mixed support, UA sniffing is required
// ==========================================================================
const browser = {
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),
};
export default browser;

285
src/js/utils/elements.js Normal file
View File

@ -0,0 +1,285 @@
// ==========================================================================
// Element utils
// ==========================================================================
import { toggleListener } from './events';
import is from './is';
// Wrap an element
export function 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);
}
});
}
// Set attributes
export function setAttributes(element, attributes) {
if (!is.element(element) || is.empty(attributes)) {
return;
}
// Assume null and undefined attributes should be left out,
// Setting them would otherwise convert them to "null" and "undefined"
Object.entries(attributes)
.filter(([, value]) => !is.nullOrUndefined(value))
.forEach(([key, value]) => element.setAttribute(key, value));
}
// Create a DocumentFragment
export function createElement(type, attributes, text) {
// Create a new <element>
const element = document.createElement(type);
// Set all passed attributes
if (is.object(attributes)) {
setAttributes(element, attributes);
}
// Add text node
if (is.string(text)) {
element.innerText = text;
}
// Return built element
return element;
}
// Inaert an element after another
export function insertAfter(element, target) {
target.parentNode.insertBefore(element, target.nextSibling);
}
// Insert a DocumentFragment
export function insertElement(type, parent, attributes, text) {
// Inject the new <element>
parent.appendChild(createElement(type, attributes, text));
}
// Remove element(s)
export function removeElement(element) {
if (is.nodeList(element) || is.array(element)) {
Array.from(element).forEach(removeElement);
return;
}
if (!is.element(element) || !is.element(element.parentNode)) {
return;
}
element.parentNode.removeChild(element);
}
// Remove all child elements
export function emptyElement(element) {
let { length } = element.childNodes;
while (length > 0) {
element.removeChild(element.lastChild);
length -= 1;
}
}
// Replace element
export function replaceElement(newChild, oldChild) {
if (!is.element(oldChild) || !is.element(oldChild.parentNode) || !is.element(newChild)) {
return null;
}
oldChild.parentNode.replaceChild(newChild, oldChild);
return newChild;
}
// Get an attribute object from a string selector
export function getAttributesFromSelector(sel, existingAttributes) {
// For example:
// '.test' to { class: 'test' }
// '#test' to { id: 'test' }
// '[data-test="test"]' to { 'data-test': 'test' }
if (!is.string(sel) || 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 (is.object(existing) && 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 hidden
export function toggleHidden(element, hidden) {
if (!is.element(element)) {
return;
}
let hide = hidden;
if (!is.boolean(hide)) {
hide = !element.hasAttribute('hidden');
}
if (hide) {
element.setAttribute('hidden', '');
} else {
element.removeAttribute('hidden');
}
}
// Mirror Element.classList.toggle, with IE compatibility for "force" argument
export function toggleClass(element, className, force) {
if (is.element(element)) {
let method = 'toggle';
if (typeof force !== 'undefined') {
method = force ? 'add' : 'remove';
}
element.classList[method](className);
return element.classList.contains(className);
}
return null;
}
// Has class name
export function hasClass(element, className) {
return is.element(element) && element.classList.contains(className);
}
// Element matches selector
export function 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
export function getElements(selector) {
return this.elements.container.querySelectorAll(selector);
}
// Find a single element
export function getElement(selector) {
return this.elements.container.querySelector(selector);
}
// Get the focused element
export function getFocusElement() {
let focused = document.activeElement;
if (!focused || focused === document.body) {
focused = null;
} else {
focused = document.querySelector(':focus');
}
return focused;
}
// Trap focus inside container
export function trapFocus(element = null, toggle = false) {
if (!is.element(element)) {
return;
}
const focusable = getElements.call(this, 'button:not(:disabled), input:not(:disabled), [tabindex]');
const first = focusable[0];
const last = focusable[focusable.length - 1];
const trap = event => {
// Bail if not tab key or not fullscreen
if (event.key !== 'Tab' || event.keyCode !== 9) {
return;
}
// Get the current focused element
const focused = getFocusElement();
if (focused === last && !event.shiftKey) {
// Move focus to first element that can be tabbed if Shift isn't used
first.focus();
event.preventDefault();
} else if (focused === first && event.shiftKey) {
// Move focus to last element that can be tabbed if Shift is used
last.focus();
event.preventDefault();
}
};
toggleListener.call(this, this.elements.container, 'keydown', trap, toggle, false);
}

120
src/js/utils/events.js Normal file
View File

@ -0,0 +1,120 @@
// ==========================================================================
// Event utils
// ==========================================================================
import is from './is';
// Check for passive event listener support
// https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
// https://www.youtube.com/watch?v=NPM6172J22g
const supportsPassiveListeners = (() => {
// 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);
window.removeEventListener('test', null, options);
} catch (e) {
// Do nothing
}
return supported;
})();
// Toggle event listener
export function toggleListener(element, event, callback, toggle = false, passive = true, capture = false) {
// Bail if no element, event, or callback
if (!element || !('addEventListener' in element) || is.empty(event) || !is.function(callback)) {
return;
}
// Allow multiple events
const events = event.split(' ');
// Build options
// Default to just the capture boolean for browsers with no passive listener support
let options = capture;
// If passive events listeners are supported
if (supportsPassiveListeners) {
options = {
// Whether the listener can be passive (i.e. default never prevented)
passive,
// Whether the listener is a capturing listener or not
capture,
};
}
// If a single node is passed, bind the event listener
events.forEach(type => {
if (this && this.eventListeners && toggle) {
// Cache event listener
this.eventListeners.push({ element, type, callback, options });
}
element[toggle ? 'addEventListener' : 'removeEventListener'](type, callback, options);
});
}
// Bind event handler
export function on(element, events = '', callback, passive = true, capture = false) {
toggleListener.call(this, element, events, callback, true, passive, capture);
}
// Unbind event handler
export function off(element, events = '', callback, passive = true, capture = false) {
toggleListener.call(this, element, events, callback, false, passive, capture);
}
// Bind once-only event handler
export function once(element, events = '', callback, passive = true, capture = false) {
function onceCallback(...args) {
off(element, events, onceCallback, passive, capture);
callback.apply(this, args);
}
toggleListener.call(this, element, events, onceCallback, true, passive, capture);
}
// Trigger event
export function triggerEvent(element, type = '', bubbles = false, detail = {}) {
// Bail if no element
if (!is.element(element) || is.empty(type)) {
return;
}
// Create and dispatch the event
const event = new CustomEvent(type, {
bubbles,
detail: Object.assign({}, detail, {
plyr: this,
}),
});
// Dispatch the event
element.dispatchEvent(event);
}
// Unbind all cached event listeners
export function unbindListeners() {
if (this && this.eventListeners) {
this.eventListeners.forEach(item => {
const { element, type, callback, options } = item;
element.removeEventListener(type, callback, options);
});
this.eventListeners = [];
}
}
// Run method when / if player is ready
export function ready() {
return new Promise(
resolve => (this.ready ? setTimeout(resolve, 0) : on.call(this, this.elements.container, 'ready', resolve)),
).then(() => {});
}

42
src/js/utils/fetch.js Normal file
View File

@ -0,0 +1,42 @@
// ==========================================================================
// Fetch wrapper
// Using XHR to avoid issues with older browsers
// ==========================================================================
export default function fetch(url, responseType = 'text') {
return new Promise((resolve, reject) => {
try {
const request = new XMLHttpRequest();
// Check for CORS support
if (!('withCredentials' in request)) {
return;
}
request.addEventListener('load', () => {
if (responseType === 'text') {
try {
resolve(JSON.parse(request.responseText));
} catch (e) {
resolve(request.responseText);
}
} else {
resolve(request.response);
}
});
request.addEventListener('error', () => {
throw new Error(request.statusText);
});
request.open('GET', url, true);
// Set the required response type
request.responseType = responseType;
request.send();
} catch (e) {
reject(e);
}
});
}

67
src/js/utils/is.js Normal file
View File

@ -0,0 +1,67 @@
// ==========================================================================
// Type checking utils
// ==========================================================================
const getConstructor = input => (input !== null && typeof input !== 'undefined' ? input.constructor : null);
const instanceOf = (input, constructor) => Boolean(input && constructor && input instanceof constructor);
const is = {
object(input) {
return getConstructor(input) === Object;
},
number(input) {
return getConstructor(input) === Number && !Number.isNaN(input);
},
string(input) {
return getConstructor(input) === String;
},
boolean(input) {
return getConstructor(input) === Boolean;
},
function(input) {
return getConstructor(input) === Function;
},
array(input) {
return !is.nullOrUndefined(input) && Array.isArray(input);
},
weakMap(input) {
return instanceOf(input, WeakMap);
},
nodeList(input) {
return instanceOf(input, NodeList);
},
element(input) {
return instanceOf(input, Element);
},
textNode(input) {
return getConstructor(input) === Text;
},
event(input) {
return instanceOf(input, Event);
},
cue(input) {
return instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue);
},
track(input) {
return instanceOf(input, TextTrack) || (!is.nullOrUndefined(input) && is.string(input.kind));
},
url(input) {
return (
!is.nullOrUndefined(input) &&
/(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input)
);
},
nullOrUndefined(input) {
return input === null || typeof input === 'undefined';
},
empty(input) {
return (
is.nullOrUndefined(input) ||
((is.string(input) || is.array(input) || is.nodeList(input)) && !input.length) ||
(is.object(input) && !Object.keys(input).length)
);
},
};
export default is;

19
src/js/utils/loadImage.js Normal file
View File

@ -0,0 +1,19 @@
// ==========================================================================
// Load image avoiding xhr/fetch CORS issues
// Server status can't be obtained this way unfortunately, so this uses "naturalWidth" to determine if the image has loaded
// By default it checks if it is at least 1px, but you can add a second argument to change this
// ==========================================================================
export default function loadImage(src, minWidth = 1) {
return new Promise((resolve, reject) => {
const image = new Image();
const handler = () => {
delete image.onload;
delete image.onerror;
(image.naturalWidth >= minWidth ? resolve : reject)(image);
};
Object.assign(image, { onload: handler, onerror: handler, src });
});
}

View File

@ -0,0 +1,14 @@
// ==========================================================================
// Load an external script
// ==========================================================================
import loadjs from 'loadjs';
export default function loadScript(url) {
return new Promise((resolve, reject) => {
loadjs(url, {
success: resolve,
error: reject,
});
});
}

View File

@ -0,0 +1,75 @@
// ==========================================================================
// Sprite loader
// ==========================================================================
import Storage from './../storage';
import is from './is';
// Load an external SVG sprite
export default function loadSprite(url, id) {
if (!is.string(url)) {
return;
}
const prefix = 'cache';
const hasId = is.string(id);
let isCached = false;
const exists = () => document.getElementById(id) !== null;
const update = (container, data) => {
container.innerHTML = data;
// Check again incase of race condition
if (hasId && exists()) {
return;
}
// Inject the SVG to the body
document.body.insertAdjacentElement('afterbegin', container);
};
// Only load once if ID set
if (!hasId || !exists()) {
const useStorage = Storage.supported;
// Create container
const container = document.createElement('div');
container.setAttribute('hidden', '');
if (hasId) {
container.setAttribute('id', id);
}
// Check in cache
if (useStorage) {
const cached = window.localStorage.getItem(`${prefix}-${id}`);
isCached = cached !== null;
if (isCached) {
const data = JSON.parse(cached);
update(container, data.content);
}
}
// Get the sprite
fetch(url)
.then(result => {
if (is.empty(result)) {
return;
}
if (useStorage) {
window.localStorage.setItem(
`${prefix}-${id}`,
JSON.stringify({
content: result,
}),
);
}
update(container, result);
})
.catch(() => {});
}
}

42
src/js/utils/objects.js Normal file
View File

@ -0,0 +1,42 @@
// ==========================================================================
// Object utils
// ==========================================================================
import is from './is';
// Clone nested objects
export function cloneDeep(object) {
return JSON.parse(JSON.stringify(object));
}
// Get a nested value in an object
export function getDeep(object, path) {
return path.split('.').reduce((obj, key) => obj && obj[key], object);
}
// Deep extend destination object with N more objects
export function extend(target = {}, ...sources) {
if (!sources.length) {
return target;
}
const source = sources.shift();
if (!is.object(source)) {
return target;
}
Object.keys(source).forEach(key => {
if (is.object(source[key])) {
if (!Object.keys(target).includes(key)) {
Object.assign(target, { [key]: {} });
}
extend(target[key], source[key]);
} else {
Object.assign(target, { [key]: source[key] });
}
});
return extend(target, ...sources);
}

85
src/js/utils/strings.js Normal file
View File

@ -0,0 +1,85 @@
// ==========================================================================
// String utils
// ==========================================================================
import is from './is';
// Generate a random ID
export function generateId(prefix) {
return `${prefix}-${Math.floor(Math.random() * 10000)}`;
}
// Format string
export function format(input, ...args) {
if (is.empty(input)) {
return input;
}
return input.toString().replace(/{(\d+)}/g, (match, i) => args[i].toString());
}
// Get percentage
export function getPercentage(current, max) {
if (current === 0 || max === 0 || Number.isNaN(current) || Number.isNaN(max)) {
return 0;
}
return (current / max * 100).toFixed(2);
}
// Replace all occurances of a string in a string
export function replaceAll(input = '', find = '', replace = '') {
return input.replace(
new RegExp(find.toString().replace(/([.*+?^=!:${}()|[\]/\\])/g, '\\$1'), 'g'),
replace.toString(),
);
}
// Convert to title case
export function toTitleCase(input = '') {
return input.toString().replace(/\w\S*/g, text => text.charAt(0).toUpperCase() + text.substr(1).toLowerCase());
}
// Convert string to pascalCase
export function toPascalCase(input = '') {
let string = input.toString();
// Convert kebab case
string = replaceAll(string, '-', ' ');
// Convert snake case
string = replaceAll(string, '_', ' ');
// Convert to title case
string = toTitleCase(string);
// Convert to pascal case
return replaceAll(string, ' ', '');
}
// Convert string to pascalCase
export function toCamelCase(input = '') {
let string = input.toString();
// Convert to pascal case
string = toPascalCase(string);
// Convert first character to lowercase
return string.charAt(0).toLowerCase() + string.slice(1);
}
// Remove HTML from a string
export function stripHTML(source) {
const fragment = document.createDocumentFragment();
const element = document.createElement('div');
fragment.appendChild(element);
element.innerHTML = source;
return fragment.firstChild.innerText;
}
// Like outerHTML, but also works for DocumentFragment
export function getHTML(element) {
const wrapper = document.createElement('div');
wrapper.appendChild(element);
return wrapper.innerHTML;
}

36
src/js/utils/time.js Normal file
View File

@ -0,0 +1,36 @@
// ==========================================================================
// Time utils
// ==========================================================================
import is from './is';
// Time helpers
export const getHours = value => parseInt((value / 60 / 60) % 60, 10);
export const getMinutes = value => parseInt((value / 60) % 60, 10);
export const getSeconds = value => parseInt(value % 60, 10);
// Format time to UI friendly string
export function formatTime(time = 0, displayHours = false, inverted = false) {
// Bail if the value isn't a number
if (!is.number(time)) {
return formatTime(null, displayHours, inverted);
}
// Format time component to add leading zero
const format = value => `0${value}`.slice(-2);
// Breakdown to hours, mins, secs
let hours = getHours(time);
const mins = getMinutes(time);
const secs = getSeconds(time);
// Do we need to display hours?
if (displayHours || hours > 0) {
hours = `${hours}:`;
} else {
hours = '';
}
// Render
return `${inverted && time > 0 ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;
}

39
src/js/utils/urls.js Normal file
View File

@ -0,0 +1,39 @@
// ==========================================================================
// URL utils
// ==========================================================================
import is from './is';
/**
* Parse a string to a URL object
* @param {string} input - the URL to be parsed
* @param {boolean} safe - failsafe parsing
*/
export function parseUrl(input, safe = true) {
let url = input;
if (safe) {
const parser = document.createElement('a');
parser.href = url;
url = parser.href;
}
try {
return new URL(url);
} catch (e) {
return null;
}
}
// Convert object to URLSearchParams
export function buildUrlParams(input) {
const params = new URLSearchParams();
if (is.object(input)) {
Object.entries(input).forEach(([key, value]) => {
params.set(key, value);
});
}
return params;
}