UNPKG

plyr

Version:

A simple, accessible and customizable HTML5, YouTube and Vimeo media player

433 lines (361 loc) 17.7 kB
// ========================================================================== // YouTube plugin // ========================================================================== import ui from '../ui'; import { createElement, replaceElement, toggleClass } from '../utils/elements'; import { triggerEvent } from '../utils/events'; import fetch from '../utils/fetch'; import is from '../utils/is'; import loadImage from '../utils/load-image'; import loadScript from '../utils/load-script'; import { extend } from '../utils/objects'; import { format, generateId } from '../utils/strings'; import { setAspectRatio } from '../utils/style'; // Parse YouTube ID from URL function parseId(url) { if (is.empty(url)) { return null; } const regex = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; return url.match(regex) ? RegExp.$2 : url; } // Set playback state and trigger change (only on actual change) function assurePlaybackState(play) { if (play && !this.embed.hasPlayed) { this.embed.hasPlayed = true; } if (this.media.paused === play) { this.media.paused = !play; triggerEvent.call(this, this.media, play ? 'play' : 'pause'); } } function getHost(config) { if (config.noCookie) { return 'https://www.youtube-nocookie.com'; } if (window.location.protocol === 'http:') { return 'http://www.youtube.com'; } // Use YouTube's default return undefined; } const youtube = { setup() { // Add embed class for responsive toggleClass(this.elements.wrapper, this.config.classNames.embed, true); // Setup API if (is.object(window.YT) && is.function(window.YT.Player)) { youtube.ready.call(this); } else { // Reference current global callback const callback = window.onYouTubeIframeAPIReady; // Set callback to process queue window.onYouTubeIframeAPIReady = () => { // Call global callback if set if (is.function(callback)) { callback(); } youtube.ready.call(this); }; // Load the SDK loadScript(this.config.urls.youtube.sdk).catch(error => { this.debug.warn('YouTube API failed to load', error); }); } }, // Get the media title getTitle(videoId) { const url = format(this.config.urls.youtube.api, videoId); fetch(url) .then(data => { if (is.object(data)) { const { title, height, width } = data; // Set title this.config.title = title; ui.setTitle.call(this); // Set aspect ratio this.embed.ratio = [width, height]; } setAspectRatio.call(this); }) .catch(() => { // Set aspect ratio setAspectRatio.call(this); }); }, // API ready ready() { const player = this; // Ignore already setup (race condition) const currentId = player.media && player.media.getAttribute('id'); if (!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 (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 = parseId(source); const id = generateId(player.provider); // Get poster, if already set const { poster } = player; // Replace media element const container = createElement('div', { id, poster }); player.media = replaceElement(container, player.media); // Id to poster wrapper const posterSrc = s => `https://i.ytimg.com/vi/${videoId}/${s}default.jpg`; // Check thumbnail images in order of quality, but reject fallback thumbnails (120px wide) loadImage(posterSrc('maxres'), 121) // Higest quality and unpadded .catch(() => loadImage(posterSrc('sd'), 121)) // 480p padded 4:3 .catch(() => loadImage(posterSrc('hq'))) // 360p padded 4:3. Always exists .then(image => ui.setPoster.call(player, image.src)) .then(src => { // If the image is padded, use background-size "cover" instead (like youtube does too with their posters) if (!src.includes('maxres')) { player.elements.poster.style.backgroundSize = 'cover'; } }) .catch(() => {}); const config = player.config.youtube; // Setup instance // https://developers.google.com/youtube/iframe_api_reference player.embed = new window.YT.Player(id, { videoId, host: getHost(config), playerVars: extend( {}, { autoplay: player.config.autoplay ? 1 : 0, // Autoplay hl: player.config.hl, // iframe interface language controls: player.supported.ui ? 0 : 1, // Only show controls if not fully supported disablekb: 1, // Disable keyboard as we handle it playsinline: !player.config.fullscreen.iosNative ? 1 : 0, // Allow iOS inline playback // Captions are flaky on YouTube cc_load_policy: player.captions.active ? 1 : 0, cc_lang_pref: player.config.captions.language, // Tracking for stats widget_referrer: window ? window.location.href : null, }, config, ), events: { onError(event) { // YouTube may fire onError twice, so only handle it once if (!player.media.error) { const code = event.data; // Messages copied from https://developers.google.com/youtube/iframe_api_reference#onError const message = { 2: 'The request contains an invalid parameter value. For example, this error occurs if you specify a video ID that does not have 11 characters, or if the video ID contains invalid characters, such as exclamation points or asterisks.', 5: 'The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred.', 100: 'The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private.', 101: 'The owner of the requested video does not allow it to be played in embedded players.', 150: 'The owner of the requested video does not allow it to be played in embedded players.', }[code] || 'An unknown error occured'; player.media.error = { code, message }; triggerEvent.call(player, player.media, 'error'); } }, onPlaybackRateChange(event) { // Get the instance const instance = event.target; // Get current speed player.media.playbackRate = instance.getPlaybackRate(); triggerEvent.call(player, player.media, 'ratechange'); }, onReady(event) { // Bail if onReady has already been called. See issue #1108 if (is.function(player.media.play)) { return; } // 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 = () => { assurePlaybackState.call(player, true); instance.playVideo(); }; player.media.pause = () => { assurePlaybackState.call(player, false); 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) { // If paused and never played, mute audio preventively (YouTube starts playing on seek if the video hasn't been played yet). if (player.paused && !player.embed.hasPlayed) { player.embed.mute(); } // Set seeking state and trigger event player.media.seeking = true; triggerEvent.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); }, }); // Volume let { volume } = player.config; Object.defineProperty(player.media, 'volume', { get() { return volume; }, set(input) { volume = input; instance.setVolume(volume * 100); triggerEvent.call(player, player.media, 'volumechange'); }, }); // Muted let { muted } = player.config; Object.defineProperty(player.media, 'muted', { get() { return muted; }, set(input) { const toggle = is.boolean(input) ? input : muted; muted = toggle; instance[toggle ? 'mute' : 'unMute'](); triggerEvent.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); } triggerEvent.call(player, player.media, 'timeupdate'); triggerEvent.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) { triggerEvent.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 triggerEvent.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); const seeked = player.media.seeking && [1, 2].includes(event.data); if (seeked) { // Unset seeking and fire seeked event player.media.seeking = false; triggerEvent.call(player, player.media, 'seeked'); } // Handle events // -1 Unstarted // 0 Ended // 1 Playing // 2 Paused // 3 Buffering // 5 Video cued switch (event.data) { case -1: // Update scrubber triggerEvent.call(player, player.media, 'timeupdate'); // Get loaded % from YouTube player.media.buffered = instance.getVideoLoadedFraction(); triggerEvent.call(player, player.media, 'progress'); break; case 0: assurePlaybackState.call(player, false); // 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 { triggerEvent.call(player, player.media, 'ended'); } break; case 1: // Restore paused state (YouTube starts playing on seek if the video hasn't been played yet) if (!player.config.autoplay && player.media.paused && !player.embed.hasPlayed) { player.media.pause(); } else { assurePlaybackState.call(player, true); triggerEvent.call(player, player.media, 'playing'); // Poll to get playback progress player.timers.playing = setInterval(() => { triggerEvent.call(player, player.media, 'timeupdate'); }, 50); // Check duration again due to YouTube bug // https://github.com/sampotts/plyr/issues/374 // https://code.google.com/p/gdata-issues/issues/detail?id=8690 if (player.media.duration !== instance.getDuration()) { player.media.duration = instance.getDuration(); triggerEvent.call(player, player.media, 'durationchange'); } } break; case 2: // Restore audio (YouTube starts playing on seek if the video hasn't been played yet) if (!player.muted) { player.embed.unMute(); } assurePlaybackState.call(player, false); break; default: break; } triggerEvent.call(player, player.elements.container, 'statechange', false, { code: event.data, }); }, }, }); }, }; export default youtube;