aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/js/captions.js430
-rw-r--r--src/js/config/defaults.js (renamed from src/js/defaults.js)50
-rw-r--r--src/js/config/types.js (renamed from src/js/types.js)18
-rw-r--r--src/js/controls.js673
-rw-r--r--src/js/fullscreen.js63
-rw-r--r--src/js/html5.js108
-rw-r--r--src/js/i18n.js19
-rw-r--r--src/js/listeners.js396
-rw-r--r--src/js/media.js33
-rw-r--r--src/js/plugins/ads.js46
-rw-r--r--src/js/plugins/vimeo.js197
-rw-r--r--src/js/plugins/youtube.js334
-rw-r--r--src/js/plyr.js504
-rw-r--r--src/js/plyr.polyfilled.js3
-rw-r--r--src/js/source.js65
-rw-r--r--src/js/storage.js15
-rw-r--r--src/js/support.js137
-rw-r--r--src/js/ui.js171
-rw-r--r--src/js/utils.js847
-rw-r--r--src/js/utils/animation.js30
-rw-r--r--src/js/utils/arrays.js23
-rw-r--r--src/js/utils/browser.js13
-rw-r--r--src/js/utils/elements.js285
-rw-r--r--src/js/utils/events.js120
-rw-r--r--src/js/utils/fetch.js42
-rw-r--r--src/js/utils/is.js63
-rw-r--r--src/js/utils/loadImage.js19
-rw-r--r--src/js/utils/loadScript.js14
-rw-r--r--src/js/utils/loadSprite.js76
-rw-r--r--src/js/utils/objects.js42
-rw-r--r--src/js/utils/strings.js85
-rw-r--r--src/js/utils/time.js36
-rw-r--r--src/js/utils/urls.js39
-rw-r--r--src/sass/components/captions.scss2
-rw-r--r--src/sass/components/control.scss8
-rw-r--r--src/sass/components/poster.scss2
-rw-r--r--src/sass/components/progress.scss30
-rw-r--r--src/sass/plyr.scss5
-rw-r--r--src/sass/states/error.scss25
39 files changed, 2522 insertions, 2546 deletions
diff --git a/src/js/captions.js b/src/js/captions.js
index df717351..9dca5505 100644
--- a/src/js/captions.js
+++ b/src/js/captions.js
@@ -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
@@ -16,32 +30,14 @@ const captions = {
return;
}
- // Set default language if not set
- const stored = this.storage.get('language');
-
- if (!utils.is.empty(stored)) {
- this.captions.language = stored;
- }
-
- if (utils.is.empty(this.captions.language)) {
- this.captions.language = this.config.captions.language.toLowerCase();
- }
-
- // Set captions enabled state if not set
- if (!utils.is.boolean(this.captions.active)) {
- const active = this.storage.get('captions');
-
- if (utils.is.boolean(active)) {
- this.captions.active = active;
- } else {
- this.captions.active = this.config.captions.active;
- }
- }
-
// Only Vimeo and HTML5 video supported at this point
if (!this.isVideo || this.isYouTube || (this.isHTML5 && !support.textTracks)) {
// Clear menu and hide
- if (utils.is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
+ if (
+ is.array(this.config.controls) &&
+ this.config.controls.includes('settings') &&
+ this.config.settings.includes('captions')
+ ) {
controls.setCaptionsMenu.call(this);
}
@@ -49,26 +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);
}
- // Set the class hook
- utils.toggleClass(this.elements.container, this.config.classNames.captions.enabled, !utils.is.empty(captions.getTracks.call(this)));
-
- // Get tracks
- const tracks = captions.getTracks.call(this);
-
- // If no caption file exists, hide container for caption text
- if (utils.is.empty(tracks)) {
- return;
- }
-
- // Get browser info
- const browser = utils.getBrowser();
-
// Fix IE captions if CORS is used
// Fetch captions and inject as blobs instead (data URIs not supported!)
if (browser.isIE && window.URL) {
@@ -76,116 +58,276 @@ const captions = {
Array.from(elements).forEach(track => {
const src = track.getAttribute('src');
- const href = utils.parseUrl(src);
-
- if (href.hostname !== window.location.href.hostname && [
- 'http:',
- 'https:',
- ].includes(href.protocol)) {
- utils
- .fetch(src, 'blob')
+ const url = parseUrl(src);
+
+ 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);
});
}
});
}
- // Set language
- captions.setLanguage.call(this);
+ // 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
- // Enable UI
- captions.show.call(this);
+ const languages = dedupe(
+ Array.from(navigator.languages || navigator.userLanguage).map(language => language.split('-')[0]),
+ );
- // Set available languages in list
- if (utils.is.array(this.config.controls) && this.config.controls.includes('settings') && this.config.settings.includes('captions')) {
- controls.setCaptionsMenu.call(this);
+ 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);
+ }
+
+ 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';
+ 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);
},
- // Set the captions language
- setLanguage() {
- // Setup HTML5 track rendering
+ // Update available language options in settings based on tracks
+ update() {
+ const tracks = captions.getTracks.call(this, true);
+ // Get the wanted language
+ 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) {
- captions.getTracks.call(this).forEach(track => {
- // Show track
- utils.on(track, 'cuechange', event => captions.setCue.call(this, event));
+ 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
- // eslint-disable-next-line
track.mode = 'hidden';
+
+ // Add event listener for cue changes
+ on.call(this, track, 'cuechange', () => captions.updateCues.call(this));
});
+ }
- // Get current track
- const currentTrack = captions.getCurrentTrack.call(this);
+ // 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);
+ }
- // Check if suported kind
- if (utils.is.track(currentTrack)) {
- // If we change the active track while a cue is already displayed we need to update it
- if (Array.from(currentTrack.activeCues || []).length) {
- captions.setCue.call(this, currentTrack);
- }
- }
- } else if (this.isVimeo && this.captions.active) {
- this.embed.enableTextTrack(this.language);
+ // Enable or disable captions based on track length
+ 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')) {
+ controls.setCaptionsMenu.call(this);
}
},
- // Get the tracks
- getTracks() {
- // Return empty array at least
- if (utils.is.nullOrUndefined(this.media)) {
- return [];
+ // 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;
}
- // Only get accepted kinds
- return Array.from(this.media.textTracks || []).filter(track => [
- 'captions',
- 'subtitles',
- ].includes(track.kind));
+ 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 button if it's enabled
+ if (this.elements.buttons.captions) {
+ 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');
+ }
},
- // Get the current track for the current language
- getCurrentTrack() {
+ // 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);
- if (!tracks.length) {
- return null;
+ // Disable captions if setting to -1
+ if (index === -1) {
+ captions.toggle.call(this, false, passive);
+ return;
}
- // Get track based on current language
- let track = tracks.find(track => track.language.toLowerCase() === this.language);
+ if (!is.number(index)) {
+ this.debug.warn('Invalid caption argument', index);
+ return;
+ }
- // Get the <track> with default attribute
- if (!track) {
- track = utils.getElement.call(this, 'track[default]');
+ if (!(index in tracks)) {
+ this.debug.warn('Track not found', index);
+ return;
}
- // Get the first track
- if (!track) {
- [track] = tracks;
+ if (this.captions.currentTrack !== index) {
+ this.captions.currentTrack = index;
+ const track = tracks[index];
+ const { language } = track || {};
+
+ // Store reference to node for invalidation on remove
+ this.captions.currentTrackNode = track;
+
+ // 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
+ if (this.isVimeo) {
+ this.embed.enableTextTrack(language);
+ }
+
+ // Trigger event
+ 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);
+ }
+ },
+
+ // 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
+ const language = input.toLowerCase();
+ this.captions.language = language;
+
+ // Set currentTrack
+ const tracks = captions.getTracks.call(this);
+ const track = captions.findTrack.call(this, [language]);
+ captions.set.call(this, tracks.indexOf(track), passive);
+ },
- return track;
+ // Get current valid caption tracks
+ // If update is false it will also ignore tracks without metadata
+ // This is used to "freeze" the language options when captions.update is false
+ getTracks(update = false) {
+ // Handle media or textTracks missing or null
+ const tracks = Array.from((this.media || {}).textTracks || []);
+ // For HTML5, use cache instead of current tracks when it exists (if captions.update is false)
+ // 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));
+ },
+
+ // 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));
+ 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();
}
@@ -195,74 +337,48 @@ const captions = {
return i18n.get('disabled', this.config);
},
- // Display active caption if it contains text
- setCue(input) {
- // Get the track from the event if needed
- const track = utils.is.event(input) ? input.target : input;
- const { activeCues } = track;
- const active = activeCues.length && activeCues[0];
- const currentTrack = captions.getCurrentTrack.call(this);
-
- // Only display current track
- if (track !== currentTrack) {
+ // Update captions using current track's active cues
+ // Also optional array argument in case there isn't any track (ex: vimeo)
+ updateCues(input) {
+ // Requires UI
+ if (!this.supported.ui) {
return;
}
- // Display a cue, if there is one
- if (utils.is.cue(active)) {
- captions.setText.call(this, active.getCueAsHTML());
- } else {
- captions.setText.call(this, null);
+ if (!is.element(this.elements.captions)) {
+ this.debug.warn('No captions element to render to');
+ return;
}
- utils.dispatchEvent.call(this, this.media, 'cuechange');
- },
-
- // Set the current caption
- setText(input) {
- // Requires UI
- if (!this.supported.ui) {
+ // Only accept array or empty input
+ if (!is.nullOrUndefined(input) && !Array.isArray(input)) {
+ this.debug.warn('updateCues: Invalid input', input);
return;
}
- if (utils.is.element(this.elements.captions)) {
- const content = utils.createElement('span');
-
- // Empty the container
- utils.emptyElement(this.elements.captions);
-
- // Default to empty
- const caption = !utils.is.nullOrUndefined(input) ? input : '';
-
- // Set the span content
- if (utils.is.string(caption)) {
- content.innerText = caption.trim();
- } else {
- content.appendChild(caption);
- }
+ let cues = input;
- // Set new caption text
- this.elements.captions.appendChild(content);
- } else {
- this.debug.warn('No captions element to render to');
+ // Get cues from track
+ if (!cues) {
+ const track = captions.getCurrentTrack.call(this);
+ cues = Array.from((track || {}).activeCues || [])
+ .map(cue => cue.getCueAsHTML())
+ .map(getHTML);
}
- },
- // Display captions container and button (for initialization)
- show() {
- // Try to load the value from storage
- let active = this.storage.get('captions');
+ // Set new caption text
+ const content = cues.map(cueText => cueText.trim()).join('\n');
+ const changed = content !== this.elements.captions.innerHTML;
- // Otherwise fall back to the default config
- if (!utils.is.boolean(active)) {
- ({ active } = this.config.captions);
- } else {
- this.captions.active = active;
- }
+ if (changed) {
+ // Empty the container and create a new child element
+ emptyElement(this.elements.captions);
+ const caption = createElement('span', getAttributesFromSelector(this.config.selectors.caption));
+ caption.innerHTML = content;
+ this.elements.captions.appendChild(caption);
- if (active) {
- utils.toggleClass(this.elements.container, this.config.classNames.captions.active, true);
- utils.toggleState(this.elements.buttons.captions, true);
+ // Trigger event
+ triggerEvent.call(this, this.media, 'cuechange');
}
},
};
diff --git a/src/js/defaults.js b/src/js/config/defaults.js
index f160b1aa..1e90a4f0 100644
--- a/src/js/defaults.js
+++ b/src/js/config/defaults.js
@@ -18,6 +18,10 @@ const defaults = {
// Only allow one media playing at once (vimeo only)
autopause: true,
+ // Allow inline playback on iOS (this effects YouTube/Vimeo - HTML5 requires the attribute present)
+ // TODO: Remove iosNative fullscreen option in favour of this (logic needs work)
+ playsinline: true,
+
// Default time to skip when rewind/fast forward
seekTime: 10,
@@ -56,7 +60,7 @@ const defaults = {
// Sprite (for icons)
loadSprite: true,
iconPrefix: 'plyr',
- iconUrl: 'https://cdn.plyr.io/3.3.7/plyr.svg',
+ iconUrl: 'https://cdn.plyr.io/3.3.12/plyr.svg',
// Blank video (used to prevent errors on source change)
blankVideo: 'https://cdn.plyr.io/static/blank.mp4',
@@ -89,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
@@ -115,7 +111,10 @@ const defaults = {
// Captions settings
captions: {
active: false,
- language: (navigator.language || navigator.userLanguage).split('-')[0],
+ language: 'auto',
+ // Listen to new tracks added after Plyr is initialized.
+ // This is needed for streaming captions, but may result in unselectable options
+ update: false,
},
// Fullscreen settings
@@ -148,11 +147,7 @@ const defaults = {
'airplay',
'fullscreen',
],
- settings: [
- 'captions',
- 'quality',
- 'speed',
- ],
+ settings: ['captions', 'quality', 'speed'],
// Localisation
i18n: {
@@ -162,6 +157,7 @@ const defaults = {
pause: 'Pause',
fastForward: 'Forward {seektime}s',
seek: 'Seek',
+ seekLabel: '{currentTime} of {duration}',
played: 'Played',
buffered: 'Buffered',
currentTime: 'Current time',
@@ -176,6 +172,7 @@ const defaults = {
frameTitle: 'Player for {title}',
captions: 'Captions',
settings: 'Settings',
+ menuBack: 'Go back to previous menu',
speed: 'Speed',
normal: 'Normal',
quality: 'Quality',
@@ -187,6 +184,14 @@ const defaults = {
disabled: 'Disabled',
enabled: 'Enabled',
advertisement: 'Ad',
+ qualityBadge: {
+ 2160: '4K',
+ 1440: 'HD',
+ 1080: 'HD',
+ 720: 'HD',
+ 576: 'SD',
+ 480: 'SD',
+ },
},
// URLs
@@ -198,8 +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',
- poster: 'https://img.youtube.com/vi/{0}/maxresdefault.jpg,https://img.youtube.com/vi/{0}/hqdefault.jpg',
+ 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',
@@ -312,13 +317,13 @@ const defaults = {
display: {
currentTime: '.plyr__time--current',
duration: '.plyr__time--duration',
- buffer: '.plyr__progress--buffer',
- played: '.plyr__progress--played',
- loop: '.plyr__progress--loop',
+ buffer: '.plyr__progress__buffer',
+ loop: '.plyr__progress__loop', // Used later
volume: '.plyr__volume--display',
},
progress: '.plyr__progress',
captions: '.plyr__captions',
+ caption: '.plyr__caption',
menu: {
quality: '.js-plyr__menu__list--quality',
},
@@ -332,13 +337,14 @@ const defaults = {
embed: 'plyr__video-embed',
embedContainer: 'plyr__video-embed__container',
poster: 'plyr__poster',
+ posterEnabled: 'plyr__poster-enabled',
ads: 'plyr__ads',
control: 'plyr__control',
+ controlPressed: 'plyr__control--pressed',
playing: 'plyr--playing',
paused: 'plyr--paused',
stopped: 'plyr--stopped',
loading: 'plyr--loading',
- error: 'plyr--has-error',
hover: 'plyr--hover',
tooltip: 'plyr__tooltip',
cues: 'plyr__cues',
diff --git a/src/js/types.js b/src/js/config/types.js
index 35716c3c..13303573 100644
--- a/src/js/types.js
+++ b/src/js/config/types.js
@@ -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 };
diff --git a/src/js/controls.js b/src/js/controls.js
index ddec6581..2e18debc 100644
--- a/src/js/controls.js
+++ b/src/js/controls.js
@@ -6,14 +6,31 @@ import captions from './captions';
import html5 from './html5';
import i18n from './i18n';
import support from './support';
-import utils from './utils';
-
-// Sniff out the browser
-const browser = utils.getBrowser();
-
+import { repaint, transitionEndEvent } from './utils/animation';
+import { dedupe } from './utils/arrays';
+import browser from './utils/browser';
+import {
+ createElement,
+ emptyElement,
+ getAttributesFromSelector,
+ getElement,
+ getElements,
+ hasClass,
+ removeElement,
+ setAttributes,
+ toggleClass,
+ toggleHidden,
+ matches,
+} from './utils/elements';
+import { off, on } from './utils/events';
+import is from './utils/is';
+import loadSprite from './utils/loadSprite';
+import { extend } from './utils/objects';
+import { getPercentage, replaceAll, toCamelCase, toTitleCase } from './utils/strings';
+import { formatTime, getHours } from './utils/time';
+
+// TODO: Don't export a massive object - break down and create class
const controls = {
-
-
// Get icon URL
getIconUrl() {
const url = new URL(this.config.iconUrl, window.location);
@@ -25,46 +42,47 @@ const controls = {
};
},
- // Find the UI controls and store references in custom controls
- // TODO: Allow settings menus with custom controls
+ // Find the UI controls
findElements() {
try {
- this.elements.controls = utils.getElement.call(this, this.config.selectors.controls.wrapper);
+ this.elements.controls = getElement.call(this, this.config.selectors.controls.wrapper);
// Buttons
this.elements.buttons = {
- play: utils.getElements.call(this, this.config.selectors.buttons.play),
- pause: utils.getElement.call(this, this.config.selectors.buttons.pause),
- restart: utils.getElement.call(this, this.config.selectors.buttons.restart),
- rewind: utils.getElement.call(this, this.config.selectors.buttons.rewind),
- fastForward: utils.getElement.call(this, this.config.selectors.buttons.fastForward),
- mute: utils.getElement.call(this, this.config.selectors.buttons.mute),
- pip: utils.getElement.call(this, this.config.selectors.buttons.pip),
- airplay: utils.getElement.call(this, this.config.selectors.buttons.airplay),
- settings: utils.getElement.call(this, this.config.selectors.buttons.settings),
- captions: utils.getElement.call(this, this.config.selectors.buttons.captions),
- fullscreen: utils.getElement.call(this, this.config.selectors.buttons.fullscreen),
+ play: getElements.call(this, this.config.selectors.buttons.play),
+ pause: getElement.call(this, this.config.selectors.buttons.pause),
+ restart: getElement.call(this, this.config.selectors.buttons.restart),
+ rewind: getElement.call(this, this.config.selectors.buttons.rewind),
+ fastForward: getElement.call(this, this.config.selectors.buttons.fastForward),
+ mute: getElement.call(this, this.config.selectors.buttons.mute),
+ pip: getElement.call(this, this.config.selectors.buttons.pip),
+ airplay: getElement.call(this, this.config.selectors.buttons.airplay),
+ settings: getElement.call(this, this.config.selectors.buttons.settings),
+ captions: getElement.call(this, this.config.selectors.buttons.captions),
+ fullscreen: getElement.call(this, this.config.selectors.buttons.fullscreen),
};
// Progress
- this.elements.progress = utils.getElement.call(this, this.config.selectors.progress);
+ this.elements.progress = getElement.call(this, this.config.selectors.progress);
// Inputs
this.elements.inputs = {
- seek: utils.getElement.call(this, this.config.selectors.inputs.seek),
- volume: utils.getElement.call(this, this.config.selectors.inputs.volume),
+ seek: getElement.call(this, this.config.selectors.inputs.seek),
+ volume: getElement.call(this, this.config.selectors.inputs.volume),
};
// Display
this.elements.display = {
- buffer: utils.getElement.call(this, this.config.selectors.display.buffer),
- currentTime: utils.getElement.call(this, this.config.selectors.display.currentTime),
- duration: utils.getElement.call(this, this.config.selectors.display.duration),
+ buffer: getElement.call(this, this.config.selectors.display.buffer),
+ currentTime: getElement.call(this, this.config.selectors.display.currentTime),
+ duration: getElement.call(this, this.config.selectors.display.duration),
};
// Seek tooltip
- if (utils.is.element(this.elements.progress)) {
- this.elements.display.seekTooltip = this.elements.progress.querySelector(`.${this.config.classNames.tooltip}`);
+ if (is.element(this.elements.progress)) {
+ this.elements.display.seekTooltip = this.elements.progress.querySelector(
+ `.${this.config.classNames.tooltip}`,
+ );
}
return true;
@@ -87,9 +105,9 @@ const controls = {
// Create <svg>
const icon = document.createElementNS(namespace, 'svg');
- utils.setAttributes(
+ setAttributes(
icon,
- utils.extend(attributes, {
+ extend(attributes, {
role: 'presentation',
focusable: 'false',
}),
@@ -115,44 +133,32 @@ const controls = {
},
// Create hidden text label
- createLabel(type, attr) {
- let text = i18n.get(type, this.config);
- const attributes = Object.assign({}, attr);
-
- switch (type) {
- case 'pip':
- text = 'PIP';
- break;
-
- case 'airplay':
- text = 'AirPlay';
- break;
-
- default:
- break;
- }
-
- if ('class' in attributes) {
- attributes.class += ` ${this.config.classNames.hidden}`;
- } else {
- attributes.class = this.config.classNames.hidden;
- }
+ createLabel(type, attr = {}) {
+ // Skip i18n for abbreviations and brand names
+ const universals = {
+ pip: 'PIP',
+ airplay: 'AirPlay',
+ };
+ const text = universals[type] || i18n.get(type, this.config);
- return utils.createElement('span', attributes, text);
+ const attributes = Object.assign({}, attr, {
+ class: [attr.class, this.config.classNames.hidden].filter(Boolean).join(' '),
+ });
+ return createElement('span', attributes, text);
},
// Create a badge
createBadge(text) {
- if (utils.is.empty(text)) {
+ if (is.empty(text)) {
return null;
}
- const badge = utils.createElement('span', {
+ const badge = createElement('span', {
class: this.config.classNames.menu.value,
});
badge.appendChild(
- utils.createElement(
+ createElement(
'span',
{
class: this.config.classNames.menu.badge,
@@ -166,9 +172,9 @@ const controls = {
// Create a <button>
createButton(buttonType, attr) {
- const button = utils.createElement('button');
+ const button = createElement('button');
const attributes = Object.assign({}, attr);
- let type = utils.toCamelCase(buttonType);
+ let type = toCamelCase(buttonType);
let toggle = false;
let label;
@@ -243,22 +249,19 @@ const controls = {
// Label/Tooltip
button.appendChild(controls.createLabel.call(this, labelPressed, { class: 'label--pressed' }));
button.appendChild(controls.createLabel.call(this, label, { class: 'label--not-pressed' }));
-
- // Add aria attributes
- attributes['aria-pressed'] = false;
} else {
button.appendChild(controls.createIcon.call(this, icon));
button.appendChild(controls.createLabel.call(this, label));
}
// Merge attributes
- utils.extend(attributes, utils.getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
+ extend(attributes, getAttributesFromSelector(this.config.selectors.buttons[type], attributes));
- utils.setAttributes(button, attributes);
+ setAttributes(button, attributes);
// We have multiple play buttons
if (type === 'play') {
- if (!utils.is.array(this.elements.buttons[type])) {
+ if (!is.array(this.elements.buttons[type])) {
this.elements.buttons[type] = [];
}
@@ -267,27 +270,28 @@ const controls = {
this.elements.buttons[type] = button;
}
+ // Toggle classname when pressed property is set
+ const className = this.config.classNames.controlPressed;
+ Object.defineProperty(button, 'pressed', {
+ enumerable: true,
+ get() {
+ return hasClass(button, className);
+ },
+ set(pressed = false) {
+ toggleClass(button, className, pressed);
+ },
+ });
+
return button;
},
// Create an <input type='range'>
createRange(type, attributes) {
- // Seek label
- const label = utils.createElement(
- 'label',
- {
- for: attributes.id,
- id: `${attributes.id}-label`,
- class: this.config.classNames.hidden,
- },
- i18n.get(type, this.config),
- );
-
// Seek input
- const input = utils.createElement(
+ const input = createElement(
'input',
- utils.extend(
- utils.getAttributesFromSelector(this.config.selectors.inputs[type]),
+ extend(
+ getAttributesFromSelector(this.config.selectors.inputs[type]),
{
type: 'range',
min: 0,
@@ -297,7 +301,7 @@ const controls = {
autocomplete: 'off',
// A11y fixes for https://github.com/sampotts/plyr/issues/905
role: 'slider',
- 'aria-labelledby': `${attributes.id}-label`,
+ 'aria-label': i18n.get(type, this.config),
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': 0,
@@ -311,18 +315,15 @@ const controls = {
// Set the fill for webkit now
controls.updateRangeFill.call(this, input);
- return {
- label,
- input,
- };
+ return input;
},
// Create a <progress>
createProgress(type, attributes) {
- const progress = utils.createElement(
+ const progress = createElement(
'progress',
- utils.extend(
- utils.getAttributesFromSelector(this.config.selectors.display[type]),
+ extend(
+ getAttributesFromSelector(this.config.selectors.display[type]),
{
min: 0,
max: 100,
@@ -336,21 +337,13 @@ const controls = {
// Create the label inside
if (type !== 'volume') {
- progress.appendChild(utils.createElement('span', null, '0'));
+ progress.appendChild(createElement('span', null, '0'));
- let suffix = '';
- switch (type) {
- case 'played':
- suffix = i18n.get('played', this.config);
- break;
-
- case 'buffer':
- suffix = i18n.get('buffered', this.config);
- break;
-
- default:
- break;
- }
+ const suffixKey = {
+ played: 'played',
+ buffer: 'buffered',
+ }[type];
+ const suffix = suffixKey ? i18n.get(suffixKey, this.config) : '';
progress.innerText = `% ${suffix.toLowerCase()}`;
}
@@ -362,12 +355,16 @@ const controls = {
// Create time display
createTime(type) {
- const attributes = utils.getAttributesFromSelector(this.config.selectors.display[type]);
+ const attributes = getAttributesFromSelector(this.config.selectors.display[type]);
- const container = utils.createElement('div', utils.extend(attributes, {
- class: `plyr__time ${attributes.class}`,
- 'aria-label': i18n.get(type, this.config),
- }), '00:00');
+ const container = createElement(
+ 'div',
+ extend(attributes, {
+ class: `plyr__time ${attributes.class}`,
+ 'aria-label': i18n.get(type, this.config),
+ }),
+ '00:00',
+ );
// Reference for updates
this.elements.display[type] = container;
@@ -376,16 +373,16 @@ const controls = {
},
// Create a settings menu item
- createMenuItem(value, list, type, title, badge = null, checked = false) {
- const item = utils.createElement('li');
+ createMenuItem({ value, list, type, title, badge = null, checked = false }) {
+ const item = createElement('li');
- const label = utils.createElement('label', {
+ const label = createElement('label', {
class: this.config.classNames.control,
});
- const radio = utils.createElement(
+ const radio = createElement(
'input',
- utils.extend(utils.getAttributesFromSelector(this.config.selectors.inputs[type]), {
+ extend(getAttributesFromSelector(this.config.selectors.inputs[type]), {
type: 'radio',
name: `plyr-${type}`,
value,
@@ -394,13 +391,13 @@ const controls = {
}),
);
- const faux = utils.createElement('span', { hidden: '' });
+ const faux = createElement('span', { hidden: '' });
label.appendChild(radio);
label.appendChild(faux);
label.insertAdjacentHTML('beforeend', title);
- if (utils.is.element(badge)) {
+ if (is.element(badge)) {
label.appendChild(badge);
}
@@ -408,18 +405,28 @@ const controls = {
list.appendChild(item);
},
+ // Format a time for display
+ formatTime(time = 0, inverted = false) {
+ // Bail if the value isn't a number
+ if (!is.number(time)) {
+ return time;
+ }
+
+ // Always display hours if duration is over an hour
+ const forceHours = getHours(this.duration) > 0;
+
+ return formatTime(time, forceHours, inverted);
+ },
+
// Update the displayed time
updateTimeDisplay(target = null, time = 0, inverted = false) {
// Bail if there's no element to display or the value isn't a number
- if (!utils.is.element(target) || !utils.is.number(time)) {
+ if (!is.element(target) || !is.number(time)) {
return;
}
- // Always display hours if duration is over an hour
- const forceHours = utils.getHours(this.duration) > 0;
-
// eslint-disable-next-line no-param-reassign
- target.innerText = utils.formatTime(time, forceHours, inverted);
+ target.innerText = controls.formatTime(time, inverted);
},
// Update volume UI and storage
@@ -429,19 +436,19 @@ const controls = {
}
// Update range
- if (utils.is.element(this.elements.inputs.volume)) {
+ if (is.element(this.elements.inputs.volume)) {
controls.setRange.call(this, this.elements.inputs.volume, this.muted ? 0 : this.volume);
}
// Update mute state
- if (utils.is.element(this.elements.buttons.mute)) {
- utils.toggleState(this.elements.buttons.mute, this.muted || this.volume === 0);
+ if (is.element(this.elements.buttons.mute)) {
+ this.elements.buttons.mute.pressed = this.muted || this.volume === 0;
}
},
// Update seek value and lower fill
setRange(target, value = 0) {
- if (!utils.is.element(target)) {
+ if (!is.element(target)) {
return;
}
@@ -454,23 +461,23 @@ const controls = {
// Update <progress> elements
updateProgress(event) {
- if (!this.supported.ui || !utils.is.event(event)) {
+ if (!this.supported.ui || !is.event(event)) {
return;
}
let value = 0;
const setProgress = (target, input) => {
- const value = utils.is.number(input) ? input : 0;
- const progress = utils.is.element(target) ? target : this.elements.display.buffer;
+ const value = is.number(input) ? input : 0;
+ const progress = is.element(target) ? target : this.elements.display.buffer;
// Update value and label
- if (utils.is.element(progress)) {
+ if (is.element(progress)) {
progress.value = value;
// Update text label inside
const label = progress.getElementsByTagName('span')[0];
- if (utils.is.element(label)) {
+ if (is.element(label)) {
label.childNodes[0].nodeValue = value;
}
}
@@ -481,7 +488,8 @@ const controls = {
// Video playing
case 'timeupdate':
case 'seeking':
- value = utils.getPercentage(this.currentTime, this.duration);
+ case 'seeked':
+ value = getPercentage(this.currentTime, this.duration);
// Set seek range value only if it's a 'natural' time event
if (event.type === 'timeupdate') {
@@ -506,15 +514,30 @@ const controls = {
// Webkit polyfill for lower fill range
updateRangeFill(target) {
// Get range from event if event passed
- const range = utils.is.event(target) ? target.target : target;
+ const range = is.event(target) ? target.target : target;
// Needs to be a valid <input type='range'>
- if (!utils.is.element(range) || range.getAttribute('type') !== 'range') {
+ if (!is.element(range) || range.getAttribute('type') !== 'range') {
return;
}
- // Set aria value for https://github.com/sampotts/plyr/issues/905
- range.setAttribute('aria-valuenow', range.value);
+ // Set aria values for https://github.com/sampotts/plyr/issues/905
+ if (matches(range, this.config.selectors.inputs.seek)) {
+ range.setAttribute('aria-valuenow', this.currentTime);
+ const currentTime = controls.formatTime(this.currentTime);
+ const duration = controls.formatTime(this.duration);
+ const format = i18n.get('seekLabel', this.config);
+ range.setAttribute(
+ 'aria-valuetext',
+ format.replace('{currentTime}', currentTime).replace('{duration}', duration),
+ );
+ } else if (matches(range, this.config.selectors.inputs.volume)) {
+ const percent = range.value * 100;
+ range.setAttribute('aria-valuenow', percent);
+ range.setAttribute('aria-valuetext', `${percent}%`);
+ } else {
+ range.setAttribute('aria-valuenow', range.value);
+ }
// WebKit only
if (!browser.isWebkit) {
@@ -530,8 +553,8 @@ const controls = {
// Bail if setting not true
if (
!this.config.tooltips.seek ||
- !utils.is.element(this.elements.inputs.seek) ||
- !utils.is.element(this.elements.display.seekTooltip) ||
+ !is.element(this.elements.inputs.seek) ||
+ !is.element(this.elements.display.seekTooltip) ||
this.duration === 0
) {
return;
@@ -543,7 +566,7 @@ const controls = {
const visible = `${this.config.classNames.tooltip}--visible`;
const toggle = toggle => {
- utils.toggleClass(this.elements.display.seekTooltip, visible, toggle);
+ toggleClass(this.elements.display.seekTooltip, visible, toggle);
};
// Hide on touch
@@ -553,9 +576,9 @@ const controls = {
}
// Determine percentage, if already visible
- if (utils.is.event(event)) {
+ if (is.event(event)) {
percent = 100 / clientRect.width * (event.pageX - clientRect.left);
- } else if (utils.hasClass(this.elements.display.seekTooltip, visible)) {
+ } else if (hasClass(this.elements.display.seekTooltip, visible)) {
percent = parseFloat(this.elements.display.seekTooltip.style.left, 10);
} else {
return;
@@ -576,10 +599,7 @@ const controls = {
// Show/hide the tooltip
// If the event is a moues in/out and percentage is inside bounds
- if (utils.is.event(event) && [
- 'mouseenter',
- 'mouseleave',
- ].includes(event.type)) {
+ if (is.event(event) && ['mouseenter', 'mouseleave'].includes(event.type)) {
toggle(event.type === 'mouseenter');
}
},
@@ -587,10 +607,15 @@ const controls = {
// Handle time change event
timeUpdate(event) {
// Only invert if only one time element is displayed and used for both duration and currentTime
- const invert = !utils.is.element(this.elements.display.duration) && this.config.invertTime;
+ const invert = !is.element(this.elements.display.duration) && this.config.invertTime;
// Duration
- controls.updateTimeDisplay.call(this, this.elements.display.currentTime, invert ? this.duration - this.currentTime : this.currentTime, invert);
+ controls.updateTimeDisplay.call(
+ this,
+ this.elements.display.currentTime,
+ invert ? this.duration - this.currentTime : this.currentTime,
+ invert,
+ );
// Ignore updates while seeking
if (event && event.type === 'timeupdate' && this.media.seeking) {
@@ -601,14 +626,20 @@ const controls = {
controls.updateProgress.call(this, event);
},
- // Show the duration on metadataloaded
+ // Show the duration on metadataloaded or durationchange events
durationUpdate() {
- if (!this.supported.ui) {
+ // Bail if no UI or durationchange event triggered after playing/seek when invertTime is false
+ if (!this.supported.ui || (!this.config.invertTime && this.currentTime)) {
return;
}
+ // Update ARIA values
+ if (is.element(this.elements.inputs.seek)) {
+ this.elements.inputs.seek.setAttribute('aria-valuemax', this.duration);
+ }
+
// If there's a spot to display duration
- const hasDuration = utils.is.element(this.elements.display.duration);
+ const hasDuration = is.element(this.elements.display.duration);
// If there's only one time display, display duration there
if (!hasDuration && this.config.displayDuration && this.paused) {
@@ -626,27 +657,26 @@ const controls = {
// Hide/show a tab
toggleTab(setting, toggle) {
- utils.toggleHidden(this.elements.settings.tabs[setting], !toggle);
+ toggleHidden(this.elements.settings.tabs[setting], !toggle);
},
// Set the quality menu
- // TODO: Vimeo support
setQualityMenu(options) {
// Menu required
- if (!utils.is.element(this.elements.settings.panes.quality)) {
+ if (!is.element(this.elements.settings.panes.quality)) {
return;
}
const type = 'quality';
const list = this.elements.settings.panes.quality.querySelector('ul');
- // Set options if passed and filter based on config
- if (utils.is.array(options)) {
- this.options.quality = options.filter(quality => this.config.quality.options.includes(quality));
+ // Set options if passed and filter based on uniqueness and config
+ if (is.array(options)) {
+ this.options.quality = dedupe(options).filter(quality => this.config.quality.options.includes(quality));
}
// Toggle the pane and tab
- const toggle = !utils.is.empty(this.options.quality) && this.options.quality.length > 1;
+ const toggle = !is.empty(this.options.quality) && this.options.quality.length > 1;
controls.toggleTab.call(this, type, toggle);
// Check if we need to toggle the parent
@@ -658,31 +688,11 @@ const controls = {
}
// Empty the menu
- utils.emptyElement(list);
+ emptyElement(list);
// Get the badge HTML for HD, 4K etc
const getBadge = quality => {
- let label = '';
-
- switch (quality) {
- case 2160:
- label = '4K';
- break;
-
- case 1440:
- case 1080:
- case 720:
- label = 'HD';
- break;
-
- case 576:
- case 480:
- label = 'SD';
- break;
-
- default:
- break;
- }
+ const label = i18n.get(`qualityBadge.${quality}`, this.config);
if (!label.length) {
return null;
@@ -698,26 +708,36 @@ const controls = {
return sorting.indexOf(a) > sorting.indexOf(b) ? 1 : -1;
})
.forEach(quality => {
- const label = controls.getLabel.call(this, 'quality', quality);
- controls.createMenuItem.call(this, quality, list, type, label, getBadge(quality));
+ controls.createMenuItem.call(this, {
+ value: quality,
+ list,
+ type,
+ title: controls.getLabel.call(this, 'quality', quality),
+ badge: getBadge(quality),
+ });
});
controls.updateSetting.call(this, type, list);
},
// Translate a value into a nice label
- // TODO: Localisation
getLabel(setting, value) {
switch (setting) {
case 'speed':
return value === 1 ? i18n.get('normal', this.config) : `${value}&times;`;
case 'quality':
- if (utils.is.number(value)) {
- return `${value}p`;
+ if (is.number(value)) {
+ const label = i18n.get(`qualityLabel.${value}`, this.config);
+
+ if (!label.length) {
+ return `${value}p`;
+ }
+
+ return label;
}
- return utils.toTitleCase(value);
+ return toTitleCase(value);
case 'captions':
return captions.getLabel.call(this);
@@ -733,50 +753,36 @@ const controls = {
let value = null;
let list = container;
- switch (setting) {
- case 'captions':
- if (this.captions.active) {
- if (this.options.captions.length > 2 || !this.options.captions.some(lang => lang === 'enabled')) {
- value = this.captions.language;
- } else {
- value = 'enabled';
- }
- } else {
- value = '';
- }
-
- break;
-
- default:
- value = !utils.is.empty(input) ? input : this[setting];
-
- // Get default
- if (utils.is.empty(value)) {
- value = this.config[setting].default;
- }
+ if (setting === 'captions') {
+ value = this.currentTrack;
+ } else {
+ value = !is.empty(input) ? input : this[setting];
- // Unsupported value
- if (!utils.is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
- this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
- return;
- }
+ // Get default
+ if (is.empty(value)) {
+ value = this.config[setting].default;
+ }
- // Disabled value
- if (!this.config[setting].options.includes(value)) {
- this.debug.warn(`Disabled value of '${value}' for ${setting}`);
- return;
- }
+ // Unsupported value
+ if (!is.empty(this.options[setting]) && !this.options[setting].includes(value)) {
+ this.debug.warn(`Unsupported value of '${value}' for ${setting}`);
+ return;
+ }
- break;
+ // Disabled value
+ if (!this.config[setting].options.includes(value)) {
+ this.debug.warn(`Disabled value of '${value}' for ${setting}`);
+ return;
+ }
}
// Get the list if we need to
- if (!utils.is.element(list)) {
+ if (!is.element(list)) {
list = pane && pane.querySelector('ul');
}
// If there's no list it means it's not been rendered...
- if (!utils.is.element(list)) {
+ if (!is.element(list)) {
return;
}
@@ -787,7 +793,7 @@ const controls = {
// Find the radio option and check it
const target = list && list.querySelector(`input[value="${value}"]`);
- if (utils.is.element(target)) {
+ if (is.element(target)) {
target.checked = true;
}
},
@@ -795,7 +801,7 @@ const controls = {
// Set the looping options
/* setLoopMenu() {
// Menu required
- if (!utils.is.element(this.elements.settings.panes.loop)) {
+ if (!is.element(this.elements.settings.panes.loop)) {
return;
}
@@ -803,22 +809,22 @@ const controls = {
const list = this.elements.settings.panes.loop.querySelector('ul');
// Show the pane and tab
- utils.toggleHidden(this.elements.settings.tabs.loop, false);
- utils.toggleHidden(this.elements.settings.panes.loop, false);
+ toggleHidden(this.elements.settings.tabs.loop, false);
+ toggleHidden(this.elements.settings.panes.loop, false);
// Toggle the pane and tab
- const toggle = !utils.is.empty(this.loop.options);
+ const toggle = !is.empty(this.loop.options);
controls.toggleTab.call(this, 'loop', toggle);
// Empty the menu
- utils.emptyElement(list);
+ emptyElement(list);
options.forEach(option => {
- const item = utils.createElement('li');
+ const item = createElement('li');
- const button = utils.createElement(
+ const button = createElement(
'button',
- utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.loop), {
+ extend(getAttributesFromSelector(this.config.selectors.buttons.loop), {
type: 'button',
class: this.config.classNames.control,
'data-plyr-loop-action': option,
@@ -844,49 +850,43 @@ const controls = {
// TODO: Captions or language? Currently it's mixed
const type = 'captions';
const list = this.elements.settings.panes.captions.querySelector('ul');
+ const tracks = captions.getTracks.call(this);
// Toggle the pane and tab
- const toggle = captions.getTracks.call(this).length;
- controls.toggleTab.call(this, type, toggle);
+ controls.toggleTab.call(this, type, tracks.length);
// Empty the menu
- utils.emptyElement(list);
+ emptyElement(list);
// Check if we need to toggle the parent
controls.checkMenu.call(this);
// If there's no captions, bail
- if (!toggle) {
+ if (!tracks.length) {
return;
}
- // Re-map the tracks into just the data we need
- const tracks = captions.getTracks.call(this).map(track => ({
- language: !utils.is.empty(track.language) ? track.language : 'enabled',
- label: captions.getLabel.call(this, track),
+ // Generate options data
+ const options = tracks.map((track, value) => ({
+ value,
+ checked: this.captions.toggled && this.currentTrack === value,
+ title: captions.getLabel.call(this, track),
+ badge: track.language && controls.createBadge.call(this, track.language.toUpperCase()),
+ list,
+ type: 'language',
}));
// Add the "Disabled" option to turn off captions
- tracks.unshift({
- language: '',
- label: i18n.get('disabled', this.config),
+ options.unshift({
+ value: -1,
+ checked: !this.captions.toggled,
+ title: i18n.get('disabled', this.config),
+ list,
+ type: 'language',
});
// Generate options
- tracks.forEach(track => {
- controls.createMenuItem.call(
- this,
- track.language,
- list,
- 'language',
- track.label,
- track.language !== 'enabled' ? controls.createBadge.call(this, track.language.toUpperCase()) : null,
- track.language.toLowerCase() === this.captions.language.toLowerCase(),
- );
- });
-
- // Store reference
- this.options.captions = tracks.map(track => track.language);
+ options.forEach(controls.createMenuItem.bind(this));
controls.updateSetting.call(this, type, list);
},
@@ -899,32 +899,24 @@ const controls = {
}
// Menu required
- if (!utils.is.element(this.elements.settings.panes.speed)) {
+ if (!is.element(this.elements.settings.panes.speed)) {
return;
}
const type = 'speed';
// Set the speed options
- if (utils.is.array(options)) {
+ if (is.array(options)) {
this.options.speed = options;
} else if (this.isHTML5 || this.isVimeo) {
- this.options.speed = [
- 0.5,
- 0.75,
- 1,
- 1.25,
- 1.5,
- 1.75,
- 2,
- ];
+ this.options.speed = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
}
// Set options if passed and filter based on config
this.options.speed = this.options.speed.filter(speed => this.config.speed.options.includes(speed));
// Toggle the pane and tab
- const toggle = !utils.is.empty(this.options.speed) && this.options.speed.length > 1;
+ const toggle = !is.empty(this.options.speed) && this.options.speed.length > 1;
controls.toggleTab.call(this, type, toggle);
// Check if we need to toggle the parent
@@ -939,12 +931,16 @@ const controls = {
const list = this.elements.settings.panes.speed.querySelector('ul');
// Empty the menu
- utils.emptyElement(list);
+ emptyElement(list);
// Create items
this.options.speed.forEach(speed => {
- const label = controls.getLabel.call(this, 'speed', speed);
- controls.createMenuItem.call(this, speed, list, type, label);
+ controls.createMenuItem.call(this, {
+ value: speed,
+ list,
+ type,
+ title: controls.getLabel.call(this, 'speed', speed),
+ });
});
controls.updateSetting.call(this, type, list);
@@ -953,9 +949,9 @@ const controls = {
// Check if we need to hide/show the settings menu
checkMenu() {
const { tabs } = this.elements.settings;
- const visible = !utils.is.empty(tabs) && Object.values(tabs).some(tab => !tab.hidden);
+ const visible = !is.empty(tabs) && Object.values(tabs).some(tab => !tab.hidden);
- utils.toggleHidden(this.elements.settings.menu, !visible);
+ toggleHidden(this.elements.settings.menu, !visible);
},
// Show/hide menu
@@ -964,14 +960,14 @@ const controls = {
const button = this.elements.buttons.settings;
// Menu and button are required
- if (!utils.is.element(form) || !utils.is.element(button)) {
+ if (!is.element(form) || !is.element(button)) {
return;
}
- const show = utils.is.boolean(event) ? event : utils.is.element(form) && form.hasAttribute('hidden');
+ const show = is.boolean(event) ? event : is.element(form) && form.hasAttribute('hidden');
- if (utils.is.event(event)) {
- const isMenuItem = utils.is.element(form) && form.contains(event.target);
+ if (is.event(event)) {
+ const isMenuItem = is.element(form) && form.contains(event.target);
const isButton = event.target === this.elements.buttons.settings;
// If the click was inside the form or if the click
@@ -988,13 +984,13 @@ const controls = {
}
// Set form and button attributes
- if (utils.is.element(button)) {
+ if (is.element(button)) {
button.setAttribute('aria-expanded', show);
}
- if (utils.is.element(form)) {
- utils.toggleHidden(form, !show);
- utils.toggleClass(this.elements.container, this.config.classNames.menu.open, show);
+ if (is.element(form)) {
+ toggleHidden(form, !show);
+ toggleClass(this.elements.container, this.config.classNames.menu.open, show);
if (show) {
form.removeAttribute('tabindex');
@@ -1025,7 +1021,7 @@ const controls = {
const height = clone.scrollHeight;
// Remove from the DOM
- utils.removeElement(clone);
+ removeElement(clone);
return {
width,
@@ -1039,11 +1035,11 @@ const controls = {
const pane = document.getElementById(target);
// Nothing to show, bail
- if (!utils.is.element(pane)) {
+ if (!is.element(pane)) {
return;
}
- // Are we targetting a tab? If not, bail
+ // Are we targeting a tab? If not, bail
const isTab = pane.getAttribute('role') === 'tabpanel';
if (!isTab) {
return;
@@ -1071,10 +1067,7 @@ const controls = {
// Restore auto height/width
const restore = e => {
// We're only bothered about height and width on the container
- if (e.target !== container || ![
- 'width',
- 'height',
- ].includes(e.propertyName)) {
+ if (e.target !== container || !['width', 'height'].includes(e.propertyName)) {
return;
}
@@ -1083,11 +1076,11 @@ const controls = {
container.style.height = '';
// Only listen once
- utils.off(container, utils.transitionEndEvent, restore);
+ off.call(this, container, transitionEndEvent, restore);
};
// Listen for the transition finishing and restore auto height/width
- utils.on(container, utils.transitionEndEvent, restore);
+ on.call(this, container, transitionEndEvent, restore);
// Set dimensions to target
container.style.width = `${size.width}px`;
@@ -1095,13 +1088,13 @@ const controls = {
}
// Set attributes on current tab
- utils.toggleHidden(current, true);
+ toggleHidden(current, true);
current.setAttribute('tabindex', -1);
// Set attributes on target
- utils.toggleHidden(pane, false);
+ toggleHidden(pane, false);
- const tabs = utils.getElements.call(this, `[aria-controls="${target}"]`);
+ const tabs = getElements.call(this, `[aria-controls="${target}"]`);
Array.from(tabs).forEach(tab => {
tab.setAttribute('aria-expanded', true);
});
@@ -1115,12 +1108,12 @@ const controls = {
// TODO: Set order based on order in the config.controls array?
create(data) {
// Do nothing if we want no controls
- if (utils.is.empty(this.config.controls)) {
+ if (is.empty(this.config.controls)) {
return null;
}
// Create the container
- const container = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.controls.wrapper));
+ const container = createElement('div', getAttributesFromSelector(this.config.selectors.controls.wrapper));
// Restart button
if (this.config.controls.includes('restart')) {
@@ -1144,14 +1137,14 @@ const controls = {
// Progress
if (this.config.controls.includes('progress')) {
- const progress = utils.createElement('div', utils.getAttributesFromSelector(this.config.selectors.progress));
+ const progress = createElement('div', getAttributesFromSelector(this.config.selectors.progress));
// Seek range slider
- const seek = controls.createRange.call(this, 'seek', {
- id: `plyr-seek-${data.id}`,
- });
- progress.appendChild(seek.label);
- progress.appendChild(seek.input);
+ progress.appendChild(
+ controls.createRange.call(this, 'seek', {
+ id: `plyr-seek-${data.id}`,
+ }),
+ );
// Buffer progress
progress.appendChild(controls.createProgress.call(this, 'buffer'));
@@ -1160,10 +1153,9 @@ const controls = {
// Seek tooltip
if (this.config.tooltips.seek) {
- const tooltip = utils.createElement(
+ const tooltip = createElement(
'span',
{
- role: 'tooltip',
class: this.config.classNames.tooltip,
},
'00:00',
@@ -1194,7 +1186,7 @@ const controls = {
// Volume range control
if (this.config.controls.includes('volume')) {
- const volume = utils.createElement('div', {
+ const volume = createElement('div', {
class: 'plyr__volume',
});
@@ -1206,15 +1198,15 @@ const controls = {
};
// Create the volume range slider
- const range = controls.createRange.call(
- this,
- 'volume',
- utils.extend(attributes, {
- id: `plyr-volume-${data.id}`,
- }),
+ volume.appendChild(
+ controls.createRange.call(
+ this,
+ 'volume',
+ extend(attributes, {
+ id: `plyr-volume-${data.id}`,
+ }),
+ ),
);
- volume.appendChild(range.label);
- volume.appendChild(range.input);
this.elements.volume = volume;
@@ -1227,8 +1219,8 @@ const controls = {
}
// Settings button / menu
- if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) {
- const menu = utils.createElement('div', {
+ if (this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
+ const menu = createElement('div', {
class: 'plyr__menu',
hidden: '',
});
@@ -1242,7 +1234,7 @@ const controls = {
}),
);
- const form = utils.createElement('form', {
+ const form = createElement('form', {
class: 'plyr__menu__container',
id: `plyr-settings-${data.id}`,
hidden: '',
@@ -1251,29 +1243,29 @@ const controls = {
tabindex: -1,
});
- const inner = utils.createElement('div');
+ const inner = createElement('div');
- const home = utils.createElement('div', {
+ const home = createElement('div', {
id: `plyr-settings-${data.id}-home`,
'aria-labelled-by': `plyr-settings-toggle-${data.id}`,
role: 'tabpanel',
});
// Create the tab list
- const tabs = utils.createElement('ul', {
+ const tabs = createElement('ul', {
role: 'tablist',
});
// Build the tabs
this.config.settings.forEach(type => {
- const tab = utils.createElement('li', {
+ const tab = createElement('li', {
role: 'tab',
hidden: '',
});
- const button = utils.createElement(
+ const button = createElement(
'button',
- utils.extend(utils.getAttributesFromSelector(this.config.selectors.buttons.settings), {
+ extend(getAttributesFromSelector(this.config.selectors.buttons.settings), {
type: 'button',
class: `${this.config.classNames.control} ${this.config.classNames.control}--forward`,
id: `plyr-settings-${data.id}-${type}-tab`,
@@ -1284,7 +1276,7 @@ const controls = {
i18n.get(type, this.config),
);
- const value = utils.createElement('span', {
+ const value = createElement('span', {
class: this.config.classNames.menu.value,
});
@@ -1303,7 +1295,7 @@ const controls = {
// Build the panes
this.config.settings.forEach(type => {
- const pane = utils.createElement('div', {
+ const pane = createElement('div', {
id: `plyr-settings-${data.id}-${type}`,
hidden: '',
'aria-labelled-by': `plyr-settings-${data.id}-${type}-tab`,
@@ -1311,7 +1303,7 @@ const controls = {
tabindex: -1,
});
- const back = utils.createElement(
+ const back = createElement(
'button',
{
type: 'button',
@@ -1325,7 +1317,7 @@ const controls = {
pane.appendChild(back);
- const options = utils.createElement('ul');
+ const options = createElement('ul');
pane.appendChild(options);
inner.appendChild(pane);
@@ -1380,7 +1372,7 @@ const controls = {
// Only load external sprite using AJAX
if (icon.cors) {
- utils.loadSprite(icon.url, 'sprite-plyr');
+ loadSprite(icon.url, 'sprite-plyr');
}
}
@@ -1399,10 +1391,10 @@ const controls = {
};
let update = true;
- if (utils.is.string(this.config.controls) || utils.is.element(this.config.controls)) {
+ if (is.string(this.config.controls) || is.element(this.config.controls)) {
// String or HTMLElement passed as the option
container = this.config.controls;
- } else if (utils.is.function(this.config.controls)) {
+ } else if (is.function(this.config.controls)) {
// A custom function to build controls
// The function can return a HTMLElement or String
container = this.config.controls.call(this, props);
@@ -1424,11 +1416,8 @@ const controls = {
const replace = input => {
let result = input;
- Object.entries(props).forEach(([
- key,
- value,
- ]) => {
- result = utils.replaceAll(result, `{${key}}`, value);
+ Object.entries(props).forEach(([key, value]) => {
+ result = replaceAll(result, `{${key}}`, value);
});
return result;
@@ -1436,9 +1425,9 @@ const controls = {
// Update markup
if (update) {
- if (utils.is.string(this.config.controls)) {
+ if (is.string(this.config.controls)) {
container = replace(container);
- } else if (utils.is.element(container)) {
+ } else if (is.element(container)) {
container.innerHTML = replace(container.innerHTML);
}
}
@@ -1447,49 +1436,41 @@ const controls = {
let target;
// Inject to custom location
- if (utils.is.string(this.config.selectors.controls.container)) {
+ if (is.string(this.config.selectors.controls.container)) {
target = document.querySelector(this.config.selectors.controls.container);
}
// Inject into the container by default
- if (!utils.is.element(target)) {
+ if (!is.element(target)) {
target = this.elements.container;
}
// Inject controls HTML
- if (utils.is.element(container)) {
+ if (is.element(container)) {
target.appendChild(container);
} else if (container) {
target.insertAdjacentHTML('beforeend', container);
}
// Find the elements if need be
- if (!utils.is.element(this.elements.controls)) {
+ if (!is.element(this.elements.controls)) {
controls.findElements.call(this);
}
// Edge sometimes doesn't finish the paint so force a redraw
if (window.navigator.userAgent.includes('Edge')) {
- utils.repaint(target);
+ repaint(target);
}
// Setup tooltips
if (this.config.tooltips.controls) {
- const labels = utils.getElements.call(
- this,
- [
- this.config.selectors.controls.wrapper,
- ' ',
- this.config.selectors.labels,
- ' .',
- this.config.classNames.hidden,
- ].join(''),
- );
+ const { classNames, selectors } = this.config;
+ const selector = `${selectors.controls.wrapper} ${selectors.labels} .${classNames.hidden}`;
+ const labels = getElements.call(this, selector);
Array.from(labels).forEach(label => {
- utils.toggleClass(label, this.config.classNames.hidden, false);
- utils.toggleClass(label, this.config.classNames.tooltip, true);
- label.setAttribute('role', 'tooltip');
+ toggleClass(label, this.config.classNames.hidden, false);
+ toggleClass(label, this.config.classNames.tooltip, true);
});
}
},
diff --git a/src/js/fullscreen.js b/src/js/fullscreen.js
index 000ba706..7091fde1 100644
--- a/src/js/fullscreen.js
+++ b/src/js/fullscreen.js
@@ -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)) {
- utils.toggleState(button, this.active);
+ 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`, () => {
- // TODO: Filter for target??
- onChange.call(this);
- });
+ 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}`]();
}
diff --git a/src/js/html5.js b/src/js/html5.js
index 3818a441..0876211a 100644
--- a/src/js/html5.js
+++ b/src/js/html5.js
@@ -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,67 +39,47 @@ const html5 = {
get() {
// Get sources
const sources = html5.getSources.call(player);
+ const source = sources.find(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)) {
- 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')));
+ // Get first match for requested size
+ const source = sources.find(source => Number(source.getAttribute('size')) === input);
- // No supported sources
- if (utils.is.empty(supported)) {
+ // No matching source found
+ if (!source) {
return;
}
- // Trigger change event
- utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, {
- quality: input,
- });
-
// Get current state
- const { currentTime, playing } = player;
+ const { currentTime, paused, preload, readyState } = player.media;
// Set new source
- player.media.src = supported[0].getAttribute('src');
-
- // Load new source
- player.media.load();
-
- // Resume playing
- if (playing) {
- player.play();
+ player.media.src = source.getAttribute('src');
+
+ // Prevent loading if preload="none" and the current source isn't loaded (#1044)
+ if (preload !== 'none' || readyState) {
+ // Restore time
+ player.once('loadedmetadata', () => {
+ player.currentTime = currentTime;
+
+ // Resume playing
+ if (!paused) {
+ player.play();
+ }
+ });
+
+ // Load new source
+ player.media.load();
}
- // Restore time
- player.currentTime = currentTime;
-
// Trigger change event
- utils.dispatchEvent.call(player, player.media, 'qualitychange', false, {
+ triggerEvent.call(player, player.media, 'qualitychange', false, {
quality: input,
});
},
@@ -126,7 +94,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
diff --git a/src/js/i18n.js b/src/js/i18n.js
index 58c3e7cf..5b0ebbab 100644
--- a/src/js/i18n.js
+++ b/src/js/i18n.js
@@ -2,26 +2,29 @@
// 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) || !Object.keys(config.i18n).includes(key)) {
+ if (is.empty(key) || is.empty(config)) {
return '';
}
- let string = config.i18n[key];
+ let string = getDeep(config.i18n, key);
+
+ if (is.empty(string)) {
+ return '';
+ }
const replace = {
'{seektime}': config.seekTime,
'{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;
diff --git a/src/js/listeners.js b/src/js/listeners.js
index e95c5e44..7615e365 100644
--- a/src/js/listeners.js
+++ b/src/js/listeners.js
@@ -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,35 +46,17 @@ 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) && 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;
}
@@ -192,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;
}
@@ -234,36 +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 visibility based on mouse movement
- if (this.player.config.hideControls) {
- // Toggle controls on mouse events and entering fullscreen
- utils.on(this.player.elements.container, 'mouseenter mouseleave mousemove touchstart touchend touchmove enterfullscreen exitfullscreen', event => {
- this.player.toggleControls(event);
- });
- }
+ // Toggle controls on mouse events and entering fullscreen
+ 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;
+ }
+
+ // Show, then hide after a timeout unless another control event occurs
+ const show = ['touchstart', 'touchmove', 'mousemove'].includes(event.type);
+
+ let delay = 0;
+
+ 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);
+ },
+ );
}
// Listen for media events
media() {
// Time change on media
- utils.on(this.player.media, 'timeupdate seeking', 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
@@ -272,23 +278,28 @@ class Listeners {
});
// Check for buffer progress
- utils.on(this.player.media, 'progress playing', 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));
-
- // Check if media failed to load
- // utils.on(this.player.media, 'play', event => ui.checkFailed.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;
}
@@ -303,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;
@@ -330,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 => {
@@ -341,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');
@@ -356,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 = {};
+ 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);
});
}
@@ -410,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
@@ -419,33 +411,43 @@ 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);
-
- utils.on(element, type, event => proxy(event, defaultHandler, customHandlerKey), passive && !hasCustomHandler);
+ const hasCustomHandler = is.function(customHandler);
+
+ 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');
+ if (this.player.elements.buttons.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',
() => {
@@ -455,10 +457,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',
() => {
@@ -468,7 +470,7 @@ class Listeners {
);
// Picture-in-Picture
- on(
+ bind(
this.player.elements.buttons.pip,
'click',
() => {
@@ -478,15 +480,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
@@ -496,16 +498,16 @@ 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,
() => {
- this.player.language = event.target.value;
+ this.player.currentTrack = Number(event.target.value);
showHomeTab();
},
'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,
() => {
@@ -514,7 +516,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,
() => {
@@ -530,28 +532,54 @@ class Listeners {
});
// Set range input alternative "value", which matches the tooltip time (#954)
- on(
- 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('seekNext', percent);
+ 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
+ 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;
+ const eventType = event.type;
+
+ if ((eventType === 'keydown' || eventType === 'keyup') && (code !== 39 && code !== 37)) {
+ return;
}
- );
+ // Was playing before?
+ const play = seek.hasAttribute('play-on-seeked');
+
+ // Done seeking
+ const done = ['mouseup', 'touchend', 'keyup'].includes(event.type);
+
+ // If we're done seeking and it was playing, resume playback
+ if (play && done) {
+ seek.removeAttribute('play-on-seeked');
+ this.player.play();
+ } else if (!done && this.player.playing) {
+ seek.setAttribute('play-on-seeked', '');
+ this.player.pause();
+ }
+ });
// Seek
- on(
+ bind(
this.player.elements.inputs.seek,
inputEvent,
event => {
const seek = event.currentTarget;
- // If it exists, use seekNext instead of "value" for consistency with tooltip time (#954)
- let seekTo = seek.getAttribute('seekNext');
- if (utils.is.empty(seekTo)) {
+
+ // If it exists, use seek-value instead of "value" for consistency with tooltip time (#954)
+ let seekTo = seek.getAttribute('seek-value');
+
+ if (is.empty(seekTo)) {
seekTo = seek.value;
}
- seek.removeAttribute('seekNext');
+
+ seek.removeAttribute('seek-value');
+
this.player.currentTime = seekTo / seek.max * this.player.duration;
},
'seek',
@@ -559,8 +587,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;
@@ -573,7 +601,7 @@ class Listeners {
}
// Volume
- on(
+ bind(
this.player.elements.inputs.volume,
inputEvent,
event => {
@@ -584,70 +612,75 @@ 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),
+ );
- // Toggle controls visibility based on mouse movement
- if (this.player.config.hideControls) {
- // Watch for cursor over controls so they don't hide when trying to interact
- on(this.player.elements.controls, 'mouseenter mouseleave', event => {
- this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter';
- });
+ // Update controls.hover state (used for ui.toggleControls to avoid hiding when interacting)
+ bind(this.player.elements.controls, 'mouseenter mouseleave', event => {
+ this.player.elements.controls.hover = !this.player.touch && event.type === 'mouseenter';
+ });
- // Watch for cursor over controls so they don't hide when trying to interact
- on(this.player.elements.controls, 'mousedown mouseup touchstart touchend touchcancel', event => {
- this.player.elements.controls.pressed = [
- 'mousedown',
- 'touchstart',
- ].includes(event.type);
- });
+ // Update controls.pressed state (used for ui.toggleControls to avoid hiding when interacting)
+ 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 => {
- this.player.toggleControls(event);
- });
- }
+ // Focus in/out on controls
+ bind(this.player.elements.controls, 'focusin focusout', event => {
+ const { config, elements, timers } = this.player;
+
+ // Skip transition to prevent focus from scrolling the parent element
+ toggleClass(elements.controls, config.classNames.noTransition, event.type === 'focusin');
+
+ // Toggle
+ ui.toggleControls.call(this.player, event.type === 'focusin');
+
+ // If focusin, hide again after delay
+ if (event.type === 'focusin') {
+ // Restore transition
+ setTimeout(() => {
+ toggleClass(elements.controls, config.classNames.noTransition, false);
+ }, 0);
+
+ // Delay a little more for keyboard users
+ const delay = this.touch ? 3000 : 4000;
+
+ // Clear timer
+ clearTimeout(timers.controls);
+ // Hide
+ timers.controls = setTimeout(() => ui.toggleControls.call(this.player, false), delay);
+ }
+ });
// Mouse wheel for volume
- on(
+ bind(
this.player.elements.inputs.volume,
'wheel',
event => {
// Detect "natural" scroll - suppored on OS X Safari only
// Other browsers on OS X will be inverted until support improves
const inverted = event.webkitDirectionInvertedFromDevice;
- const step = 1 / 50;
- let direction = 0;
-
- // Scroll down (or up on natural) to decrease
- if (event.deltaY < 0 || event.deltaX > 0) {
- if (inverted) {
- this.player.decreaseVolume(step);
- direction = -1;
- } else {
- this.player.increaseVolume(step);
- direction = 1;
- }
- }
- // Scroll up (or down on natural) to increase
- if (event.deltaY > 0 || event.deltaX < 0) {
- if (inverted) {
- this.player.increaseVolume(step);
- direction = 1;
- } else {
- this.player.decreaseVolume(step);
- direction = -1;
- }
- }
+ // Get delta from event. Invert if `inverted` is true
+ const [x, y] = [event.deltaX, -event.deltaY]
+ .map(value => inverted ? -value : value);
+
+ // Using the biggest delta, normalize to 1 or -1 (or 0 if no delta)
+ const direction = Math.sign(Math.abs(x) > Math.abs(y) ? x : y);
+
+ // Change the volume by 2%
+ this.player.increaseVolume(direction / 50);
// Don't break page scrolling at max and min
- if ((direction === 1 && this.player.media.volume < 1) || (direction === -1 && this.player.media.volume > 0)) {
+ const { volume } = this.player.media;
+ if ((direction === 1 && volume < 1) || (direction === -1 && volume > 0)) {
event.preventDefault();
}
},
@@ -655,11 +688,6 @@ class Listeners {
false,
);
}
-
- // Reset on destroy
- clear() {
- this.global(false);
- }
}
export default Listeners;
diff --git a/src/js/media.js b/src/js/media.js
index f10bea1f..eb37d441 100644
--- a/src/js/media.js
+++ b/src/js/media.js
@@ -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);
}
},
};
diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js
index 0246e221..856772d5 100644
--- a/src/js/plugins/ads.js
+++ b/src/js/plugins/ads.js
@@ -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,9 @@ class Ads {
}
get enabled() {
- return this.player.isVideo && this.player.config.ads.enabled && !utils.is.empty(this.publisherId);
+ return (
+ this.player.isHTML5 && this.player.isVideo && this.player.config.ads.enabled && !is.empty(this.publisherId)
+ );
}
/**
@@ -53,9 +60,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 +109,7 @@ class Ads {
const base = 'https://go.aniview.com/api/adserver6/vast/';
- return `${base}?${utils.buildUrlParams(params)}`;
+ return `${base}?${buildUrlParams(params)}`;
}
/**
@@ -116,7 +122,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 +152,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 +194,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 +222,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 +276,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 +403,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 +540,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 +556,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 +587,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);
diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js
index 0ceb89e5..09339229 100644
--- a/src/js/plugins/vimeo.js
+++ b/src/js/plugins/vimeo.js
@@ -5,20 +5,57 @@
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) {
+ if (play && !this.embed.hasPlayed) {
+ this.embed.hasPlayed = true;
+ }
+ if (this.media.paused === play) {
+ this.media.paused = !play;
+ 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);
})
@@ -33,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) {
@@ -62,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;
}
@@ -99,11 +139,8 @@ const vimeo = {
// Get original image
url.pathname = `${url.pathname.split('_')[0]}.jpg`;
- // Set attribute
- player.media.setAttribute('poster', url.href);
-
- // Update
- ui.setPoster.call(player);
+ // Set and show poster
+ ui.setPoster.call(player, url.href).catch(() => {});
});
// Setup instance
@@ -123,15 +160,13 @@ const vimeo = {
// Create a faux HTML5 API using the Vimeo API
player.media.play = () => {
- player.embed.play().then(() => {
- player.media.paused = false;
- });
+ assurePlaybackState.call(player, true);
+ return player.embed.play();
};
player.media.pause = () => {
- player.embed.pause().then(() => {
- player.media.paused = true;
- });
+ assurePlaybackState.call(player, false);
+ return player.embed.pause();
};
player.media.stop = () => {
@@ -146,25 +181,27 @@ const vimeo = {
return currentTime;
},
set(time) {
- // Get current paused state
- // Vimeo will automatically play on seek
- const { paused } = player.media;
-
- // Set seeking flag
- player.media.seeking = true;
-
- // Trigger seeking
- utils.dispatchEvent.call(player, player.media, 'seeking');
-
- // Seek after events
- player.embed.setCurrentTime(time).catch(() => {
- // Do nothing
- });
-
- // Restore pause state
- if (paused) {
- player.pause();
- }
+ // Vimeo will automatically play on seek if the video hasn't been played before
+
+ // Get current paused state and volume etc
+ const { embed, media, paused, volume } = player;
+ const restorePause = paused && !embed.hasPlayed;
+
+ // Set seeking state and trigger event
+ media.seeking = true;
+ triggerEvent.call(player, media, 'seeking');
+
+ // If paused, mute until seek is complete
+ Promise.resolve(restorePause && embed.setVolume(0))
+ // Seek
+ .then(() => embed.setCurrentTime(time))
+ // Restore paused
+ .then(() => restorePause && embed.pause())
+ // Restore volume
+ .then(() => restorePause && embed.setVolume(volume))
+ .catch(() => {
+ // Do nothing
+ });
},
});
@@ -179,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)
@@ -199,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');
});
},
});
@@ -211,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');
});
},
});
@@ -227,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;
@@ -260,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);
});
@@ -282,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
@@ -297,18 +331,21 @@ const vimeo = {
captions.setup.call(player);
});
- player.embed.on('cuechange', data => {
- let cue = null;
-
- if (data.cues.length) {
- cue = utils.stripHTML(data.cues[0].text);
- }
-
- captions.setText.call(player, cue);
+ player.embed.on('cuechange', ({ cues = [] }) => {
+ const strippedCues = cues.map(cue => stripHTML(cue.text));
+ captions.updateCues.call(player, strippedCues);
});
player.embed.on('loaded', () => {
- if (utils.is.element(player.embed.element) && player.supported.ui) {
+ // Assure state and events are updated on autoplay
+ player.embed.getPaused().then(paused => {
+ assurePlaybackState.call(player, !paused);
+ if (!paused) {
+ triggerEvent.call(player, player.media, 'playing');
+ }
+ });
+
+ if (is.element(player.embed.element) && player.supported.ui) {
const frame = player.embed.element;
// Fix keyboard focus issues
@@ -318,32 +355,27 @@ const vimeo = {
});
player.embed.on('play', () => {
- // Only fire play if paused before
- if (player.media.paused) {
- utils.dispatchEvent.call(player, player.media, 'play');
- }
- player.media.paused = false;
- utils.dispatchEvent.call(player, player.media, 'playing');
+ assurePlaybackState.call(player, true);
+ triggerEvent.call(player, player.media, 'playing');
});
player.embed.on('pause', () => {
- player.media.paused = true;
- utils.dispatchEvent.call(player, player.media, 'pause');
+ assurePlaybackState.call(player, false);
});
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
@@ -351,25 +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');
- utils.dispatchEvent.call(player, player.media, 'play');
+ 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
diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js
index 4fde9319..94ab6dfa 100644
--- a/src/js/plugins/youtube.js
+++ b/src/js/plugins/youtube.js
@@ -4,80 +4,81 @@
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;
-
- case 2160:
- return 'hd2160';
-
- 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';
+ const qualities = {
+ hd2160: 2160,
+ hd1440: 1440,
+ hd1080: 1080,
+ hd720: 720,
+ large: 480,
+ medium: 360,
+ small: 240,
+ tiny: 144,
+ };
+
+ const entry = Object.entries(qualities).find(entry => entry.includes(input));
+
+ 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)
+function assurePlaybackState(play) {
+ if (play && !this.embed.hasPlayed) {
+ this.embed.hasPlayed = true;
+ }
+ if (this.media.paused === play) {
+ this.media.paused = !play;
+ 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);
});
@@ -104,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;
@@ -116,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);
}
@@ -143,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;
}
@@ -151,18 +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);
-
- // Set poster image
- player.media.setAttribute('poster', utils.format(player.config.urls.youtube.poster, videoId));
+ const videoId = parseId(source);
+ const id = generateId(player.provider);
+
+ // 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)
+ 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
@@ -188,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,
});
},
@@ -241,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
@@ -252,10 +247,12 @@ const youtube = {
// Create a faux HTML5 API using the YouTube API
player.media.play = () => {
+ assurePlaybackState.call(player, true);
instance.playVideo();
};
player.media.pause = () => {
+ assurePlaybackState.call(player, false);
instance.pauseVideo();
};
@@ -273,22 +270,17 @@ const youtube = {
return Number(instance.getCurrentTime());
},
set(time) {
- // Vimeo will automatically play on seek
- const { paused } = player.media;
+ // If paused and never played, mute audio preventively (YouTube starts playing on seek if the video hasn't been played yet).
+ if (player.paused && !player.embed.hasPlayed) {
+ player.embed.mute();
+ }
- // Set seeking flag
+ // Set seeking state and trigger event
player.media.seeking = true;
-
- // Trigger seeking
- utils.dispatchEvent.call(player, player.media, 'seeking');
+ triggerEvent.call(player, player.media, 'seeking');
// Seek after events sent
instance.seekTo(time);
-
- // Restore pause state
- if (paused) {
- player.pause();
- }
},
});
@@ -308,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));
},
});
@@ -329,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');
},
});
@@ -340,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');
},
});
@@ -369,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);
@@ -382,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
@@ -393,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);
@@ -407,6 +391,14 @@ const youtube = {
// Reset timer
clearInterval(player.timers.playing);
+ const seeked = player.media.seeking && [1, 2].includes(event.data);
+
+ if (seeked) {
+ // Unset seeking and fire seeked event
+ player.media.seeking = false;
+ triggerEvent.call(player, player.media, 'seeked');
+ }
+
// Handle events
// -1 Unstarted
// 0 Ended
@@ -417,16 +409,16 @@ 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;
case 0:
- player.media.paused = true;
+ assurePlaybackState.call(player, false);
// YouTube doesn't support loop for a single video, so mimick it.
if (player.media.loop) {
@@ -434,48 +426,48 @@ const youtube = {
instance.stopVideo();
instance.playVideo();
} else {
- utils.dispatchEvent.call(player, player.media, 'ended');
+ triggerEvent.call(player, player.media, 'ended');
}
break;
case 1:
- // If we were seeking, fire seeked event
- if (player.media.seeking) {
- utils.dispatchEvent.call(player, player.media, 'seeked');
- }
- player.media.seeking = false;
-
- // Only fire play if paused before
- if (player.media.paused) {
- utils.dispatchEvent.call(player, player.media, 'play');
- }
- player.media.paused = false;
-
- utils.dispatchEvent.call(player, player.media, 'playing');
-
- // Poll to get playback progress
- player.timers.playing = setInterval(() => {
- utils.dispatchEvent.call(player, player.media, 'timeupdate');
- }, 50);
-
- // Check duration again due to YouTube bug
- // https://github.com/sampotts/plyr/issues/374
- // https://code.google.com/p/gdata-issues/issues/detail?id=8690
- if (player.media.duration !== instance.getDuration()) {
- player.media.duration = instance.getDuration();
- utils.dispatchEvent.call(player, player.media, 'durationchange');
+ // Restore paused state (YouTube starts playing on seek if the video hasn't been played yet)
+ if (player.media.paused && !player.embed.hasPlayed) {
+ player.media.pause();
+ } else {
+ assurePlaybackState.call(player, true);
+
+ triggerEvent.call(player, player.media, 'playing');
+
+ // Poll to get playback progress
+ player.timers.playing = setInterval(() => {
+ triggerEvent.call(player, player.media, 'timeupdate');
+ }, 50);
+
+ // Check duration again due to YouTube bug
+ // https://github.com/sampotts/plyr/issues/374
+ // https://code.google.com/p/gdata-issues/issues/detail?id=8690
+ if (player.media.duration !== instance.getDuration()) {
+ player.media.duration = instance.getDuration();
+ triggerEvent.call(player, player.media, 'durationchange');
+ }
+
+ // Get quality
+ controls.setQualityMenu.call(
+ player,
+ mapQualityUnits(instance.getAvailableQualityLevels()),
+ );
}
- // Get quality
- controls.setQualityMenu.call(player, mapQualityUnits(instance.getAvailableQualityLevels()));
-
break;
case 2:
- player.media.paused = true;
-
- utils.dispatchEvent.call(player, player.media, 'pause');
+ // Restore audio (YouTube starts playing on seek if the video hasn't been played yet)
+ if (!player.muted) {
+ player.embed.unMute();
+ }
+ assurePlaybackState.call(player, false);
break;
@@ -483,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,
});
},
diff --git a/src/js/plyr.js b/src/js/plyr.js
index bed09827..374251e6 100644
--- a/src/js/plyr.js
+++ b/src/js/plyr.js
@@ -1,14 +1,15 @@
// ==========================================================================
// Plyr
-// plyr.js v3.3.7
+// plyr.js v3.3.15
// https://github.com/sampotts/plyr
// License: The MIT License (MIT)
// ==========================================================================
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,
@@ -84,7 +90,8 @@ class Plyr {
// Captions
this.captions = {
active: null,
- currentTrack: null,
+ currentTrack: -1,
+ meta: new WeakMap(),
};
// Fullscreen
@@ -96,7 +103,6 @@ class Plyr {
this.options = {
speed: [],
quality: [],
- captions: [],
};
// Debugging
@@ -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 (truthy.includes(params.autoplay)) {
+ if (url.searchParams.length) {
+ const truthy = ['1', 'true'];
+
+ 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,14 +258,11 @@ 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);
}
- // Allow focus to be captured
- this.elements.container.setAttribute('tabindex', 0);
-
// Add style hook
ui.addStyleHook.call(this);
@@ -271,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}`);
});
}
@@ -330,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;
}
@@ -342,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;
}
@@ -383,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();
@@ -399,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();
}
}
@@ -416,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);
}
/**
@@ -424,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);
}
/**
@@ -432,21 +432,16 @@ class Plyr {
* @param {number} input - where to seek to in seconds. Defaults to 0 (the start)
*/
set currentTime(input) {
- let targetTime = 0;
-
- if (utils.is.number(input)) {
- targetTime = input;
+ // Bail if media duration isn't available yet
+ if (!this.duration) {
+ return;
}
- // Normalise targetTime
- if (targetTime < 0) {
- targetTime = 0;
- } else if (targetTime > this.duration) {
- targetTime = this.duration;
- }
+ // Validate input
+ const inputIsValid = is.number(input) && input > 0;
// Set
- this.media.currentTime = targetTime;
+ this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;
// Logging
this.debug.log(`Seeking to ${this.currentTime} seconds`);
@@ -466,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;
}
@@ -494,11 +489,11 @@ class Plyr {
// Faux duration set via config
const fauxDuration = parseFloat(this.config.duration);
- // True duration
- const realDuration = this.media ? Number(this.media.duration) : 0;
+ // Media duration can be NaN before the media has loaded
+ const duration = (this.media || {}).duration || 0;
- // If custom duration is funky, use regular duration
- return !Number.isNaN(fauxDuration) ? fauxDuration : realDuration;
+ // If config duration is funky, use regular duration
+ return fauxDuration || duration;
}
/**
@@ -510,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);
}
@@ -540,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;
}
}
@@ -558,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 : 0);
}
/**
@@ -566,8 +561,7 @@ class Plyr {
* @param {boolean} step - How much to decrease by (between 0 and 1)
*/
decreaseVolume(step) {
- const volume = this.media.muted ? 0 : this.volume;
- this.volume = volume - (utils.is.number(step) ? step : 1);
+ this.increaseVolume(-step);
}
/**
@@ -578,12 +572,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;
}
@@ -629,15 +623,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;
}
@@ -674,36 +668,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 (!options.length) {
+ return;
}
- if (!utils.is.number(quality) || quality === 0) {
- quality = this.storage.get('quality');
- }
+ let quality = [
+ !is.empty(input) && Number(input),
+ this.storage.get('quality'),
+ config.selected,
+ config.default,
+ ].find(is.number);
- if (!utils.is.number(quality)) {
- quality = this.config.quality.selected;
+ if (!options.includes(quality)) {
+ const value = closest(options, quality);
+ this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);
+ quality = value;
}
- if (!utils.is.number(quality)) {
- quality = this.config.quality.default;
- }
-
- if (!this.options.quality.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;
- }
+ // 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;
@@ -722,7 +711,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;
@@ -802,10 +791,7 @@ class Plyr {
return;
}
- if (utils.is.string(input)) {
- this.media.setAttribute('poster', input);
- ui.setPoster.call(this);
- }
+ ui.setPoster.call(this, input, false).catch(() => {});
}
/**
@@ -824,7 +810,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;
}
@@ -840,88 +826,39 @@ class Plyr {
* @param {boolean} input - Whether to enable captions
*/
toggleCaptions(input) {
- // If there's no full support
- if (!this.supported.ui) {
- return;
- }
-
- // If the method is called without parameter, toggle based on current value
- const show = utils.is.boolean(input) ? input : !this.elements.container.classList.contains(this.config.classNames.captions.active);
-
- // Nothing to change...
- if (this.captions.active === show) {
- return;
- }
-
- // Set global
- this.captions.active = show;
-
- // Toggle state
- utils.toggleState(this.elements.buttons.captions, this.captions.active);
+ captions.toggle.call(this, input, false);
+ }
- // Add class hook
- utils.toggleClass(this.elements.container, this.config.classNames.captions.active, this.captions.active);
+ /**
+ * Set the caption track by index
+ * @param {number} - Caption index
+ */
+ set currentTrack(input) {
+ captions.set.call(this, input, false);
+ }
- // Trigger an event
- utils.dispatchEvent.call(this, this.media, this.captions.active ? 'captionsenabled' : 'captionsdisabled');
+ /**
+ * Get the current caption track index (-1 if disabled)
+ */
+ get currentTrack() {
+ const { toggled, currentTrack } = this.captions;
+ return toggled ? currentTrack : -1;
}
/**
- * Set the captions language
+ * Set the wanted language for captions
+ * Since tracks can be added later it won't update the actual caption track until there is a matching track
* @param {string} - Two character ISO language code (e.g. EN, FR, PT, etc)
*/
set language(input) {
- // Nothing specified
- if (!utils.is.string(input)) {
- return;
- }
-
- // If empty string is passed, assume disable captions
- if (utils.is.empty(input)) {
- this.toggleCaptions(false);
- return;
- }
-
- // Normalize
- const language = input.toLowerCase();
-
- // Check for support
- if (!this.options.captions.includes(language)) {
- this.debug.warn(`Unsupported language option: ${language}`);
- return;
- }
-
- // Ensure captions are enabled
- this.toggleCaptions(true);
-
- // Enabled only
- if (language === 'enabled') {
- return;
- }
-
- // If nothing to change, bail
- if (this.language === language) {
- return;
- }
-
- // Update config
- this.captions.language = language;
-
- // Clear caption
- captions.setText.call(this, null);
-
- // Update captions
- captions.setLanguage.call(this);
-
- // Trigger an event
- utils.dispatchEvent.call(this, this.media, 'languagechange');
+ captions.setLanguage.call(this, input, false);
}
/**
- * Get the current captions language
+ * Get the current track's language
*/
get language() {
- return this.captions.language;
+ return (captions.getCurrentTrack.call(this) || {}).language;
}
/**
@@ -941,7 +878,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);
@@ -971,119 +908,32 @@ class Plyr {
/**
* Toggle the player controls
- * @param {boolean} toggle - Whether to show the controls
+ * @param {boolean} [toggle] - Whether to show the controls
*/
toggleControls(toggle) {
- // We need controls of course...
- if (!utils.is.element(this.elements.controls)) {
- return;
- }
+ // Don't toggle if missing UI support or if it's audio
+ if (this.supported.ui && !this.isAudio) {
+ // Get state before change
+ const isHidden = hasClass(this.elements.container, this.config.classNames.hideControls);
- // Don't hide if no UI support or it's audio
- if (!this.supported.ui || this.isAudio) {
- return;
- }
-
- let delay = 0;
- let show = toggle;
- let isEnterFullscreen = false;
-
- // Get toggle state if not set
- if (!utils.is.boolean(toggle)) {
- if (utils.is.event(toggle)) {
- // Is the enter fullscreen event
- isEnterFullscreen = toggle.type === 'enterfullscreen';
-
- // Events that show the controls
- const showEvents = [
- 'touchstart',
- 'touchmove',
- 'mouseenter',
- 'mousemove',
- 'focusin',
- ];
-
- // Events that delay hiding
- const delayEvents = [
- 'touchmove',
- 'touchend',
- 'mousemove',
- ];
-
- // Whether to show controls
- show = showEvents.includes(toggle.type);
-
- // Delay hiding on move events
- if (delayEvents.includes(toggle.type)) {
- delay = 2000;
- }
-
- // Delay a little more for keyboard users
- if (!this.touch && toggle.type === 'focusin') {
- delay = 3000;
- utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, true);
- }
- } else {
- show = utils.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;
- // Clear timer on every call
- clearTimeout(this.timers.controls);
+ // Apply and get updated state
+ const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);
- // If the mouse is not over the controls, set a timeout to hide them
- if (show || this.paused || this.loading) {
- // Check if controls toggled
- const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, false);
-
- // Trigger event
- if (toggled) {
- utils.dispatchEvent.call(this, this.media, 'controlsshown');
+ // Close menu
+ if (hiding && this.config.controls.includes('settings') && !is.empty(this.config.settings)) {
+ controls.toggleMenu.call(this, false);
}
-
- // Always show controls when paused or if touch
- if (this.paused || this.loading) {
- return;
- }
-
- // Delay for hiding on touch
- if (this.touch) {
- delay = 3000;
+ // Trigger event on change
+ if (hiding !== isHidden) {
+ const eventName = hiding ? 'controlshidden' : 'controlsshown';
+ triggerEvent.call(this, this.media, eventName);
}
+ return !hiding;
}
-
- // If toggle is false or if we're playing (regardless of toggle),
- // then set the timer to hide the controls
- if (!show || this.playing) {
- this.timers.controls = setTimeout(() => {
- // We need controls of course...
- if (!utils.is.element(this.elements.controls)) {
- return;
- }
-
- // If the mouse is over the controls (and not entering fullscreen), bail
- if ((this.elements.controls.pressed || this.elements.controls.hover) && !isEnterFullscreen) {
- return;
- }
-
- // Restore transition behaviour
- if (!utils.hasClass(this.elements.container, this.config.classNames.hideControls)) {
- utils.toggleClass(this.elements.controls, this.config.classNames.noTransition, false);
- }
-
- // Set hideControls class
- const toggled = utils.toggleClass(this.elements.container, this.config.classNames.hideControls, this.config.hideControls);
-
- // Trigger event and close menu
- if (toggled) {
- utils.dispatchEvent.call(this, this.media, 'controlshidden');
-
- if (this.config.controls.includes('settings') && !utils.is.empty(this.config.settings)) {
- controls.toggleMenu.call(this, false);
- }
- }
- }, delay);
- }
+ return false;
}
/**
@@ -1092,16 +942,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);
}
/**
@@ -1127,10 +984,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;
@@ -1140,21 +997,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);
}
@@ -1172,50 +1029,37 @@ class Plyr {
// Stop playback
this.stop();
- // Type specific stuff
- switch (`${this.provider}:${this.type}`) {
- case 'html5:video':
- case 'html5:audio':
- // Clear timeout
- clearTimeout(this.timers.loading);
-
- // Restore native video controls
- ui.toggleNativeControls.call(this, true);
-
- // Clean up
- done();
-
- break;
-
- case 'youtube:video':
- // Clear timers
- clearInterval(this.timers.buffering);
- clearInterval(this.timers.playing);
-
- // Destroy YouTube API
- if (this.embed !== null && utils.is.function(this.embed.destroy)) {
- this.embed.destroy();
- }
-
- // Clean up
- done();
-
- break;
-
- case 'vimeo:video':
- // Destroy Vimeo API
- // then clean up (wait, to prevent postmessage errors)
- if (this.embed !== null) {
- this.embed.unload().then(done);
- }
-
- // Vimeo does not always return
- setTimeout(done, 200);
+ // Provider specific stuff
+ if (this.isHTML5) {
+ // Clear timeout
+ clearTimeout(this.timers.loading);
+
+ // Restore native video controls
+ ui.toggleNativeControls.call(this, true);
+
+ // Clean up
+ done();
+ } else if (this.isYouTube) {
+ // Clear timers
+ clearInterval(this.timers.buffering);
+ clearInterval(this.timers.playing);
+
+ // Destroy YouTube API
+ if (this.embed !== null && is.function(this.embed.destroy)) {
+ this.embed.destroy();
+ }
- break;
+ // Clean up
+ done();
+ } else if (this.isVimeo) {
+ // Destroy Vimeo API
+ // then clean up (wait, to prevent postmessage errors)
+ if (this.embed !== null) {
+ this.embed.unload().then(done);
+ }
- default:
- break;
+ // Vimeo does not always return
+ setTimeout(done, 200);
}
}
@@ -1243,7 +1087,7 @@ class Plyr {
* @param {string} [id] - Unique ID
*/
static loadSprite(url, id) {
- return utils.loadSprite(url, id);
+ return loadSprite(url, id);
}
/**
@@ -1254,15 +1098,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(i => utils.is.element(i));
+ } else if (is.array(selector)) {
+ targets = selector.filter(is.element);
}
- if (utils.is.empty(targets)) {
+ if (is.empty(targets)) {
return null;
}
@@ -1270,6 +1114,6 @@ class Plyr {
}
}
-Plyr.defaults = utils.cloneDeep(defaults);
+Plyr.defaults = cloneDeep(defaults);
export default Plyr;
diff --git a/src/js/plyr.polyfilled.js b/src/js/plyr.polyfilled.js
index 635dee63..505163e8 100644
--- a/src/js/plyr.polyfilled.js
+++ b/src/js/plyr.polyfilled.js
@@ -1,12 +1,13 @@
// ==========================================================================
// Plyr Polyfilled Build
-// plyr.js v3.3.7
+// plyr.js v3.3.15
// https://github.com/sampotts/plyr
// License: The MIT License (MIT)
// ==========================================================================
import 'babel-polyfill';
import 'custom-event-polyfill';
+import 'url-polyfill';
import Plyr from './plyr';
export default Plyr;
diff --git a/src/js/source.js b/src/js/source.js
index e9a2938e..8c9fdf44 100644
--- a/src/js/source.js
+++ b/src/js/source.js
@@ -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;
-
- // 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;
- }
+ const { sources, type } = input;
+ const [{ provider = providers.html5, src }] = sources;
+ const tagName = provider === 'html5' ? type : 'div';
+ const attributes = provider === 'html5' ? {} : { src };
+
+ 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
diff --git a/src/js/storage.js b/src/js/storage.js
index 5b914331..27fdad9f 100644
--- a/src/js/storage.js
+++ b/src/js/storage.js
@@ -2,7 +2,8 @@
// Plyr storage
// ==========================================================================
-import utils from './utils';
+import is from './utils/is';
+import { extend } from './utils/objects';
class Storage {
constructor(player) {
@@ -31,19 +32,19 @@ class Storage {
}
get(key) {
- if (!Storage.supported) {
+ if (!Storage.supported || !this.enabled) {
return null;
}
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));
diff --git a/src/js/support.js b/src/js/support.js
index 38212d9f..6395293f 100644
--- a/src/js/support.js
+++ b/src/js/support.js
@@ -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;
+ mime(inputType) {
+ const [mediaType] = inputType.split('/');
+ if (!this.isHTML5 || mediaType !== this.type) {
+ 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 {
- // 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) {
+ return Boolean(type && this.media.canPlayType(type).replace(/no/, ''));
+ } catch (err) {
return false;
}
-
- // If we got this far, we're stuffed
- return false;
},
// Check for textTracks support
textTracks: 'textTracks' in document.createElement('video'),
- // Check for passive event listener support
- // https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
- // https://www.youtube.com/watch?v=NPM6172J22g
- passiveListeners: (() => {
- // Test via a getter in the options object to see if the passive property is accessed
- let supported = false;
- try {
- const options = Object.defineProperty({}, 'passive', {
- get() {
- supported = true;
- return null;
- },
- });
- window.addEventListener('test', null, options);
- 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/
diff --git a/src/js/ui.js b/src/js/ui.js
index 8f3f6a77..34fe7e82 100644
--- a/src/js/ui.js
+++ b/src/js/ui.js
@@ -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);
@@ -55,8 +56,10 @@ const ui = {
// Remove native controls
ui.toggleNativeControls.call(this);
- // Captions
- captions.setup.call(this);
+ // Setup captions for HTML5
+ if (this.isHTML5) {
+ captions.setup.call(this);
+ }
// Reset volume
this.volume = null;
@@ -83,30 +86,42 @@ 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);
- // Set the poster image
- ui.setPoster.call(this);
+ // Assure the poster image is set, if the property was added before the element was created
+ if (this.poster) {
+ ui.setPoster.call(this, this.poster, false).catch(() => {});
+ }
+
+ // Manually set the duration if user has overridden it.
+ // The event listeners for it doesn't get called if preload is disabled (#701)
+ if (this.config.duration) {
+ controls.durationUpdate.call(this);
+ }
},
// Setup aria attribute for play and iframe title
@@ -115,108 +130,126 @@ 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}`;
-
- // Set container label
- this.elements.container.setAttribute('aria-label', this.config.title);
}
// If there's a play button, set label
- if (utils.is.nodeList(this.elements.buttons.play)) {
- Array.from(this.elements.buttons.play).forEach(button => {
- button.setAttribute('aria-label', label);
- });
- }
+ Array.from(this.elements.buttons.play || []).forEach(button => {
+ button.setAttribute('aria-label', label);
+ });
// Set iframe title
// https://github.com/sampotts/plyr/issues/124
if (this.isEmbed) {
- const iframe = utils.getElement.call(this, 'iframe');
+ 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));
}
},
- // Set the poster image
- setPoster() {
- if (!utils.is.element(this.elements.poster) || utils.is.empty(this.poster)) {
- return;
+ // Toggle poster
+ togglePoster(enable) {
+ toggleClass(this.elements.container, this.config.classNames.posterEnabled, enable);
+ },
+
+ // Set the poster image (async)
+ // 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'));
}
- // Set the inline style
- const posters = this.poster.split(',');
- this.elements.poster.style.backgroundImage = posters.map(p => `url('${p}')`).join(',');
+ // Set property synchronously to respect the call order
+ this.media.setAttribute('poster', poster);
+
+ // 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 ARIA state
- utils.toggleState(this.elements.buttons.play, this.playing);
+ // Set state
+ Array.from(this.elements.buttons.play || []).forEach(target => {
+ target.pressed = this.playing;
+ });
// Only update controls on non timeupdate events
- if (utils.is.event(event) && event.type === 'timeupdate') {
+ if (is.event(event) && event.type === 'timeupdate') {
return;
}
// Toggle controls
- this.toggleControls(!this.playing);
+ ui.toggleControls.call(this);
},
// 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);
// Timer to prevent flicker when seeking
this.timers.loading = setTimeout(() => {
- // Toggle container class hook
- utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
+ // Update progress bar loading class state
+ toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
- // Show controls if loading, hide if done
- this.toggleControls(this.loading);
+ // Update controls visibility
+ ui.toggleControls.call(this);
}, this.loading ? 250 : 0);
},
- // Check if media failed to load
- checkFailed() {
- // https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/networkState
- this.failed = this.media.networkState === 3;
+ // Toggle controls based on state and `force` argument
+ toggleControls(force) {
+ const { controls } = this.elements;
- if (this.failed) {
- utils.toggleClass(this.elements.container, this.config.classNames.loading, false);
- utils.toggleClass(this.elements.container, this.config.classNames.error, true);
+ if (controls && this.config.hideControls) {
+ // Show controls if force, loading, paused, or button interaction, otherwise hide
+ this.toggleControls(Boolean(force || this.loading || this.paused || controls.pressed || controls.hover));
}
-
- // Clear timer
- clearTimeout(this.timers.failed);
-
- // Timer to prevent flicker when seeking
- this.timers.loading = setTimeout(() => {
- // Toggle container class hook
- utils.toggleClass(this.elements.container, this.config.classNames.loading, this.loading);
-
- // Show controls if loading, hide if done
- this.toggleControls(this.loading);
- }, this.loading ? 250 : 0);
},
};
diff --git a/src/js/utils.js b/src/js/utils.js
deleted file mode 100644
index a58d8555..00000000
--- a/src/js/utils.js
+++ /dev/null
@@ -1,847 +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 this.getConstructor(input) === Object;
- },
- number(input) {
- return this.getConstructor(input) === Number && !Number.isNaN(input);
- },
- string(input) {
- return this.getConstructor(input) === String;
- },
- boolean(input) {
- return this.getConstructor(input) === Boolean;
- },
- function(input) {
- return this.getConstructor(input) === Function;
- },
- array(input) {
- return !this.nullOrUndefined(input) && Array.isArray(input);
- },
- weakMap(input) {
- return this.instanceof(input, WeakMap);
- },
- nodeList(input) {
- return this.instanceof(input, NodeList);
- },
- element(input) {
- return this.instanceof(input, Element);
- },
- textNode(input) {
- return this.getConstructor(input) === Text;
- },
- event(input) {
- return this.instanceof(input, Event);
- },
- cue(input) {
- return this.instanceof(input, TextTrackCue) || this.instanceof(input, VTTCue);
- },
- track(input) {
- return this.instanceof(input, TextTrack) || (!this.nullOrUndefined(input) && this.string(input.kind));
- },
- url(input) {
- return !this.nullOrUndefined(input) && /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(input);
- },
- nullOrUndefined(input) {
- return input === null || typeof input === 'undefined';
- },
- empty(input) {
- return (
- this.nullOrUndefined(input) ||
- ((this.string(input) || this.array(input) || this.nodeList(input)) && !input.length) ||
- (this.object(input) && !Object.keys(input).length)
- );
- },
- instanceof(input, constructor) {
- return Boolean(input && constructor && input instanceof constructor);
- },
- getConstructor(input) {
- return !this.nullOrUndefined(input) ? input.constructor : null;
- },
- },
-
- // Unfortunately, due to mixed support, UA sniffing is required
- getBrowser() {
- return {
- isIE: /* @cc_on!@ */ false || !!document.documentMode,
- isWebkit: 'WebkitAppearance' in document.documentElement.style && !/Edge/.test(navigator.userAgent),
- isIPhone: /(iPhone|iPod)/gi.test(navigator.platform),
- isIos: /(iPad|iPhone|iPod)/gi.test(navigator.platform),
- };
- },
-
- // Fetch wrapper
- // Using XHR to avoid issues with older browsers
- fetch(url, responseType = 'text') {
- return new Promise((resolve, reject) => {
- try {
- const request = new XMLHttpRequest();
-
- // Check for CORS support
- if (!('withCredentials' in request)) {
- return;
- }
-
- request.addEventListener('load', () => {
- if (responseType === 'text') {
- try {
- resolve(JSON.parse(request.responseText));
- } catch (e) {
- resolve(request.responseText);
- }
- } else {
- resolve(request.response);
- }
- });
-
- request.addEventListener('error', () => {
- throw new Error(request.statusText);
- });
-
- request.open('GET', url, true);
-
- // Set the required response type
- request.responseType = responseType;
-
- request.send();
- } catch (e) {
- reject(e);
- }
- });
- },
-
- // Load an external script
- loadScript(url) {
- return new Promise((resolve, reject) => {
- 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.querySelectorAll(`#${id}`).length;
-
- function injectSprite(data) {
- // Check again incase of race condition
- if (hasId && exists()) {
- return;
- }
-
- // Inject content
- this.innerHTML = data;
-
- // Inject the SVG to the body
- document.body.insertBefore(this, document.body.childNodes[0]);
- }
-
- // 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);
- injectSprite.call(container, data.content);
- return;
- }
- }
-
- // 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,
- }),
- );
- }
-
- injectSprite.call(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');
- }
- },
-
- // Toggle class on an element
- toggleClass(element, className, toggle) {
- if (utils.is.element(element)) {
- const contains = element.classList.contains(className);
-
- element.classList[toggle ? 'add' : 'remove'](className);
-
- return (toggle && !contains) || (!toggle && contains);
- }
-
- return null;
- },
-
- // Has class name
- hasClass(element, className) {
- return utils.is.element(element) && element.classList.contains(className);
- },
-
- // 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);
- },
-
- // Toggle aria-pressed state on a toggle button
- // http://www.ssbbartgroup.com/blog/how-not-to-misuse-aria-states-properties-and-roles
- toggleState(element, input) {
- // If multiple elements passed
- if (utils.is.array(element) || utils.is.nodeList(element)) {
- Array.from(element).forEach(target => utils.toggleState(target, input));
- return;
- }
-
- // Bail if no target
- if (!utils.is.element(element)) {
- return;
- }
-
- // Get state
- const pressed = element.getAttribute('aria-pressed') === 'true';
- const state = utils.is.boolean(input) ? input : !pressed;
-
- // Set the attribute on target
- element.setAttribute('aria-pressed', state);
- },
-
- // 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 this.formatTime(null, displayHours, inverted);
- }
-
- // Format time component to add leading zero
- const format = value => `0${value}`.slice(-2);
-
- // Breakdown to hours, mins, secs
- let hours = this.getHours(time);
- const mins = this.getMinutes(time);
- const secs = this.getSeconds(time);
-
- // Do we need to display hours?
- if (displayHours || hours > 0) {
- hours = `${hours}:`;
- } else {
- hours = '';
- }
-
- // Render
- return `${inverted ? '-' : ''}${hours}${format(mins)}:${format(secs)}`;
- },
-
- // 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 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 } = this.parseUrl(input));
- }
-
- if (this.is.empty(search)) {
- return null;
- }
-
- const hashes = search.slice(search.indexOf('?') + 1).split('&');
-
- return hashes.reduce((params, hash) => {
- const [
- key,
- val,
- ] = hash.split('=');
-
- return Object.assign(params, { [key]: decodeURIComponent(val) });
- }, {});
- },
-
- // Convert object to URL parameters
- buildUrlParams(input) {
- if (!utils.is.object(input)) {
- return '';
- }
-
- return Object.keys(input)
- .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(input[key])}`)
- .join('&');
- },
-
- // Remove HTML from a string
- stripHTML(source) {
- const fragment = document.createDocumentFragment();
- const element = document.createElement('div');
- fragment.appendChild(element);
- element.innerHTML = source;
- return fragment.firstChild.innerText;
- },
-
- // Get aspect ratio for dimensions
- getAspectRatio(width, height) {
- const getRatio = (w, h) => (h === 0 ? w : getRatio(h, w % h));
- const ratio = getRatio(width, height);
- return `${width / ratio}:${height / ratio}`;
- },
-
- // Get the transition end event
- get transitionEndEvent() {
- const element = document.createElement('span');
-
- const events = {
- WebkitTransition: 'webkitTransitionEnd',
- MozTransition: 'transitionend',
- OTransition: 'oTransitionEnd otransitionend',
- transition: 'transitionend',
- };
-
- const type = Object.keys(events).find(event => element.style[event] !== undefined);
-
- return utils.is.string(type) ? events[type] : false;
- },
-
- // Force repaint of element
- repaint(element) {
- setTimeout(() => {
- utils.toggleHidden(element, true);
- element.offsetHeight; // eslint-disable-line
- utils.toggleHidden(element, false);
- }, 0);
- },
-};
-
-export default utils;
diff --git a/src/js/utils/animation.js b/src/js/utils/animation.js
new file mode 100644
index 00000000..95e39f03
--- /dev/null
+++ b/src/js/utils/animation.js
@@ -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);
+}
diff --git a/src/js/utils/arrays.js b/src/js/utils/arrays.js
new file mode 100644
index 00000000..69ef242c
--- /dev/null
+++ b/src/js/utils/arrays.js
@@ -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));
+}
diff --git a/src/js/utils/browser.js b/src/js/utils/browser.js
new file mode 100644
index 00000000..d574f683
--- /dev/null
+++ b/src/js/utils/browser.js
@@ -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;
diff --git a/src/js/utils/elements.js b/src/js/utils/elements.js
new file mode 100644
index 00000000..69e4d46c
--- /dev/null
+++ b/src/js/utils/elements.js
@@ -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);
+}
diff --git a/src/js/utils/events.js b/src/js/utils/events.js
new file mode 100644
index 00000000..9f734f04
--- /dev/null
+++ b/src/js/utils/events.js
@@ -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(() => {});
+}
diff --git a/src/js/utils/fetch.js b/src/js/utils/fetch.js
new file mode 100644
index 00000000..ee33ea7c
--- /dev/null
+++ b/src/js/utils/fetch.js
@@ -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.status);
+ });
+
+ request.open('GET', url, true);
+
+ // Set the required response type
+ request.responseType = responseType;
+
+ request.send();
+ } catch (e) {
+ reject(e);
+ }
+ });
+}
diff --git a/src/js/utils/is.js b/src/js/utils/is.js
new file mode 100644
index 00000000..b4760da4
--- /dev/null
+++ b/src/js/utils/is.js
@@ -0,0 +1,63 @@
+// ==========================================================================
+// 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 isNullOrUndefined = input => input === null || typeof input === 'undefined';
+const isObject = input => getConstructor(input) === Object;
+const isNumber = input => getConstructor(input) === Number && !Number.isNaN(input);
+const isString = input => getConstructor(input) === String;
+const isBoolean = input => getConstructor(input) === Boolean;
+const isFunction = input => getConstructor(input) === Function;
+const isArray = input => Array.isArray(input);
+const isWeakMap = input => instanceOf(input, WeakMap);
+const isNodeList = input => instanceOf(input, NodeList);
+const isElement = input => instanceOf(input, Element);
+const isTextNode = input => getConstructor(input) === Text;
+const isEvent = input => instanceOf(input, Event);
+const isCue = input => instanceOf(input, window.TextTrackCue) || instanceOf(input, window.VTTCue);
+const isTrack = input => instanceOf(input, TextTrack) || (!isNullOrUndefined(input) && isString(input.kind));
+
+const isEmpty = input =>
+ isNullOrUndefined(input) ||
+ ((isString(input) || isArray(input) || isNodeList(input)) && !input.length) ||
+ (isObject(input) && !Object.keys(input).length);
+
+const isUrl = input => {
+ // Accept a URL object
+ if (instanceOf(input, window.URL)) {
+ return true;
+ }
+
+ // Add the protocol if required
+ let string = input;
+ if (!input.startsWith('http://') || !input.startsWith('https://')) {
+ string = `http://${input}`;
+ }
+
+ try {
+ return !isEmpty(new URL(string).hostname);
+ } catch (e) {
+ return false;
+ }
+};
+
+export default {
+ nullOrUndefined: isNullOrUndefined,
+ object: isObject,
+ number: isNumber,
+ string: isString,
+ boolean: isBoolean,
+ function: isFunction,
+ array: isArray,
+ weakMap: isWeakMap,
+ nodeList: isNodeList,
+ element: isElement,
+ textNode: isTextNode,
+ event: isEvent,
+ cue: isCue,
+ track: isTrack,
+ url: isUrl,
+ empty: isEmpty,
+};
diff --git a/src/js/utils/loadImage.js b/src/js/utils/loadImage.js
new file mode 100644
index 00000000..8acd2496
--- /dev/null
+++ b/src/js/utils/loadImage.js
@@ -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 });
+ });
+}
diff --git a/src/js/utils/loadScript.js b/src/js/utils/loadScript.js
new file mode 100644
index 00000000..81ae36f4
--- /dev/null
+++ b/src/js/utils/loadScript.js
@@ -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,
+ });
+ });
+}
diff --git a/src/js/utils/loadSprite.js b/src/js/utils/loadSprite.js
new file mode 100644
index 00000000..f05795f8
--- /dev/null
+++ b/src/js/utils/loadSprite.js
@@ -0,0 +1,76 @@
+// ==========================================================================
+// Sprite loader
+// ==========================================================================
+
+import Storage from './../storage';
+import fetch from './fetch';
+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(() => {});
+ }
+}
diff --git a/src/js/utils/objects.js b/src/js/utils/objects.js
new file mode 100644
index 00000000..225bb459
--- /dev/null
+++ b/src/js/utils/objects.js
@@ -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);
+}
diff --git a/src/js/utils/strings.js b/src/js/utils/strings.js
new file mode 100644
index 00000000..c872498c
--- /dev/null
+++ b/src/js/utils/strings.js
@@ -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;
+}
diff --git a/src/js/utils/time.js b/src/js/utils/time.js
new file mode 100644
index 00000000..7c9860fd
--- /dev/null
+++ b/src/js/utils/time.js
@@ -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)}`;
+}
diff --git a/src/js/utils/urls.js b/src/js/utils/urls.js
new file mode 100644
index 00000000..3ebe622e
--- /dev/null
+++ b/src/js/utils/urls.js
@@ -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;
+}
diff --git a/src/sass/components/captions.scss b/src/sass/components/captions.scss
index 9dfc2be8..8fce581a 100644
--- a/src/sass/components/captions.scss
+++ b/src/sass/components/captions.scss
@@ -21,7 +21,7 @@
transition: transform 0.4s ease-in-out;
width: 100%;
- span {
+ .plyr__caption {
background: $plyr-captions-bg;
border-radius: 2px;
box-decoration-break: clone;
diff --git a/src/sass/components/control.scss b/src/sass/components/control.scss
index 52716805..cfef1b3a 100644
--- a/src/sass/components/control.scss
+++ b/src/sass/components/control.scss
@@ -34,10 +34,10 @@
}
// Change icons on state change
-.plyr__control[aria-pressed='false'] .icon--pressed,
-.plyr__control[aria-pressed='true'] .icon--not-pressed,
-.plyr__control[aria-pressed='false'] .label--pressed,
-.plyr__control[aria-pressed='true'] .label--not-pressed {
+.plyr__control:not(.plyr__control--pressed) .icon--pressed,
+.plyr__control.plyr__control--pressed .icon--not-pressed,
+.plyr__control:not(.plyr__control--pressed) .label--pressed,
+.plyr__control.plyr__control--pressed .label--not-pressed {
display: none;
}
diff --git a/src/sass/components/poster.scss b/src/sass/components/poster.scss
index 92ab0fce..4bdb60d9 100644
--- a/src/sass/components/poster.scss
+++ b/src/sass/components/poster.scss
@@ -18,6 +18,6 @@
pointer-events: none;
}
-.plyr--stopped .plyr__poster {
+.plyr--stopped.plyr__poster-enabled .plyr__poster {
opacity: 1;
}
diff --git a/src/sass/components/progress.scss b/src/sass/components/progress.scss
index 60994f99..eddd32ab 100644
--- a/src/sass/components/progress.scss
+++ b/src/sass/components/progress.scss
@@ -5,16 +5,21 @@
.plyr__progress {
display: flex;
flex: 1;
- position: relative;
- margin-right: $plyr-range-thumb-height;
left: $plyr-range-thumb-height / 2;
+ margin-right: $plyr-range-thumb-height;
+ position: relative;
+
+ input[type='range'],
+ &__buffer {
+ margin-left: -($plyr-range-thumb-height / 2);
+ margin-right: -($plyr-range-thumb-height / 2);
+ // Offset the range thumb in order to be able to calculate the relative progress (#954)
+ width: calc(100% + #{$plyr-range-thumb-height});
+ }
input[type='range'] {
position: relative;
z-index: 2;
- // Offset the range thumb in order to be able to calculate the relative progress (#954)
- width: calc(100% + #{$plyr-range-thumb-height}) !important;
- margin: 0 -#{$plyr-range-thumb-height / 2} !important;
}
// Seek tooltip to show time
@@ -24,18 +29,17 @@
}
}
-.plyr__progress--buffer {
+.plyr__progress__buffer {
-webkit-appearance: none; /* stylelint-disable-line */
background: transparent;
border: 0;
border-radius: 100px;
height: $plyr-range-track-height;
left: 0;
- margin: -($plyr-range-track-height / 2) 0 0;
+ margin-top: -($plyr-range-track-height / 2);
padding: 0;
position: absolute;
top: 50%;
- width: 100%;
&::-webkit-progress-bar {
background: transparent;
@@ -63,17 +67,17 @@
}
}
-.plyr--video .plyr__progress--buffer {
+.plyr--video .plyr__progress__buffer {
box-shadow: 0 1px 1px rgba(#000, 0.15);
color: $plyr-video-progress-buffered-bg;
}
-.plyr--audio .plyr__progress--buffer {
+.plyr--audio .plyr__progress__buffer {
color: $plyr-audio-progress-buffered-bg;
}
// Loading state
-.plyr--loading .plyr__progress--buffer {
+.plyr--loading .plyr__progress__buffer {
animation: plyr-progress 1s linear infinite;
background-image: linear-gradient(
-45deg,
@@ -90,10 +94,10 @@
color: transparent;
}
-.plyr--video.plyr--loading .plyr__progress--buffer {
+.plyr--video.plyr--loading .plyr__progress__buffer {
background-color: $plyr-video-progress-buffered-bg;
}
-.plyr--audio.plyr--loading .plyr__progress--buffer {
+.plyr--audio.plyr--loading .plyr__progress__buffer {
background-color: $plyr-audio-progress-buffered-bg;
}
diff --git a/src/sass/plyr.scss b/src/sass/plyr.scss
index 65134331..3d824f7d 100644
--- a/src/sass/plyr.scss
+++ b/src/sass/plyr.scss
@@ -31,15 +31,14 @@
@import 'components/controls';
@import 'components/embed';
@import 'components/menus';
-@import 'components/progress';
-@import 'components/poster';
@import 'components/sliders';
+@import 'components/poster';
@import 'components/times';
@import 'components/tooltips';
@import 'components/video';
+@import 'components/progress';
@import 'components/volume';
-@import 'states/error';
@import 'states/fullscreen';
@import 'plugins/ads';
diff --git a/src/sass/states/error.scss b/src/sass/states/error.scss
deleted file mode 100644
index 64d05c7b..00000000
--- a/src/sass/states/error.scss
+++ /dev/null
@@ -1,25 +0,0 @@
-// --------------------------------------------------------------
-// Error state
-// --------------------------------------------------------------
-
-.plyr--has-error {
- pointer-events: none;
-
- &::after {
- align-items: center;
- background: rgba(#000, 90%);
- color: #fff;
- content: attr(data-plyr-error);
- display: flex;
- font-size: $plyr-font-size-base;
- height: 100%;
- justify-content: center;
- left: 0;
- position: absolute;
- text-align: center;
- text-shadow: 0 1px 1px rgba(#000, 10%);
- top: 0;
- width: 100%;
- z-index: 10;
- }
-}