@fpapado/yt-player
Version:
Simple, robust YouTube Iframe Player API
512 lines (419 loc) • 17 kB
JavaScript
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