UNPKG

@johnfordman/yt-player

Version:

Simple, robust YouTube Iframe Player API

573 lines (472 loc) 18.9 kB
/*! yt-player. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */ const EventEmitter = require("events").EventEmitter; const loadScript = require("load-script2"); const YOUTUBE_IFRAME_API_SRC = "https://www.youtube.com/iframe_api"; const YOUTUBE_STATES = { "-1": "unstarted", 0: "ended", 1: "playing", 2: "paused", 3: "buffering", 5: "cued", }; const YOUTUBE_ERROR = { // The request contains an invalid parameter value. For example, this error // occurs if you specify a videoId that does not have 11 characters, or if the // videoId contains invalid characters, such as exclamation points or asterisks. INVALID_PARAM: 2, // The requested content cannot be played in an HTML5 player or another error // related to the HTML5 player has occurred. HTML5_ERROR: 5, // The video requested was not found. This error occurs when a video has been // removed (for any reason) or has been marked as private. NOT_FOUND: 100, // The owner of the requested video does not allow it to be played in embedded // players. UNPLAYABLE_1: 101, // This error is the same as 101. It's just a 101 error in disguise! UNPLAYABLE_2: 150, }; const loadIframeAPICallbacks = []; /** * YouTube Player. Exposes a better API, with nicer events. * @param {HTMLElement|selector} element */ class YouTubePlayer extends EventEmitter { constructor(element, opts) { super(); const elem = typeof element === "string" ? document.querySelector(element) : element; if (elem.id) { this._id = elem.id; // use existing element id } else { this._id = elem.id = "ytplayer-" + Math.random().toString(16).slice(2, 8); } this._opts = Object.assign( { width: 640, height: 360, autoplay: false, captions: undefined, controls: true, keyboard: true, fullscreen: true, annotations: true, modestBranding: false, related: true, timeupdateFrequency: 1000, playsInline: true, start: 0, }, opts ); this.videoId = null; this.destroyed = false; this._api = null; this._autoplay = false; // autoplay the first video? this._player = null; this._ready = false; // is player ready? this._queue = []; this._interval = null; // Setup listeners for 'timeupdate' events. The YouTube Player does not fire // 'timeupdate' events, so they are simulated using a setInterval(). this._startInterval = this._startInterval.bind(this); this._stopInterval = this._stopInterval.bind(this); this.on("playing", this._startInterval); this.on("unstarted", this._stopInterval); this.on("ended", this._stopInterval); this.on("paused", this._stopInterval); this.on("buffering", this._stopInterval); this._loadIframeAPI((err, api) => { if (err) return this._destroy(new Error("YouTube Iframe API failed to load")); this._api = api; // If load(videoId, [autoplay, [size]]) was called before Iframe API // loaded, ensure it gets called again now if (this.videoId) this.load(this.videoId, this._autoplay, this._start); }); } load(videoId, autoplay = false, start = 0) { if (this.destroyed) return; this.videoId = videoId; this._autoplay = autoplay; this._start = start; // If the Iframe API is not ready yet, do nothing. Once the Iframe API is // ready, `load(this.videoId)` will be called. if (!this._api) return; // If there is no player instance, create one. if (!this._player) { this._createPlayer(videoId); return; } // If the player instance is not ready yet, do nothing. Once the player // instance is ready, `load(this.videoId)` will be called. This ensures that // the last call to `load()` is the one that takes effect. if (!this._ready) return; // If the player instance is ready, load the given `videoId`. if (autoplay) { this._player.loadVideoById(videoId, start); } else { this._player.cueVideoById(videoId, start); } } play() { if (this._ready) this._player.playVideo(); else this._queueCommand("play"); } pause() { if (this._ready) this._player.pauseVideo(); else this._queueCommand("pause"); } stop() { if (this._ready) this._player.stopVideo(); else this._queueCommand("stop"); } seek(seconds) { if (this._ready) this._player.seekTo(seconds, true); else this._queueCommand("seek", seconds); } setVolume(volume) { if (this._ready) this._player.setVolume(volume); else this._queueCommand("setVolume", volume); } getVolume() { return (this._ready && this._player.getVolume()) || 0; } mute() { if (this._ready) this._player.mute(); else this._queueCommand("mute"); } unMute() { if (this._ready) this._player.unMute(); else this._queueCommand("unMute"); } isMuted() { return (this._ready && this._player.isMuted()) || false; } setSize(width, height) { if (this._ready) this._player.setSize(width, height); else this._queueCommand("setSize", width, height); } setPlaybackRate(rate) { if (this._ready) this._player.setPlaybackRate(rate); else this._queueCommand("setPlaybackRate", rate); } setPlaybackQuality(suggestedQuality) { if (this._ready) this._player.setPlaybackQuality(suggestedQuality); else this._queueCommand("setPlaybackQuality", suggestedQuality); } getPlaybackRate() { return (this._ready && this._player.getPlaybackRate()) || 1; } getCaption() { return (this._ready && this._player.getOption("captions", "track")) || null; } getTest() { return "Hello"; } getAvailablePlaybackRates() { return (this._ready && this._player.getAvailablePlaybackRates()) || [1]; } getDuration() { return (this._ready && this._player.getDuration()) || 0; } getProgress() { return (this._ready && this._player.getVideoLoadedFraction()) || 0; } getState() { return ( (this._ready && YOUTUBE_STATES[this._player.getPlayerState()]) || "unstarted" ); } getCurrentTime() { return (this._ready && this._player.getCurrentTime()) || 0; } destroy() { this._destroy(); } _destroy(err) { if (this.destroyed) return; this.destroyed = true; if (this._player) { this._player.stopVideo && this._player.stopVideo(); this._player.destroy(); } this.videoId = null; this._id = null; this._opts = null; this._api = null; this._player = null; this._ready = false; this._queue = null; this._stopInterval(); this.removeListener("playing", this._startInterval); this.removeListener("paused", this._stopInterval); this.removeListener("buffering", this._stopInterval); this.removeListener("unstarted", this._stopInterval); this.removeListener("ended", this._stopInterval); if (err) this.emit("error", err); } _queueCommand(command, ...args) { if (this.destroyed) return; this._queue.push([command, args]); } _flushQueue() { while (this._queue.length) { const command = this._queue.shift(); this[command[0]].apply(this, command[1]); } } _loadIframeAPI(cb) { // If API is loaded, there is nothing else to do if (window.YT && typeof window.YT.Player === "function") { return cb(null, window.YT); } // Otherwise, queue callback until API is loaded loadIframeAPICallbacks.push(cb); const scripts = Array.from(document.getElementsByTagName("script")); const isLoading = scripts.some( (script) => script.src === YOUTUBE_IFRAME_API_SRC ); // If API <script> tag is not present in the page, inject it. Ensures that // if user includes a hardcoded <script> tag in HTML for performance, another // one will not be added if (!isLoading) { loadScript(YOUTUBE_IFRAME_API_SRC).catch((err) => { while (loadIframeAPICallbacks.length) { const loadCb = loadIframeAPICallbacks.shift(); loadCb(err); } }); } const prevOnYouTubeIframeAPIReady = window.onYouTubeIframeAPIReady; window.onYouTubeIframeAPIReady = () => { if (typeof prevOnYouTubeIframeAPIReady === "function") { prevOnYouTubeIframeAPIReady(); } while (loadIframeAPICallbacks.length) { const loadCb = loadIframeAPICallbacks.shift(); loadCb(null, window.YT); } }; } _createPlayer(videoId) { if (this.destroyed) return; const opts = this._opts; this._player = new this._api.Player(this._id, { width: opts.width, height: opts.height, videoId: videoId, // (Not part of documented API) This parameter controls the hostname that // videos are loaded from. Set to `'https://www.youtube-nocookie.com'` // for enhanced privacy. host: opts.host, playerVars: { // This parameter specifies whether the initial video will automatically // start to play when the player loads. Supported values are 0 or 1. The // default value is 0. autoplay: opts.autoplay ? 1 : 0, // Setting the parameter's value to 1 causes closed captions to be shown // by default, even if the user has turned captions off. The default // behavior is based on user preference. cc_load_policy: opts.captions != null ? (opts.captions !== false ? 1 : 0) : undefined, // default to not setting this option // Sets the player's interface language. The parameter value is an ISO // 639-1 two-letter language code or a fully specified locale. For // example, fr and fr-ca are both valid values. Other language input // codes, such as IETF language tags (BCP 47) might also be handled // properly. hl: opts.captions != null && opts.captions !== false ? opts.captions : undefined, // default to not setting this option // This parameter specifies the default language that the player will // use to display captions. Set the parameter's value to an ISO 639-1 // two-letter language code. cc_lang_pref: opts.captions != null && opts.captions !== false ? opts.captions : undefined, // default to not setting this option // This parameter indicates whether the video player controls are // displayed. For IFrame embeds that load a Flash player, it also defines // when the controls display in the player as well as when the player // will load. Supported values are: // - controls=0 – Player controls do not display in the player. For // IFrame embeds, the Flash player loads immediately. // - controls=1 – (default) Player controls display in the player. For // IFrame embeds, the controls display immediately and // the Flash player also loads immediately. // - controls=2 – Player controls display in the player. For IFrame // embeds, the controls display and the Flash player // loads after the user initiates the video playback. controls: opts.controls ? 2 : 0, // Setting the parameter's value to 1 causes the player to not respond to // keyboard controls. The default value is 0, which means that keyboard // controls are enabled. disablekb: opts.keyboard ? 0 : 1, // Setting the parameter's value to 1 enables the player to be // controlled via IFrame or JavaScript Player API calls. The default // value is 0, which means that the player cannot be controlled using // those APIs. enablejsapi: 1, // Setting this parameter to 0 prevents the fullscreen button from // displaying in the player. The default value is 1, which causes the // fullscreen button to display. fs: opts.fullscreen ? 1 : 0, // Setting the parameter's value to 1 causes video annotations to be // shown by default, whereas setting to 3 causes video annotations to not // be shown by default. The default value is 1. iv_load_policy: opts.annotations ? 1 : 3, // This parameter lets you use a YouTube player that does not show a // YouTube logo. Set the parameter value to 1 to prevent the YouTube logo // from displaying in the control bar. Note that a small YouTube text // label will still display in the upper-right corner of a paused video // when the user's mouse pointer hovers over the player. modestbranding: opts.modestBranding ? 1 : 0, // This parameter provides an extra security measure for the IFrame API // and is only supported for IFrame embeds. If you are using the IFrame // API, which means you are setting the enablejsapi parameter value to 1, // you should always specify your domain as the origin parameter value. origin: window.location.origin, // This parameter controls whether videos play inline or fullscreen in an // HTML5 player on iOS. Valid values are: // - 0: This value causes fullscreen playback. This is currently the // default value, though the default is subject to change. // - 1: This value causes inline playback for UIWebViews created with // the allowsInlineMediaPlayback property set to TRUE. playsinline: opts.playsInline ? 1 : 0, // This parameter indicates whether the player should show related // videos from the same channel (0) or from any channel (1) when // playback of the video ends. The default value is 1. rel: opts.related ? 1 : 0, // (Not part of documented API) Allow html elements with higher z-index // to be shown on top of the YouTube player. wmode: "opaque", // This parameter causes the player to begin playing the video at the given number // of seconds from the start of the video. The parameter value is a positive integer. // Note that similar to the seek function, the player will look for the closest // keyframe to the time you specify. This means that sometimes the play head may seek // to just before the requested time, usually no more than around two seconds. start: opts.start, }, events: { onApiChange: (data) => this._onApiChange(data), onReady: () => this._onReady(videoId), onStateChange: (data) => this._onStateChange(data), onPlaybackQualityChange: (data) => this._onPlaybackQualityChange(data), onPlaybackRateChange: (data) => this._onPlaybackRateChange(data), onError: (data) => this._onError(data), }, }); } /** * This event fires when the player has finished loading and is ready to begin * receiving API calls. */ _onReady(videoId) { if (this.destroyed) return; this._ready = true; // Once the player is ready, always call `load(videoId, [autoplay, [size]])` // to handle these possible cases: // // 1. `load(videoId, true)` was called before the player was ready. Ensure that // the selected video starts to play. // // 2. `load(videoId, false)` was called before the player was ready. Now the // player is ready and there's nothing to do. // // 3. `load(videoId, [autoplay])` was called multiple times before the player // was ready. Therefore, the player was initialized with the wrong videoId, // so load the latest videoId and potentially autoplay it. this.load(this.videoId, this._autoplay, this._start); this._flushQueue(); } /** * Called when the player's state changes. We emit friendly events so the user * doesn't need to use YouTube's YT.PlayerState.* event constants. */ _onStateChange(data) { if (this.destroyed) return; const state = YOUTUBE_STATES[data.data]; if (state) { // Send a 'timeupdate' anytime the state changes. When the video halts for any // reason ('paused', 'buffering', or 'ended') no further 'timeupdate' events // should fire until the video unhalts. if (["paused", "buffering", "ended"].includes(state)) this._onTimeupdate(); this.emit(state); // When the video changes ('unstarted' or 'cued') or starts ('playing') then a // 'timeupdate' should follow afterwards (never before!) to reset the time. if (["unstarted", "playing", "cued"].includes(state)) this._onTimeupdate(); } else { throw new Error("Unrecognized state change: " + data); } } /** * This event fires whenever the video playback quality changes. Possible * values are: 'small', 'medium', 'large', 'hd720', 'hd1080', 'highres'. */ _onPlaybackQualityChange(data) { if (this.destroyed) return; this.emit("playbackQualityChange", data.data); } /** * This event fires whenever the api changes */ _onApiChange(data) { if (this.destroyed) return; this.emit("apiChange", data.data); } /** * This event fires whenever the video playback rate changes. */ _onPlaybackRateChange(data) { if (this.destroyed) return; this.emit("playbackRateChange", data.data); } /** * This event fires if an error occurs in the player. */ _onError(data) { if (this.destroyed) return; const code = Number(data.data); // The HTML5_ERROR error occurs when the YouTube player needs to switch from // HTML5 to Flash to show an ad. Ignore it. if (code === YOUTUBE_ERROR.HTML5_ERROR) return; // The remaining error types occur when the YouTube player cannot play the // given video. This is not a fatal error. Report it as unplayable so the user // has an opportunity to play another video. if ( code === YOUTUBE_ERROR.UNPLAYABLE_1 || code === YOUTUBE_ERROR.UNPLAYABLE_2 || code === YOUTUBE_ERROR.NOT_FOUND || code === YOUTUBE_ERROR.INVALID_PARAM ) { return this.emit("unplayable", this.videoId); } // Unexpected error, does not match any known type this._destroy( new Error("YouTube Player Error. Unknown error code: " + code) ); } /** * This event fires when the time indicated by the `getCurrentTime()` method * has been updated. */ _onTimeupdate() { this.emit("timeupdate", this.getCurrentTime()); } _startInterval() { this._interval = setInterval( () => this._onTimeupdate(), this._opts.timeupdateFrequency ); } _stopInterval() { clearInterval(this._interval); this._interval = null; } } module.exports = YouTubePlayer;