UNPKG

@fpapado/yt-player

Version:

Simple, robust YouTube Iframe Player API

512 lines (419 loc) 17 kB
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, info: true, timeupdateFrequency: 1000 }, opts) this.videoId = null this.startSeconds = 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]) was called before Iframe API loaded, ensure it gets // called again now if (this.videoId) this.load(this.videoId, { startSeconds: this.startSeconds, autoplay: this._autoplay }) }) } load (videoId, { startSeconds, autoplay = false }) { if (this.destroyed) return this.videoId = videoId this.startSeconds = startSeconds this._autoplay = autoplay // 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, startSeconds) } else { this._player.cueVideoById(videoId, startSeconds) } } 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) } getPlaybackRate () { return (this._ready && this._player.getPlaybackRate()) || 1 } 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.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, (err) => { if (!err) return while (loadIframeAPICallbacks.length) { const loadCb = loadIframeAPICallbacks.shift() loadCb(err) } }) } // If ready function is not present, create it if (typeof window.onYouTubeIframeAPIReady !== 'function') { window.onYouTubeIframeAPIReady = () => { 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, 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 ? 1 : 0 : 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: 1, // This parameter indicates whether the player should show related videos // when playback of the initial video ends. Supported values are 0 and 1. // The default value is 1. rel: opts.related ? 1 : 0, // Supported values are 0 and 1. Setting the parameter's value to 0 // causes the player to not display information like the video title and // uploader before the video starts playing. If the player is loading a // playlist, and you explicitly set the parameter value to 1, then, upon // loading, the player will also display thumbnail images for the videos // in the playlist. Note that this functionality is only supported for // the AS3 player. showinfo: opts.info ? 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' }, events: { 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])` 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, { startSeconds: this.startSeconds, autoplay: this._autoplay }) 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 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 = 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