diff options
Diffstat (limited to 'src/js/plugins')
-rw-r--r-- | src/js/plugins/ads.js | 574 | ||||
-rw-r--r-- | src/js/plugins/vimeo.js | 323 | ||||
-rw-r--r-- | src/js/plugins/youtube.js | 417 |
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; |