This repository has been archived on 2026-01-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
plyr/src/js/plyr.js
T
Sam Potts aa3378fd73 v3.6.4 (#2089)
* force fullscreen events to trigger on plyr element (media element in iOS) and not fullscreen container

* Fixing "missing code in detail" for PlyrEvent type

When using typescript and listening for youtube statechange event, it is missing the code property definition inside the event (even though it is provided in the code).
By making events a map of key-value, we can add easily custom event type for specific event name. Since YouTube "statechange" event differs from the basic PlyrEvent, I added a new Event Type "PlyrStateChangeEvent" having a code property corresponding to a YoutubeState enum defined by the YouTube API documentation.
This pattern follows how addEventListener in the lib.dom.d.ts is defined.

* Update link to working dash.js demo (was broken)

* Fix PreviewThumbnailsOptions type

According to the docs, the `src` should also accept an array of strings.

* fix issue #1872

* Check if key is a string before attempt --plyr checking

* Fix for Slow loading videos not autoplaying

* Fix for Slow loading videos not autoplaying

* Network requests are not cancelled after the player is destroyed

* Fix for apect ratio problem when using Vimeo player on mobile devices (issue #1940)

* chore: update packages and linting

* Invoke custom listener on triggering fullscreen via double-click

* Fix volume when unmuting from volume 0

* adding a nice Svelte plugin that I found

* Add missing unit to calc in media query

* Assigning player's lastSeekTime on rewind/fast forward to prevent immediate controls hide on mobile

* Fix youtube not working when player is inside shadow dom

* v3.6.2

* ESLint to use common config

* add BitChute to users list

* Fix aspect ratio issue

* Revert noCookie change

* feat: demo radius tweaks

* fix: poster image shouldn’t receive click events

* chore: package updates

* chore: linting

* feat: custom controls option for embedded players

* Package upgrades

* ESLint to use common config

* Linting changes

* Update README.md

* chore: formatting

* fix: revert pointer events change for poster

* fix: hack for Safari 14 not repainting Vimeo embed on entering fullscreen

* fix: demo using custom controls for YouTube

* doc: Add STROLLÿN among the list of Plyr users

* Fixes #2005

* fix: overflowing volume slider

* chore: clean up CSS

* fix: hide poster when not using custom controls

* Package upgrades

* ESLint to use common config

* Linting changes

* chore: revert customControls default option (to prevent breaking change)

* docs: changelog for v3.6.3

* Remove unnecessary calc from media query (#2049)

* Enhance types (#1841)

* 🏷️(type) enhance QualityOptions type

Some optional properties in the QualityOptions were missing. The forced
and onChange property allwoing to use an external handler.

* ♻️(type) use Plyr.Provider for the readonly provider property

A type exists to define all available providers. This type isn't used in
the Plyr class definition and the same provider list is also defined.
This code is refactored to use the Plyr.Provider type

* 🏷️(type) add missing elements property in Plyr class

In Plyr class, you can access elements set in cache. This property is
missing in the class definition. The Plyr.Elements is for now
incomplete.

* FIX - object values for the providers must be used (#2053)

To make use of the provider configuration, the objects values must be used.

* Fix to work inside iframes. (#2069)

* Fix to work inside iframes.

Right now Plyr fails to load inside iframes because the selectors are not instances of Element (iframes have their own, separate globals). This is an alternative method to check isElement that will work inside iframes. This is battle-tested fallback code used before browsers supported HTMLElement.

* Update is.js

* Added --plyr-video-background for having control over the background of a video with alpha channel (webm) or a poster image with alpha channel. (#2076)

* Fix issue with not entering iosfullscreen of vimeo videos with playsi… (#2038)

* Fix issue with not entering iosfullscreen of vimeo videos with playsinline=true

* Use isVimeo-function instead of hardcoded value

Co-authored-by: Julian Frosch <julian.frosch@gmail.com>

* fix: use new syntax for iframe allow attribute

* Bump ini from 1.3.5 to 1.3.7 (#2044)

Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.7.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.7)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore: package updates

* chore: add @babel/plugin-proposal-class-properties

* fix: use bound arrow functions in classes

* chore: cleanup commented out code

* chore: release

Co-authored-by: Som Meaden <som@theprojectsomething.com>
Co-authored-by: akuma06 <demon.akuma06@gmail.com>
Co-authored-by: Jonathan Arbely <dev@jonathanarbely.de>
Co-authored-by: Takeshi <iwatakeshi@users.noreply.github.com>
Co-authored-by: Hex <hex@codeigniter.org.cn>
Co-authored-by: Syed Husain <syed.husain@appspace.com>
Co-authored-by: Danielh112 <Daniel@sbgsportssoftware.com>
Co-authored-by: Danil Stoyanov <d.stoyanov@corp.mail.ru>
Co-authored-by: Guru Prasad Srinivasa <gurupras@buffalo.edu>
Co-authored-by: Stephane Fortin Bouchard <stephane.f.bouchard@gmail.com>
Co-authored-by: Zev Averbach <zev@averba.ch>
Co-authored-by: Vincent Orback <hello@vincentorback.se>
Co-authored-by: trafium <trafium@gmail.com>
Co-authored-by: xansen <27698939+xansen@users.noreply.github.com>
Co-authored-by: zoomerdev <59863739+zoomerdev@users.noreply.github.com>
Co-authored-by: Mikaël Castellani <mikael.castellani@gmail.com>
Co-authored-by: dirkjf <d.j.faber@outlook.com>
Co-authored-by: Naomi <naomizuiverloon@gmail.com>
Co-authored-by: Manuel Raynaud <manu@raynaud.io>
Co-authored-by: syteknet-core <github.core@sytek.net>
Co-authored-by: Andre Gagnon <ajgagnon@uwalumni.com>
Co-authored-by: Nepomuk Leutschacher <864660+nepomuc@users.noreply.github.com>
Co-authored-by: Elias Saalmann <lordon@users.noreply.github.com>
Co-authored-by: Julian Frosch <julian.frosch@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-01-29 23:14:02 +11:00

1265 lines
31 KiB
JavaScript

// ==========================================================================
// Plyr
// plyr.js v3.6.3
// https://github.com/sampotts/plyr
// License: The MIT License (MIT)
// ==========================================================================
import captions from './captions';
import defaults from './config/defaults';
import { pip } from './config/states';
import { getProviderByUrl, providers, types } from './config/types';
import Console from './console';
import controls from './controls';
import Fullscreen from './fullscreen';
import html5 from './html5';
import Listeners from './listeners';
import media from './media';
import Ads from './plugins/ads';
import PreviewThumbnails from './plugins/preview-thumbnails';
import source from './source';
import Storage from './storage';
import support from './support';
import ui from './ui';
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/load-sprite';
import { clamp } from './utils/numbers';
import { cloneDeep, extend } from './utils/objects';
import { silencePromise } from './utils/promise';
import { getAspectRatio, reduceAspectRatio, setAspectRatio, validateRatio } from './utils/style';
import { parseUrl } from './utils/urls';
// Private properties
// TODO: Use a WeakMap for private globals
// const globals = new WeakMap();
// Plyr instance
class Plyr {
constructor(target, options) {
this.timers = {};
// State
this.ready = false;
this.loading = false;
this.failed = false;
// Touch device
this.touch = support.touch;
// Set the media element
this.media = target;
// String selector passed
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) || is.nodeList(this.media) || is.array(this.media)) {
// eslint-disable-next-line
this.media = this.media[0];
}
// Set config
this.config = extend(
{},
defaults,
Plyr.defaults,
options || {},
(() => {
try {
return JSON.parse(this.media.getAttribute('data-plyr-config'));
} catch (e) {
return {};
}
})(),
);
// Elements cache
this.elements = {
container: null,
fullscreen: null,
captions: null,
buttons: {},
display: {},
progress: {},
inputs: {},
settings: {
popup: null,
menu: null,
panels: {},
buttons: {},
},
};
// Captions
this.captions = {
active: null,
currentTrack: -1,
meta: new WeakMap(),
};
// Fullscreen
this.fullscreen = {
active: false,
};
// Options
this.options = {
speed: [],
quality: [],
};
// Debugging
// TODO: move to globals
this.debug = new Console(this.config.debug);
// Log config options and support
this.debug.log('Config', this.config);
this.debug.log('Support', support);
// We need an element to setup
if (is.nullOrUndefined(this.media) || !is.element(this.media)) {
this.debug.error('Setup failed: no suitable element passed');
return;
}
// Bail if the element is initialized
if (this.media.plyr) {
this.debug.warn('Target already setup');
return;
}
// Bail if not enabled
if (!this.config.enabled) {
this.debug.error('Setup failed: disabled by config');
return;
}
// Bail if disabled or no basic support
// You may want to disable certain UAs etc
if (!support.check().api) {
this.debug.error('Setup failed: no support');
return;
}
// Cache original element state for .destroy()
const clone = this.media.cloneNode(true);
clone.autoplay = false;
this.elements.original = clone;
// Set media type based on tag or data attribute
// Supported: video, audio, vimeo, youtube
const type = this.media.tagName.toLowerCase();
// Embed properties
let iframe = null;
let url = null;
// Different setup based on type
switch (type) {
case 'div':
// Find the frame
iframe = this.media.querySelector('iframe');
// <iframe> type
if (is.element(iframe)) {
// Detect provider
url = parseUrl(iframe.getAttribute('src'));
this.provider = getProviderByUrl(url.toString());
// Rework elements
this.elements.container = this.media;
this.media = iframe;
// Reset classname
this.elements.container.className = '';
// Get attributes from URL and set config
if (url.search.length) {
const truthy = ['1', 'true'];
if (truthy.includes(url.searchParams.get('autoplay'))) {
this.config.autoplay = true;
}
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(url.searchParams.get('playsinline'));
this.config.youtube.hl = url.searchParams.get('hl'); // TODO: Should this be setting language?
} else {
this.config.playsinline = true;
}
}
} else {
// <div> with attributes
this.provider = this.media.getAttribute(this.config.attributes.embed.provider);
// Remove attribute
this.media.removeAttribute(this.config.attributes.embed.provider);
}
// Unsupported or missing provider
if (is.empty(this.provider) || !Object.values(providers).includes(this.provider)) {
this.debug.error('Setup failed: Invalid provider');
return;
}
// Audio will come later for external providers
this.type = types.video;
break;
case 'video':
case 'audio':
this.type = type;
this.provider = providers.html5;
// Get config from attributes
if (this.media.hasAttribute('crossorigin')) {
this.config.crossorigin = true;
}
if (this.media.hasAttribute('autoplay')) {
this.config.autoplay = true;
}
if (this.media.hasAttribute('playsinline') || this.media.hasAttribute('webkit-playsinline')) {
this.config.playsinline = true;
}
if (this.media.hasAttribute('muted')) {
this.config.muted = true;
}
if (this.media.hasAttribute('loop')) {
this.config.loop.active = true;
}
break;
default:
this.debug.error('Setup failed: unsupported type');
return;
}
// Check for support again but with type
this.supported = support.check(this.type, this.provider, this.config.playsinline);
// If no support for even API, bail
if (!this.supported.api) {
this.debug.error('Setup failed: no support');
return;
}
this.eventListeners = [];
// Create listeners
this.listeners = new Listeners(this);
// Setup local storage for user settings
this.storage = new Storage(this);
// Store reference
this.media.plyr = this;
// Wrap media
if (!is.element(this.elements.container)) {
this.elements.container = createElement('div', { tabindex: 0 });
wrap(this.media, this.elements.container);
}
// Migrate custom properties from media to container (so they work 😉)
ui.migrateStyles.call(this);
// Add style hook
ui.addStyleHook.call(this);
// Setup media
media.setup.call(this);
// Listen for events if debugging
if (this.config.debug) {
on.call(this, this.elements.container, this.config.events.join(' '), (event) => {
this.debug.log(`event: ${event.type}`);
});
}
// Setup fullscreen
this.fullscreen = new Fullscreen(this);
// Setup interface
// If embed but not fully supported, build interface now to avoid flash of controls
if (this.isHTML5 || (this.isEmbed && !this.supported.ui)) {
ui.build.call(this);
}
// Container listeners
this.listeners.container();
// Global listeners
this.listeners.global();
// Setup ads if provided
if (this.config.ads.enabled) {
this.ads = new Ads(this);
}
// Autoplay if required
if (this.isHTML5 && this.config.autoplay) {
this.once('canplay', () => silencePromise(this.play()));
}
// Seek time will be recorded (in listeners.js) so we can prevent hiding controls for a few seconds after seek
this.lastSeekTime = 0;
// Setup preview thumbnails if enabled
if (this.config.previewThumbnails.enabled) {
this.previewThumbnails = new PreviewThumbnails(this);
}
}
// ---------------------------------------
// API
// ---------------------------------------
/**
* Types and provider helpers
*/
get isHTML5() {
return this.provider === providers.html5;
}
get isEmbed() {
return this.isYouTube || this.isVimeo;
}
get isYouTube() {
return this.provider === providers.youtube;
}
get isVimeo() {
return this.provider === providers.vimeo;
}
get isVideo() {
return this.type === types.video;
}
get isAudio() {
return this.type === types.audio;
}
/**
* Play the media, or play the advertisement (if they are not blocked)
*/
play = () => {
if (!is.function(this.media.play)) {
return null;
}
// Intecept play with ads
if (this.ads && this.ads.enabled) {
this.ads.managerPromise.then(() => this.ads.play()).catch(() => silencePromise(this.media.play()));
}
// Return the promise (for HTML5)
return this.media.play();
};
/**
* Pause the media
*/
pause = () => {
if (!this.playing || !is.function(this.media.pause)) {
return null;
}
return this.media.pause();
};
/**
* Get playing state
*/
get playing() {
return Boolean(this.ready && !this.paused && !this.ended);
}
/**
* Get paused state
*/
get paused() {
return Boolean(this.media.paused);
}
/**
* Get stopped state
*/
get stopped() {
return Boolean(this.paused && this.currentTime === 0);
}
/**
* Get ended state
*/
get ended() {
return Boolean(this.media.ended);
}
/**
* Toggle playback based on current status
* @param {Boolean} input
*/
togglePlay = (input) => {
// Toggle based on current state if nothing passed
const toggle = is.boolean(input) ? input : !this.playing;
if (toggle) {
return this.play();
}
return this.pause();
};
/**
* Stop playback
*/
stop = () => {
if (this.isHTML5) {
this.pause();
this.restart();
} else if (is.function(this.media.stop)) {
this.media.stop();
}
};
/**
* Restart playback
*/
restart = () => {
this.currentTime = 0;
};
/**
* Rewind
* @param {Number} seekTime - how far to rewind in seconds. Defaults to the config.seekTime
*/
rewind = (seekTime) => {
this.currentTime -= is.number(seekTime) ? seekTime : this.config.seekTime;
};
/**
* Fast forward
* @param {Number} seekTime - how far to fast forward in seconds. Defaults to the config.seekTime
*/
forward = (seekTime) => {
this.currentTime += is.number(seekTime) ? seekTime : this.config.seekTime;
};
/**
* Seek to a time
* @param {Number} input - where to seek to in seconds. Defaults to 0 (the start)
*/
set currentTime(input) {
// Bail if media duration isn't available yet
if (!this.duration) {
return;
}
// Validate input
const inputIsValid = is.number(input) && input > 0;
// Set
this.media.currentTime = inputIsValid ? Math.min(input, this.duration) : 0;
// Logging
this.debug.log(`Seeking to ${this.currentTime} seconds`);
}
/**
* Get current time
*/
get currentTime() {
return Number(this.media.currentTime);
}
/**
* Get buffered
*/
get buffered() {
const { buffered } = this.media;
// YouTube / Vimeo return a float between 0-1
if (is.number(buffered)) {
return buffered;
}
// HTML5
// TODO: Handle buffered chunks of the media
// (i.e. seek to another section buffers only that section)
if (buffered && buffered.length && this.duration > 0) {
return buffered.end(0) / this.duration;
}
return 0;
}
/**
* Get seeking status
*/
get seeking() {
return Boolean(this.media.seeking);
}
/**
* Get the duration of the current media
*/
get duration() {
// Faux duration set via config
const fauxDuration = parseFloat(this.config.duration);
// Media duration can be NaN or Infinity before the media has loaded
const realDuration = (this.media || {}).duration;
const duration = !is.number(realDuration) || realDuration === Infinity ? 0 : realDuration;
// If config duration is funky, use regular duration
return fauxDuration || duration;
}
/**
* Set the player volume
* @param {Number} value - must be between 0 and 1. Defaults to the value from local storage and config.volume if not set in storage
*/
set volume(value) {
let volume = value;
const max = 1;
const min = 0;
if (is.string(volume)) {
volume = Number(volume);
}
// Load volume from storage if no value specified
if (!is.number(volume)) {
volume = this.storage.get('volume');
}
// Use config if all else fails
if (!is.number(volume)) {
({ volume } = this.config);
}
// Maximum is volumeMax
if (volume > max) {
volume = max;
}
// Minimum is volumeMin
if (volume < min) {
volume = min;
}
// Update config
this.config.volume = volume;
// Set the player volume
this.media.volume = volume;
// If muted, and we're increasing volume manually, reset muted state
if (!is.empty(value) && this.muted && volume > 0) {
this.muted = false;
}
}
/**
* Get the current player volume
*/
get volume() {
return Number(this.media.volume);
}
/**
* Increase volume
* @param {Boolean} step - How much to decrease by (between 0 and 1)
*/
increaseVolume = (step) => {
const volume = this.media.muted ? 0 : this.volume;
this.volume = volume + (is.number(step) ? step : 0);
};
/**
* Decrease volume
* @param {Boolean} step - How much to decrease by (between 0 and 1)
*/
decreaseVolume = (step) => {
this.increaseVolume(-step);
};
/**
* Set muted state
* @param {Boolean} mute
*/
set muted(mute) {
let toggle = mute;
// Load muted state from storage
if (!is.boolean(toggle)) {
toggle = this.storage.get('muted');
}
// Use config if all else fails
if (!is.boolean(toggle)) {
toggle = this.config.muted;
}
// Update config
this.config.muted = toggle;
// Set mute on the player
this.media.muted = toggle;
}
/**
* Get current muted state
*/
get muted() {
return Boolean(this.media.muted);
}
/**
* Check if the media has audio
*/
get hasAudio() {
// Assume yes for all non HTML5 (as we can't tell...)
if (!this.isHTML5) {
return true;
}
if (this.isAudio) {
return true;
}
// Get audio tracks
return (
Boolean(this.media.mozHasAudio) ||
Boolean(this.media.webkitAudioDecodedByteCount) ||
Boolean(this.media.audioTracks && this.media.audioTracks.length)
);
}
/**
* Set playback speed
* @param {Number} speed - the speed of playback (0.5-2.0)
*/
set speed(input) {
let speed = null;
if (is.number(input)) {
speed = input;
}
if (!is.number(speed)) {
speed = this.storage.get('speed');
}
if (!is.number(speed)) {
speed = this.config.speed.selected;
}
// Clamp to min/max
const { minimumSpeed: min, maximumSpeed: max } = this;
speed = clamp(speed, min, max);
// Update config
this.config.speed.selected = speed;
// Set media speed
setTimeout(() => {
this.media.playbackRate = speed;
}, 0);
}
/**
* Get current playback speed
*/
get speed() {
return Number(this.media.playbackRate);
}
/**
* Get the minimum allowed speed
*/
get minimumSpeed() {
if (this.isYouTube) {
// https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
return Math.min(...this.options.speed);
}
if (this.isVimeo) {
// https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
return 0.5;
}
// https://stackoverflow.com/a/32320020/1191319
return 0.0625;
}
/**
* Get the maximum allowed speed
*/
get maximumSpeed() {
if (this.isYouTube) {
// https://developers.google.com/youtube/iframe_api_reference#setPlaybackRate
return Math.max(...this.options.speed);
}
if (this.isVimeo) {
// https://github.com/vimeo/player.js/#setplaybackrateplaybackrate-number-promisenumber-rangeerrorerror
return 2;
}
// https://stackoverflow.com/a/32320020/1191319
return 16;
}
/**
* Set playback quality
* Currently HTML5 & YouTube only
* @param {Number} input - Quality level
*/
set quality(input) {
const config = this.config.quality;
const options = this.options.quality;
if (!options.length) {
return;
}
let quality = [
!is.empty(input) && Number(input),
this.storage.get('quality'),
config.selected,
config.default,
].find(is.number);
let updateStorage = true;
if (!options.includes(quality)) {
const value = closest(options, quality);
this.debug.warn(`Unsupported quality option: ${quality}, using ${value} instead`);
quality = value;
// Don't update storage if quality is not supported
updateStorage = false;
}
// Update config
config.selected = quality;
// Set quality
this.media.quality = quality;
// Save to storage
if (updateStorage) {
this.storage.set({ quality });
}
}
/**
* Get current quality level
*/
get quality() {
return this.media.quality;
}
/**
* Toggle loop
* TODO: Finish fancy new logic. Set the indicator on load as user may pass loop as config
* @param {Boolean} input - Whether to loop or not
*/
set loop(input) {
const toggle = is.boolean(input) ? input : this.config.loop.active;
this.config.loop.active = toggle;
this.media.loop = toggle;
// Set default to be a true toggle
/* const type = ['start', 'end', 'all', 'none', 'toggle'].includes(input) ? input : 'toggle';
switch (type) {
case 'start':
if (this.config.loop.end && this.config.loop.end <= this.currentTime) {
this.config.loop.end = null;
}
this.config.loop.start = this.currentTime;
// this.config.loop.indicator.start = this.elements.display.played.value;
break;
case 'end':
if (this.config.loop.start >= this.currentTime) {
return this;
}
this.config.loop.end = this.currentTime;
// this.config.loop.indicator.end = this.elements.display.played.value;
break;
case 'all':
this.config.loop.start = 0;
this.config.loop.end = this.duration - 2;
this.config.loop.indicator.start = 0;
this.config.loop.indicator.end = 100;
break;
case 'toggle':
if (this.config.loop.active) {
this.config.loop.start = 0;
this.config.loop.end = null;
} else {
this.config.loop.start = 0;
this.config.loop.end = this.duration - 2;
}
break;
default:
this.config.loop.start = 0;
this.config.loop.end = null;
break;
} */
}
/**
* Get current loop state
*/
get loop() {
return Boolean(this.media.loop);
}
/**
* Set new media source
* @param {Object} input - The new source object (see docs)
*/
set source(input) {
source.change.call(this, input);
}
/**
* Get current source
*/
get source() {
return this.media.currentSrc;
}
/**
* Get a download URL (either source or custom)
*/
get download() {
const { download } = this.config.urls;
return is.url(download) ? download : this.source;
}
/**
* Set the download URL
*/
set download(input) {
if (!is.url(input)) {
return;
}
this.config.urls.download = input;
controls.setDownloadUrl.call(this);
}
/**
* Set the poster image for a video
* @param {String} input - the URL for the new poster image
*/
set poster(input) {
if (!this.isVideo) {
this.debug.warn('Poster can only be set for video');
return;
}
ui.setPoster.call(this, input, false).catch(() => {});
}
/**
* Get the current poster image
*/
get poster() {
if (!this.isVideo) {
return null;
}
return this.media.getAttribute('poster') || this.media.getAttribute('data-poster');
}
/**
* Get the current aspect ratio in use
*/
get ratio() {
if (!this.isVideo) {
return null;
}
const ratio = reduceAspectRatio(getAspectRatio.call(this));
return is.array(ratio) ? ratio.join(':') : ratio;
}
/**
* Set video aspect ratio
*/
set ratio(input) {
if (!this.isVideo) {
this.debug.warn('Aspect ratio can only be set for video');
return;
}
if (!is.string(input) || !validateRatio(input)) {
this.debug.error(`Invalid aspect ratio specified (${input})`);
return;
}
this.config.ratio = input;
setAspectRatio.call(this);
}
/**
* Set the autoplay state
* @param {Boolean} input - Whether to autoplay or not
*/
set autoplay(input) {
const toggle = is.boolean(input) ? input : this.config.autoplay;
this.config.autoplay = toggle;
}
/**
* Get the current autoplay state
*/
get autoplay() {
return Boolean(this.config.autoplay);
}
/**
* Toggle captions
* @param {Boolean} input - Whether to enable captions
*/
toggleCaptions(input) {
captions.toggle.call(this, input, false);
}
/**
* Set the caption track by index
* @param {Number} - Caption index
*/
set currentTrack(input) {
captions.set.call(this, input, false);
}
/**
* Get the current caption track index (-1 if disabled)
*/
get currentTrack() {
const { toggled, currentTrack } = this.captions;
return toggled ? currentTrack : -1;
}
/**
* 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) {
captions.setLanguage.call(this, input, false);
}
/**
* Get the current track's language
*/
get language() {
return (captions.getCurrentTrack.call(this) || {}).language;
}
/**
* Toggle picture-in-picture playback on WebKit/MacOS
* TODO: update player with state, support, enabled
* TODO: detect outside changes
*/
set pip(input) {
// Bail if no support
if (!support.pip) {
return;
}
// Toggle based on current state if not passed
const toggle = is.boolean(input) ? input : !this.pip;
// Toggle based on current state
// Safari
if (is.function(this.media.webkitSetPresentationMode)) {
this.media.webkitSetPresentationMode(toggle ? pip.active : pip.inactive);
}
// Chrome
if (is.function(this.media.requestPictureInPicture)) {
if (!this.pip && toggle) {
this.media.requestPictureInPicture();
} else if (this.pip && !toggle) {
document.exitPictureInPicture();
}
}
}
/**
* Get the current picture-in-picture state
*/
get pip() {
if (!support.pip) {
return null;
}
// Safari
if (!is.empty(this.media.webkitPresentationMode)) {
return this.media.webkitPresentationMode === pip.active;
}
// Chrome
return this.media === document.pictureInPictureElement;
}
/**
* Trigger the airplay dialog
* TODO: update player with state, support, enabled
*/
airplay = () => {
// Show dialog if supported
if (support.airplay) {
this.media.webkitShowPlaybackTargetPicker();
}
};
/**
* Toggle the player controls
* @param {Boolean} [toggle] - Whether to show the controls
*/
toggleControls = (toggle) => {
// 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);
// Negate the argument if not undefined since adding the class to hides the controls
const force = typeof toggle === 'undefined' ? undefined : !toggle;
// Apply and get updated state
const hiding = toggleClass(this.elements.container, this.config.classNames.hideControls, force);
// Close menu
if (
hiding &&
is.array(this.config.controls) &&
this.config.controls.includes('settings') &&
!is.empty(this.config.settings)
) {
controls.toggleMenu.call(this, false);
}
// Trigger event on change
if (hiding !== isHidden) {
const eventName = hiding ? 'controlshidden' : 'controlsshown';
triggerEvent.call(this, this.media, eventName);
}
return !hiding;
}
return false;
};
/**
* Add event listeners
* @param {String} event - Event type
* @param {Function} callback - Callback for when event occurs
*/
on = (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) => {
off(this.elements.container, event, callback);
};
/**
* Destroy an instance
* Event listeners are removed when elements are removed
* http://stackoverflow.com/questions/12528049/if-a-dom-element-is-removed-are-its-listeners-also-removed-from-memory
* @param {Function} callback - Callback for when destroy is complete
* @param {Boolean} soft - Whether it's a soft destroy (for source changes etc)
*/
destroy = (callback, soft = false) => {
if (!this.ready) {
return;
}
const done = () => {
// Reset overflow (incase destroyed while in fullscreen)
document.body.style.overflow = '';
// GC for embed
this.embed = null;
// If it's a soft destroy, make minimal changes
if (soft) {
if (Object.keys(this.elements).length) {
// Remove elements
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;
this.elements.captions = null;
this.elements.controls = null;
this.elements.wrapper = null;
}
// Callback
if (is.function(callback)) {
callback();
}
} else {
// Unbind listeners
unbindListeners.call(this);
// Cancel current network requests
html5.cancelRequests.call(this);
// Replace the container with the original element provided
replaceElement(this.elements.original, this.elements.container);
// Event
triggerEvent.call(this, this.elements.original, 'destroyed', true);
// Callback
if (is.function(callback)) {
callback.call(this.elements.original);
}
// Reset state
this.ready = false;
// Clear for garbage collection
setTimeout(() => {
this.elements = null;
this.media = null;
}, 200);
}
};
// Stop playback
this.stop();
// Clear timeouts
clearTimeout(this.timers.loading);
clearTimeout(this.timers.controls);
clearTimeout(this.timers.resized);
// Provider specific stuff
if (this.isHTML5) {
// 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();
}
// 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);
}
// Vimeo does not always return
setTimeout(done, 200);
}
};
/**
* Check for support for a mime type (HTML5 only)
* @param {String} type - Mime type
*/
supports = (type) => support.mime.call(this, type);
/**
* Check for support
* @param {String} type - Player type (audio/video)
* @param {String} provider - Provider (html5/youtube/vimeo)
* @param {Boolean} inline - Where player has `playsinline` sttribute
*/
static supported(type, provider, inline) {
return support.check(type, provider, inline);
}
/**
* Load an SVG sprite into the page
* @param {String} url - URL for the SVG sprite
* @param {String} [id] - Unique ID
*/
static loadSprite(url, id) {
return loadSprite(url, id);
}
/**
* Setup multiple instances
* @param {*} selector
* @param {Object} options
*/
static setup(selector, options = {}) {
let targets = null;
if (is.string(selector)) {
targets = Array.from(document.querySelectorAll(selector));
} else if (is.nodeList(selector)) {
targets = Array.from(selector);
} else if (is.array(selector)) {
targets = selector.filter(is.element);
}
if (is.empty(targets)) {
return null;
}
return targets.map((t) => new Plyr(t, options));
}
}
Plyr.defaults = cloneDeep(defaults);
export default Plyr;