aboutsummaryrefslogtreecommitdiffstats
path: root/src/js/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'src/js/plugins')
-rw-r--r--src/js/plugins/ads.js574
-rw-r--r--src/js/plugins/vimeo.js323
-rw-r--r--src/js/plugins/youtube.js417
3 files changed, 1314 insertions, 0 deletions
diff --git a/src/js/plugins/ads.js b/src/js/plugins/ads.js
new file mode 100644
index 00000000..31a797c2
--- /dev/null
+++ b/src/js/plugins/ads.js
@@ -0,0 +1,574 @@
+// ==========================================================================
+// Advertisement plugin using Google IMA HTML5 SDK
+// Create an account with our ad partner, vi here:
+// https://www.vi.ai/publisher-video-monetization/
+// ==========================================================================
+
+/* global google */
+
+import utils from '../utils';
+
+class Ads {
+ /**
+ * Ads constructor.
+ * @param {object} player
+ * @return {Ads}
+ */
+ constructor(player) {
+ this.player = player;
+ this.publisherId = player.config.ads.publisherId;
+ this.enabled = player.isHTML5 && player.isVideo && player.config.ads.enabled && utils.is.string(this.publisherId) && this.publisherId.length;
+ this.playing = false;
+ this.initialized = false;
+ this.elements = {
+ container: null,
+ displayContainer: null,
+ };
+ this.manager = null;
+ this.loader = null;
+ this.cuePoints = null;
+ this.events = {};
+ this.safetyTimer = null;
+ this.countdownTimer = null;
+
+ // Setup a promise to resolve when the IMA manager is ready
+ this.managerPromise = new Promise((resolve, reject) => {
+ // The ad is loaded and ready
+ this.on('loaded', resolve);
+
+ // Ads failed
+ this.on('error', reject);
+ });
+
+ this.load();
+ }
+
+ /**
+ * Load the IMA SDK
+ */
+ 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.api)
+ .then(() => {
+ this.ready();
+ })
+ .catch(() => {
+ // Script failed to load or is blocked
+ this.trigger('error', new Error('Google IMA SDK failed to load'));
+ });
+ } else {
+ this.ready();
+ }
+ }
+ }
+
+ /**
+ * Get the ads instance ready
+ */
+ ready() {
+ // Start ticking our safety timer. If the whole advertisement
+ // thing doesn't resolve within our set time; we bail
+ this.startSafetyTimer(12000, 'ready()');
+
+ // Clear the safety timer
+ this.managerPromise.then(() => {
+ this.clearSafetyTimer('onAdsManagerLoaded()');
+ });
+
+ // Set listeners on the Plyr instance
+ this.listeners();
+
+ // Setup the IMA SDK
+ this.setupIMA();
+ }
+
+ // Build the default tag URL
+ get tagUrl() {
+ const params = {
+ AV_PUBLISHERID: '58c25bb0073ef448b1087ad6',
+ AV_CHANNELID: '5a0458dc28a06145e4519d21',
+ AV_URL: location.hostname,
+ cb: Date.now(),
+ AV_WIDTH: 640,
+ AV_HEIGHT: 480,
+ AV_CDIM2: this.publisherId,
+ };
+
+ const base = 'https://go.aniview.com/api/adserver6/vast/';
+
+ return `${base}?${utils.buildUrlParams(params)}`;
+ }
+
+ /**
+ * In order for the SDK to display ads for our video, we need to tell it where to put them,
+ * so here we define our ad container. This div is set up to render on top of the video player.
+ * Using the code below, we tell the SDK to render ads within that div. We also provide a
+ * handle to the content video player - the SDK will poll the current time of our player to
+ * properly place mid-rolls. After we create the ad display container, we initialize it. On
+ * mobile devices, this initialization is done as the result of a user action.
+ */
+ setupIMA() {
+ // Create the container for our advertisements
+ this.elements.container = utils.createElement('div', {
+ class: this.player.config.classNames.ads,
+ });
+ this.player.elements.container.appendChild(this.elements.container);
+
+ // So we can run VPAID2
+ google.ima.settings.setVpaidMode(google.ima.ImaSdkSettings.VpaidMode.ENABLED);
+
+ // Set language
+ google.ima.settings.setLocale(this.player.config.ads.language);
+
+ // We assume the adContainer is the video container of the plyr element
+ // that will house the ads
+ this.elements.displayContainer = new google.ima.AdDisplayContainer(this.elements.container);
+
+ // Request video ads to be pre-loaded
+ this.requestAds();
+ }
+
+ /**
+ * Request advertisements
+ */
+ requestAds() {
+ const { container } = this.player.elements;
+
+ try {
+ // Create ads loader
+ 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.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error), false);
+
+ // Request video ads
+ const request = new google.ima.AdsRequest();
+ request.adTagUrl = this.tagUrl;
+
+ // Specify the linear and nonlinear slot sizes. This helps the SDK
+ // to select the correct creative if multiple are returned
+ request.linearAdSlotWidth = container.offsetWidth;
+ request.linearAdSlotHeight = container.offsetHeight;
+ request.nonLinearAdSlotWidth = container.offsetWidth;
+ request.nonLinearAdSlotHeight = container.offsetHeight;
+
+ // We only overlay ads as we only support video.
+ request.forceNonLinearFullSlot = false;
+
+ this.loader.requestAds(request);
+ } catch (e) {
+ this.onAdError(e);
+ }
+ }
+
+ /**
+ * Update the ad countdown
+ * @param {boolean} start
+ */
+ pollCountdown(start = false) {
+ if (!start) {
+ clearInterval(this.countdownTimer);
+ this.elements.container.removeAttribute('data-badge-text');
+ return;
+ }
+
+ const update = () => {
+ const time = utils.formatTime(Math.max(this.manager.getRemainingTime(), 0));
+ const label = `${this.player.config.i18n.advertisement} - ${time}`;
+ this.elements.container.setAttribute('data-badge-text', label);
+ };
+
+ this.countdownTimer = setInterval(update, 100);
+ }
+
+ /**
+ * This method is called whenever the ads are ready inside the AdDisplayContainer
+ * @param {Event} adsManagerLoadedEvent
+ */
+ onAdsManagerLoaded(event) {
+ // Get the ads manager
+ const settings = new google.ima.AdsRenderingSettings();
+
+ // Tell the SDK to save and restore content video state on our behalf
+ settings.restoreCustomPlaybackStateOnAdBreakComplete = true;
+ settings.enablePreloading = true;
+
+ // The SDK is polling currentTime on the contentPlayback. And needs a duration
+ // so it can determine when to start the mid- and post-roll
+ this.manager = event.getAdsManager(this.player, settings);
+
+ // Get the cue points for any mid-rolls by filtering out the pre- and post-roll
+ this.cuePoints = this.manager.getCuePoints();
+
+ // Add advertisement cue's within the time line if available
+ this.cuePoints.forEach(cuePoint => {
+ if (cuePoint !== 0 && cuePoint !== -1 && cuePoint < this.player.duration) {
+ const seekElement = this.player.elements.progress;
+
+ if (seekElement) {
+ const cuePercentage = 100 / this.player.duration * cuePoint;
+ const cue = utils.createElement('span', {
+ class: this.player.config.classNames.cues,
+ });
+
+ cue.style.left = `${cuePercentage.toString()}%`;
+ seekElement.appendChild(cue);
+ }
+ }
+ });
+
+ // Get skippable state
+ // TODO: Skip button
+ // this.manager.getAdSkippableState();
+
+ // Set volume to match player
+ this.manager.setVolume(this.player.volume);
+
+ // Add listeners to the required events
+ // Advertisement error events
+ this.manager.addEventListener(google.ima.AdErrorEvent.Type.AD_ERROR, error => this.onAdError(error));
+
+ // Advertisement regular events
+ Object.keys(google.ima.AdEvent.Type).forEach(type => {
+ this.manager.addEventListener(google.ima.AdEvent.Type[type], event => this.onAdEvent(event));
+ });
+
+ // Resolve our adsManager
+ this.trigger('loaded');
+ }
+
+ /**
+ * This is where all the event handling takes place. Retrieve the ad from the event. Some
+ * events (e.g. ALL_ADS_COMPLETED) don't have the ad object associated
+ * https://developers.google.com/interactive-media-ads/docs/sdks/html5/v3/apis#ima.AdEvent.Type
+ * @param {Event} event
+ */
+ onAdEvent(event) {
+ const { container } = this.player.elements;
+
+ // Retrieve the ad from the event. Some events (e.g. ALL_ADS_COMPLETED)
+ // don't have ad object associated
+ const ad = event.getAd();
+
+ // Proxy event
+ const dispatchEvent = type => {
+ const event = `ads${type.replace(/_/g, '').toLowerCase()}`;
+ utils.dispatchEvent.call(this.player, this.player.media, event);
+ };
+
+ switch (event.type) {
+ case google.ima.AdEvent.Type.LOADED:
+ // This is the first event sent for an ad - it is possible to determine whether the
+ // ad is a video ad or an overlay
+ this.trigger('loaded');
+
+ // Bubble event
+ dispatchEvent(event.type);
+
+ // Start countdown
+ this.pollCountdown(true);
+
+ if (!ad.isLinear()) {
+ // Position AdDisplayContainer correctly for overlay
+ ad.width = container.offsetWidth;
+ ad.height = container.offsetHeight;
+ }
+
+ // console.info('Ad type: ' + event.getAd().getAdPodInfo().getPodIndex());
+ // console.info('Ad time: ' + event.getAd().getAdPodInfo().getTimeOffset());
+ break;
+
+ case google.ima.AdEvent.Type.ALL_ADS_COMPLETED:
+ // All ads for the current videos are done. We can now request new advertisements
+ // in case the video is re-played
+
+ // Fire event
+ dispatchEvent(event.type);
+
+ // TODO: Example for what happens when a next video in a playlist would be loaded.
+ // So here we load a new video when all ads are done.
+ // Then we load new ads within a new adsManager. When the video
+ // Is started - after - the ads are loaded, then we get ads.
+ // You can also easily test cancelling and reloading by running
+ // player.ads.cancel() and player.ads.play from the console I guess.
+ // this.player.source = {
+ // type: 'video',
+ // title: 'View From A Blue Moon',
+ // sources: [{
+ // src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.mp4', type:
+ // 'video/mp4', }], poster:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.jpg', tracks:
+ // [ { kind: 'captions', label: 'English', srclang: 'en', src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.en.vtt',
+ // default: true, }, { kind: 'captions', label: 'French', srclang: 'fr', src:
+ // 'https://cdn.plyr.io/static/demo/View_From_A_Blue_Moon_Trailer-HD.fr.vtt', }, ],
+ // };
+
+ // TODO: So there is still this thing where a video should only be allowed to start
+ // playing when the IMA SDK is ready or has failed
+
+ this.loadAds();
+ break;
+
+ case google.ima.AdEvent.Type.CONTENT_PAUSE_REQUESTED:
+ // This event indicates the ad has started - the video player can adjust the UI,
+ // for example display a pause button and remaining time. Fired when content should
+ // be paused. This usually happens right before an ad is about to cover the content
+
+ dispatchEvent(event.type);
+
+ this.pauseContent();
+
+ break;
+
+ case google.ima.AdEvent.Type.CONTENT_RESUME_REQUESTED:
+ // This event indicates the ad has finished - the video player can perform
+ // appropriate UI actions, such as removing the timer for remaining time detection.
+ // Fired when content should be resumed. This usually happens when an ad finishes
+ // or collapses
+
+ dispatchEvent(event.type);
+
+ this.pollCountdown();
+
+ this.resumeContent();
+
+ break;
+
+ case google.ima.AdEvent.Type.STARTED:
+ case google.ima.AdEvent.Type.MIDPOINT:
+ case google.ima.AdEvent.Type.COMPLETE:
+ case google.ima.AdEvent.Type.IMPRESSION:
+ case google.ima.AdEvent.Type.CLICK:
+ dispatchEvent(event.type);
+ break;
+
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Any ad error handling comes through here
+ * @param {Event} event
+ */
+ onAdError(event) {
+ this.cancel();
+ this.player.debug.warn('Ads error', event);
+ }
+
+ /**
+ * Setup hooks for Plyr and window events. This ensures
+ * the mid- and post-roll launch at the correct time. And
+ * resize the advertisement when the player resizes
+ */
+ listeners() {
+ const { container } = this.player.elements;
+ let time;
+
+ // Add listeners to the required events
+ this.player.on('ended', () => {
+ this.loader.contentComplete();
+ });
+
+ this.player.on('seeking', () => {
+ time = this.player.currentTime;
+ return time;
+ });
+
+ this.player.on('seeked', () => {
+ const seekedTime = this.player.currentTime;
+
+ this.cuePoints.forEach((cuePoint, index) => {
+ if (time < cuePoint && cuePoint < seekedTime) {
+ this.manager.discardAdBreak();
+ this.cuePoints.splice(index, 1);
+ }
+ });
+ });
+
+ // Listen to the resizing of the window. And resize ad accordingly
+ // TODO: eventually implement ResizeObserver
+ window.addEventListener('resize', () => {
+ this.manager.resize(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);
+ });
+ }
+
+ /**
+ * Initialize the adsManager and start playing advertisements
+ */
+ play() {
+ const { container } = this.player.elements;
+
+ if (!this.managerPromise) {
+ this.resumeContent();
+ }
+
+ // Play the requested advertisement whenever the adsManager is ready
+ this.managerPromise
+ .then(() => {
+ // Initialize the container. Must be done via a user action on mobile devices
+ this.elements.displayContainer.initialize();
+
+ try {
+ if (!this.initialized) {
+ // Initialize the ads manager. Ad rules playlist will start at this time
+ this.manager.init(container.offsetWidth, container.offsetHeight, google.ima.ViewMode.NORMAL);
+
+ // Call play to start showing the ad. Single video and overlay ads will
+ // start at this time; the call will be ignored for ad rules
+ this.manager.start();
+ }
+
+ this.initialized = true;
+ } catch (adError) {
+ // An error may be thrown if there was a problem with the
+ // VAST response
+ this.onAdError(adError);
+ }
+ })
+ .catch(() => {});
+ }
+
+ /**
+ * Resume our video
+ */
+ resumeContent() {
+ // Hide the advertisement container
+ this.elements.container.style.zIndex = '';
+
+ // Ad is stopped
+ this.playing = false;
+
+ // Play our video
+ if (this.player.currentTime < this.player.duration) {
+ this.player.play();
+ }
+ }
+
+ /**
+ * Pause our video
+ */
+ pauseContent() {
+ // Show the advertisement container
+ this.elements.container.style.zIndex = 3;
+
+ // Ad is playing.
+ this.playing = true;
+
+ // Pause our video.
+ this.player.pause();
+ }
+
+ /**
+ * Destroy the adsManager so we can grab new ads after this. If we don't then we're not
+ * allowed to call new ads based on google policies, as they interpret this as an accidental
+ * video requests. https://developers.google.com/interactive-
+ * media-ads/docs/sdks/android/faq#8
+ */
+ cancel() {
+ // Pause our video
+ if (this.initialized) {
+ this.resumeContent();
+ }
+
+ // Tell our instance that we're done for now
+ this.trigger('error');
+
+ // Re-create our adsManager
+ this.loadAds();
+ }
+
+ /**
+ * Re-create our adsManager
+ */
+ loadAds() {
+ // Tell our adsManager to go bye bye
+ this.managerPromise
+ .then(() => {
+ // Destroy our adsManager
+ if (this.manager) {
+ this.manager.destroy();
+ }
+
+ // Re-set our adsManager promises
+ this.managerPromise = new Promise(resolve => {
+ this.on('loaded', resolve);
+ this.player.debug.log(this.manager);
+ });
+
+ // Now request some new advertisements
+ this.requestAds();
+ })
+ .catch(() => {});
+ }
+
+ /**
+ * Handles callbacks after an ad event was invoked
+ * @param {string} event - Event type
+ */
+ trigger(event, ...args) {
+ const handlers = this.events[event];
+
+ if (utils.is.array(handlers)) {
+ handlers.forEach(handler => {
+ if (utils.is.function(handler)) {
+ handler.apply(this, args);
+ }
+ });
+ }
+ }
+
+ /**
+ * Add event listeners
+ * @param {string} event - Event type
+ * @param {function} callback - Callback for when event occurs
+ * @return {Ads}
+ */
+ on(event, callback) {
+ if (!utils.is.array(this.events[event])) {
+ this.events[event] = [];
+ }
+
+ this.events[event].push(callback);
+
+ return this;
+ }
+
+ /**
+ * Setup a safety timer for when the ad network doesn't respond for whatever reason.
+ * The advertisement has 12 seconds to get its things together. We stop this timer when the
+ * advertisement is playing, or when a user action is required to start, then we clear the
+ * timer on ad ready
+ * @param {number} time
+ * @param {string} from
+ */
+ startSafetyTimer(time, from) {
+ this.player.debug.log(`Safety timer invoked from: ${from}`);
+
+ this.safetyTimer = setTimeout(() => {
+ this.cancel();
+ this.clearSafetyTimer('startSafetyTimer()');
+ }, time);
+ }
+
+ /**
+ * Clear our safety timer(s)
+ * @param {string} from
+ */
+ clearSafetyTimer(from) {
+ if (!utils.is.nullOrUndefined(this.safetyTimer)) {
+ this.player.debug.log(`Safety timer cleared from: ${from}`);
+
+ clearTimeout(this.safetyTimer);
+ this.safetyTimer = null;
+ }
+ }
+}
+
+export default Ads;
diff --git a/src/js/plugins/vimeo.js b/src/js/plugins/vimeo.js
new file mode 100644
index 00000000..fcc4247c
--- /dev/null
+++ b/src/js/plugins/vimeo.js
@@ -0,0 +1,323 @@
+// ==========================================================================
+// Vimeo plugin
+// ==========================================================================
+
+import utils from './../utils';
+import captions from './../captions';
+import ui from './../ui';
+
+const vimeo = {
+ setup() {
+ // Add embed class for responsive
+ utils.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.api)
+ .then(() => {
+ vimeo.ready.call(this);
+ })
+ .catch(error => {
+ this.debug.warn('Vimeo API failed to load', error);
+ });
+ } else {
+ vimeo.ready.call(this);
+ }
+ },
+
+ // 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 height = 200;
+ const offset = (height - padding) / (height / 50);
+ this.elements.wrapper.style.paddingBottom = `${padding}%`;
+ this.media.style.transform = `translateY(-${offset}%)`;
+ },
+
+ // API Ready
+ ready() {
+ const player = this;
+
+ // Get Vimeo params for the iframe
+ const options = {
+ loop: player.config.loop.active,
+ autoplay: player.autoplay,
+ byline: false,
+ portrait: false,
+ title: false,
+ speed: true,
+ transparent: 0,
+ gesture: 'media',
+ };
+ const params = utils.buildUrlParams(options);
+
+ // Get the source URL or ID
+ let source = player.media.getAttribute('src');
+
+ // Get from <div> if needed
+ if (utils.is.empty(source)) {
+ source = player.media.getAttribute(this.config.attributes.embed.id);
+ }
+
+ const id = utils.parseVimeoId(source);
+
+ // Build an iframe
+ const iframe = utils.createElement('iframe');
+ const src = `https://player.vimeo.com/video/${id}?${params}`;
+ iframe.setAttribute('src', src);
+ iframe.setAttribute('allowfullscreen', '');
+ iframe.setAttribute('allowtransparency', '');
+ iframe.setAttribute('allow', 'autoplay');
+
+ // Inject the package
+ const wrapper = utils.createElement('div');
+ wrapper.appendChild(iframe);
+ player.media = utils.replaceElement(wrapper, player.media);
+
+ // Setup instance
+ // https://github.com/vimeo/player.js
+ player.embed = new window.Vimeo.Player(iframe);
+
+ player.media.paused = true;
+ player.media.currentTime = 0;
+
+ // Create a faux HTML5 API using the Vimeo API
+ player.media.play = () => {
+ player.embed.play().then(() => {
+ player.media.paused = false;
+ });
+ };
+
+ player.media.pause = () => {
+ player.embed.pause().then(() => {
+ player.media.paused = true;
+ });
+ };
+
+ player.media.stop = () => {
+ player.embed.stop().then(() => {
+ player.media.paused = true;
+ player.currentTime = 0;
+ });
+ };
+
+ // Seeking
+ let { currentTime } = player.media;
+ Object.defineProperty(player.media, 'currentTime', {
+ get() {
+ 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);
+
+ // Restore pause state
+ if (paused) {
+ player.pause();
+ }
+ },
+ });
+
+ // Playback speed
+ let speed = player.config.speed.selected;
+ Object.defineProperty(player.media, 'playbackRate', {
+ get() {
+ return speed;
+ },
+ set(input) {
+ player.embed.setPlaybackRate(input).then(() => {
+ speed = input;
+ utils.dispatchEvent.call(player, player.media, 'ratechange');
+ });
+ },
+ });
+
+ // Volume
+ let { volume } = player.config;
+ Object.defineProperty(player.media, 'volume', {
+ get() {
+ return volume;
+ },
+ set(input) {
+ player.embed.setVolume(input).then(() => {
+ volume = input;
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ });
+ },
+ });
+
+ // Muted
+ let { muted } = player.config;
+ Object.defineProperty(player.media, 'muted', {
+ get() {
+ return muted;
+ },
+ set(input) {
+ const toggle = utils.is.boolean(input) ? input : false;
+
+ player.embed.setVolume(toggle ? 0 : player.config.volume).then(() => {
+ muted = toggle;
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ });
+ },
+ });
+
+ // Loop
+ let { loop } = player.config;
+ Object.defineProperty(player.media, 'loop', {
+ get() {
+ return loop;
+ },
+ set(input) {
+ const toggle = utils.is.boolean(input) ? input : player.config.loop.active;
+
+ player.embed.setLoop(toggle).then(() => {
+ loop = toggle;
+ });
+ },
+ });
+
+ // Source
+ let currentSrc;
+ player.embed.getVideoUrl().then(value => {
+ currentSrc = value;
+ });
+ Object.defineProperty(player.media, 'currentSrc', {
+ get() {
+ return currentSrc;
+ },
+ });
+
+ // Ended
+ Object.defineProperty(player.media, 'ended', {
+ get() {
+ return player.currentTime === player.duration;
+ },
+ });
+
+ // 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]);
+ vimeo.setAspectRatio.call(this, ratio);
+ });
+
+ // Set autopause
+ player.embed.setAutopause(player.config.autopause).then(state => {
+ player.config.autopause = state;
+ });
+
+ // Get title
+ player.embed.getVideoTitle().then(title => {
+ player.config.title = title;
+ ui.setTitle.call(this);
+ });
+
+ // Get current time
+ player.embed.getCurrentTime().then(value => {
+ currentTime = value;
+ utils.dispatchEvent.call(player, player.media, 'timeupdate');
+ });
+
+ // Get duration
+ player.embed.getDuration().then(value => {
+ player.media.duration = value;
+ utils.dispatchEvent.call(player, player.media, 'durationchange');
+ });
+
+ // Get captions
+ player.embed.getTextTracks().then(tracks => {
+ player.media.textTracks = tracks;
+ captions.setup.call(player);
+ });
+
+ player.embed.on('cuechange', data => {
+ let cue = null;
+
+ if (data.cues.length) {
+ cue = utils.stripHTML(data.cues[0].text);
+ }
+
+ captions.setText.call(player, cue);
+ });
+
+ player.embed.on('loaded', () => {
+ if (utils.is.element(player.embed.element) && player.supported.ui) {
+ const frame = player.embed.element;
+
+ // Fix keyboard focus issues
+ // https://github.com/sampotts/plyr/issues/317
+ frame.setAttribute('tabindex', -1);
+ }
+ });
+
+ 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');
+ });
+
+ player.embed.on('pause', () => {
+ player.media.paused = true;
+ utils.dispatchEvent.call(player, player.media, 'pause');
+ });
+
+ player.embed.on('timeupdate', data => {
+ player.media.seeking = false;
+ currentTime = data.seconds;
+ utils.dispatchEvent.call(player, player.media, 'timeupdate');
+ });
+
+ player.embed.on('progress', data => {
+ player.media.buffered = data.percent;
+ utils.dispatchEvent.call(player, player.media, 'progress');
+
+ // Check all loaded
+ if (parseInt(data.percent, 10) === 1) {
+ utils.dispatchEvent.call(player, player.media, 'canplaythrough');
+ }
+ });
+
+ player.embed.on('seeked', () => {
+ player.media.seeking = false;
+ utils.dispatchEvent.call(player, player.media, 'seeked');
+ utils.dispatchEvent.call(player, player.media, 'play');
+ });
+
+ player.embed.on('ended', () => {
+ player.media.paused = true;
+ utils.dispatchEvent.call(player, player.media, 'ended');
+ });
+
+ player.embed.on('error', detail => {
+ player.media.error = detail;
+ utils.dispatchEvent.call(player, player.media, 'error');
+ });
+
+ // Rebuild UI
+ setTimeout(() => ui.build.call(player), 0);
+ },
+};
+
+export default vimeo;
diff --git a/src/js/plugins/youtube.js b/src/js/plugins/youtube.js
new file mode 100644
index 00000000..0ded378a
--- /dev/null
+++ b/src/js/plugins/youtube.js
@@ -0,0 +1,417 @@
+// ==========================================================================
+// YouTube plugin
+// ==========================================================================
+
+import utils from './../utils';
+import controls from './../controls';
+import ui from './../ui';
+
+const youtube = {
+ setup() {
+ // Add embed class for responsive
+ utils.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)) {
+ youtube.ready.call(this);
+ } else {
+ // Load the API
+ utils.loadScript(this.config.urls.youtube.api).catch(error => {
+ this.debug.warn('YouTube API failed to load', error);
+ });
+
+ // Setup callback for the API
+ // YouTube has it's own system of course...
+ window.onYouTubeReadyCallbacks = window.onYouTubeReadyCallbacks || [];
+
+ // Add to queue
+ window.onYouTubeReadyCallbacks.push(() => {
+ youtube.ready.call(this);
+ });
+
+ // Set callback to process queue
+ window.onYouTubeIframeAPIReady = () => {
+ window.onYouTubeReadyCallbacks.forEach(callback => {
+ callback();
+ });
+ };
+ }
+ },
+
+ // Get the media title
+ getTitle(videoId) {
+ // 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)) {
+ const { title } = this.embed.getVideoData();
+
+ if (utils.is.empty(title)) {
+ this.config.title = title;
+ ui.setTitle.call(this);
+ return;
+ }
+ }
+
+ // Or via Google API
+ const key = this.config.keys.google;
+ if (utils.is.string(key) && !utils.is.empty(key)) {
+ const url = `https://www.googleapis.com/youtube/v3/videos?id=${videoId}&key=${key}&fields=items(snippet(title))&part=snippet`;
+
+ utils
+ .fetch(url)
+ .then(result => {
+ if (utils.is.object(result)) {
+ this.config.title = result.items[0].snippet.title;
+ ui.setTitle.call(this);
+ }
+ })
+ .catch(() => {});
+ }
+ },
+
+ // Set aspect ratio
+ setAspectRatio() {
+ const ratio = this.config.ratio.split(':');
+ this.elements.wrapper.style.paddingBottom = `${100 / ratio[0] * ratio[1]}%`;
+ },
+
+ // API ready
+ ready() {
+ const player = this;
+
+ // Ignore already setup (race condition)
+ const currentId = player.media.getAttribute('id');
+ if (!utils.is.empty(currentId) && currentId.startsWith('youtube-')) {
+ return;
+ }
+
+ // Get the source URL or ID
+ let source = player.media.getAttribute('src');
+
+ // Get from <div> if needed
+ if (utils.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);
+
+ // Setup instance
+ // https://developers.google.com/youtube/iframe_api_reference
+ player.embed = new window.YT.Player(id, {
+ videoId,
+ playerVars: {
+ autoplay: player.config.autoplay ? 1 : 0, // Autoplay
+ controls: player.supported.ui ? 0 : 1, // Only show controls if not fully supported
+ rel: 0, // No related vids
+ showinfo: 0, // Hide info
+ iv_load_policy: 3, // Hide annotations
+ modestbranding: 1, // Hide logos as much as possible (they still show one in the corner when paused)
+ disablekb: 1, // Disable keyboard as we handle it
+ playsinline: 1, // Allow iOS inline playback
+
+ // Tracking for stats
+ // origin: window ? `${window.location.protocol}//${window.location.host}` : null,
+ widget_referrer: window ? window.location.href : null,
+
+ // Captions are flaky on YouTube
+ cc_load_policy: player.captions.active ? 1 : 0,
+ cc_lang_pref: player.config.captions.language,
+ },
+ 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;
+ }
+
+ 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(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Get current quality
+ player.media.quality = instance.getPlaybackQuality();
+
+ utils.dispatchEvent.call(player, player.media, 'qualitychange');
+ },
+ onPlaybackRateChange(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Get current speed
+ player.media.playbackRate = instance.getPlaybackRate();
+
+ utils.dispatchEvent.call(player, player.media, 'ratechange');
+ },
+ onReady(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Get the title
+ youtube.getTitle.call(player, videoId);
+
+ // Create a faux HTML5 API using the YouTube API
+ player.media.play = () => {
+ instance.playVideo();
+ };
+
+ player.media.pause = () => {
+ instance.pauseVideo();
+ };
+
+ player.media.stop = () => {
+ instance.stopVideo();
+ };
+
+ player.media.duration = instance.getDuration();
+ player.media.paused = true;
+
+ // Seeking
+ player.media.currentTime = 0;
+ Object.defineProperty(player.media, 'currentTime', {
+ get() {
+ return Number(instance.getCurrentTime());
+ },
+ set(time) {
+ // Set seeking flag
+ player.media.seeking = true;
+
+ // Trigger seeking
+ utils.dispatchEvent.call(player, player.media, 'seeking');
+
+ // Seek after events sent
+ instance.seekTo(time);
+ },
+ });
+
+ // Playback speed
+ Object.defineProperty(player.media, 'playbackRate', {
+ get() {
+ return instance.getPlaybackRate();
+ },
+ set(input) {
+ instance.setPlaybackRate(input);
+ },
+ });
+
+ // Quality
+ Object.defineProperty(player.media, 'quality', {
+ get() {
+ return instance.getPlaybackQuality();
+ },
+ set(input) {
+ // Trigger request event
+ utils.dispatchEvent.call(player, player.media, 'qualityrequested', false, {
+ quality: input,
+ });
+
+ instance.setPlaybackQuality(input);
+ },
+ });
+
+ // Volume
+ let { volume } = player.config;
+ Object.defineProperty(player.media, 'volume', {
+ get() {
+ return volume;
+ },
+ set(input) {
+ volume = input;
+ instance.setVolume(volume * 100);
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ },
+ });
+
+ // Muted
+ let { muted } = player.config;
+ Object.defineProperty(player.media, 'muted', {
+ get() {
+ return muted;
+ },
+ set(input) {
+ const toggle = utils.is.boolean(input) ? input : muted;
+ muted = toggle;
+ instance[toggle ? 'mute' : 'unMute']();
+ utils.dispatchEvent.call(player, player.media, 'volumechange');
+ },
+ });
+
+ // Source
+ Object.defineProperty(player.media, 'currentSrc', {
+ get() {
+ return instance.getVideoUrl();
+ },
+ });
+
+ // Ended
+ Object.defineProperty(player.media, 'ended', {
+ get() {
+ return player.currentTime === player.duration;
+ },
+ });
+
+ // Get available speeds
+ player.options.speed = instance.getAvailablePlaybackRates();
+
+ // Set the tabindex to avoid focus entering iframe
+ if (player.supported.ui) {
+ player.media.setAttribute('tabindex', -1);
+ }
+
+ utils.dispatchEvent.call(player, player.media, 'timeupdate');
+ utils.dispatchEvent.call(player, player.media, 'durationchange');
+
+ // Reset timer
+ clearInterval(player.timers.buffering);
+
+ // Setup buffering
+ player.timers.buffering = setInterval(() => {
+ // Get loaded % from YouTube
+ player.media.buffered = instance.getVideoLoadedFraction();
+
+ // Trigger progress only when we actually buffer something
+ if (player.media.lastBuffered === null || player.media.lastBuffered < player.media.buffered) {
+ utils.dispatchEvent.call(player, player.media, 'progress');
+ }
+
+ // Set last buffer point
+ player.media.lastBuffered = player.media.buffered;
+
+ // Bail if we're at 100%
+ if (player.media.buffered === 1) {
+ clearInterval(player.timers.buffering);
+
+ // Trigger event
+ utils.dispatchEvent.call(player, player.media, 'canplaythrough');
+ }
+ }, 200);
+
+ // Rebuild UI
+ setTimeout(() => ui.build.call(player), 50);
+ },
+ onStateChange(event) {
+ // Get the instance
+ const instance = event.target;
+
+ // Reset timer
+ clearInterval(player.timers.playing);
+
+ // Handle events
+ // -1 Unstarted
+ // 0 Ended
+ // 1 Playing
+ // 2 Paused
+ // 3 Buffering
+ // 5 Video cued
+ switch (event.data) {
+ case 0:
+ player.media.paused = true;
+
+ // YouTube doesn't support loop for a single video, so mimick it.
+ if (player.media.loop) {
+ // YouTube needs a call to `stopVideo` before playing again
+ instance.stopVideo();
+ instance.playVideo();
+ } else {
+ utils.dispatchEvent.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');
+ }
+
+ // Get quality
+ controls.setQualityMenu.call(player, instance.getAvailableQualityLevels());
+
+ break;
+
+ case 2:
+ player.media.paused = true;
+
+ utils.dispatchEvent.call(player, player.media, 'pause');
+
+ break;
+
+ default:
+ break;
+ }
+
+ utils.dispatchEvent.call(player, player.elements.container, 'statechange', false, {
+ code: event.data,
+ });
+ },
+ },
+ });
+ },
+};
+
+export default youtube;