UNPKG

@odopod/odo-video

Version:
774 lines (676 loc) 21.6 kB
import screenfull from 'screenfull'; import settings from './settings'; import supportTest from './support-test'; import autoplayTest from './autoplay-test'; import Controls from './controls'; /** * Features: * * Autoplay support callbacks. * Skinnable UI controls. * Fullscreen mode. * Click-to-seek. * Preload video. * Dynamically update source. * Hide controls & mouse in fullscreen when idle. * Spacebar pauses video in fullscreen mode. * Function to define control structure. * Callback for updating controls on video progress. */ class Video { constructor(element, options) { /** * @type {Element} */ this.element = element; /** * @type {HTMLVideoElement} */ this.videoEl = this._findVideoElement(); /** * Random id for this instance. */ this.id = Math.random().toString(36).substring(7); /** * Options for this instance. * @type {object} */ this.options = Object.assign({}, Video.Defaults, options); this.isPlaying = this._isPlaying(); this.isFullscreen = false; this._idleTimeout = null; /** * Whether the browser can go in fullscreen mode. This will be true for iOS * because the video element can go fullscreen, but false for IE<11. * @private {boolean} */ this._noFullscreen = false; this.element.classList.toggle(Video.Classes.NO_FLEXBOX, Video.NO_FLEXBOX); // Set id attributes for each source. this._setSourceIds(); this._createControls(); this._saveElements(); this.bindEvents(); if (this._isMetadataLoaded()) { this._setProgressDisplay(); this._setBufferDisplay(); } this.autoplay = Promise.resolve(Video.autoplay); } /** * Bind the correct `this` context to each event listener. The native `bind` * method returns a different function each time `bind` is called, so the value * must be saved in order to remove the correct listener. * @protected */ _bindListeners() { this._onMetadataLoaded = this._handleMetadataLoaded.bind(this); this._onClick = this._handleClick.bind(this); this._onPlay = this._handlePlaying.bind(this); this._onPause = this._handlePaused.bind(this); this._onTimeUpdate = this._handleTimeUpdate.bind(this); this._onProgress = this._handleProgress.bind(this); this._onFullscreenToggle = this.toggleFullscreen.bind(this); this._onFullscreenChange = this._fullscreenChanged.bind(this); this._onVolumeToggle = this.toggleVolume.bind(this); this._onProgressClick = this._handleProgressClick.bind(this); this._onSeeking = this._handleSeeking.bind(this); this._onSeeked = this._handleSeeked.bind(this); this._onMouseMove = this._returnFromIdle.bind(this); this._onIdleTimeout = this._wentIdle.bind(this); this._onKeyboardPlaybackToggle = this._handleKeyboardPlaybackToggle.bind(this); } /** * Add event listeners to the video and UI elements. * @protected */ bindEvents() { this._bindListeners(); this._waitForMetadata(); if (this.options.pauseOnClick) { this.videoEl.addEventListener('click', this._onClick); } // Sent when playback is paused. this.videoEl.addEventListener('pause', this._onPause); // Sent when the media begins to play (either for the first time, after having // been paused, or after ending and then restarting). this.videoEl.addEventListener('playing', this._onPlay); this.videoEl.addEventListener('timeupdate', this._onTimeUpdate); this.videoEl.addEventListener('progress', this._onProgress); // this.videoEl.addEventListener('volumechange', this._onVolumeChange); this.videoEl.addEventListener('seeking', this._onSeeking); this.videoEl.addEventListener('seeked', this._onSeeked); this.getElementByClass(Video.Classes.PLAY_TOGGLE) .addEventListener('click', this._onClick); this.getElementByClass(Video.Classes.VOLUME) .addEventListener('click', this._onVolumeToggle); this.getElementByClass(Video.Classes.PROGRESS_HOLDER) .addEventListener('click', this._onProgressClick); this.getElementByClass(Video.Classes.FULLSCREEN) .addEventListener('click', this._onFullscreenToggle); if (Video.screenfull.enabled) { document.addEventListener( Video.screenfull.raw.fullscreenchange, this._onFullscreenChange, ); document.addEventListener( Video.screenfull.raw.fullscreenerror, this._onFullscreenChange, ); // iOS handles full screen differently. } else if (this.videoEl.webkitSupportsFullscreen) { this.videoEl.addEventListener('webkitbeginfullscreen', this._onFullscreenChange); this.videoEl.addEventListener('webkitendfullscreen', this._onFullscreenChange); } else { this._noFullscreen = true; } } /** * Checks the network state of the video element. Returns true if the metadata * for the video is present. * @return {boolean} * @private */ _isMetadataLoaded() { return this.videoEl.readyState > 0; } /** * Add the `loadedmetadata` event listener if the video has not loaded yet, or * call the event handler directly if it has. * @private */ _waitForMetadata() { if (this._isMetadataLoaded()) { this._handleMetadataLoaded(); } else { this.videoEl.addEventListener('loadedmetadata', this._onMetadataLoaded); } } /** * Save references to UI elements which needs to be constantly updated. * @protected */ _saveElements() { this.currentTimeEl = this.getElementByClass(Video.Classes.CURRENT_TIME); this.progressEl = this.getElementByClass(Video.Classes.PROGRESS); this.bufferEl = this.getElementByClass(Video.Classes.BUFFER); } /** * Separated into its own function so sinon can stub it in the tests. * @return {HTMLVideoElement} * @private */ _findVideoElement() { return this.element.getElementsByTagName('video')[0]; } /** * Whether the video is currently playing. * @return {boolean} */ _isPlaying() { if (this.videoEl.ended || this.videoEl.paused) { return false; } return true; } /** * For each <source> element, set a unique id on it. * @private */ _setSourceIds() { this.getSourceElements().forEach((source) => { source.id = this.id + source.type.split('/')[1]; }); } /** * Retrieve all <source> elements within the video. * @return {Array.<HTMLSourceElement>} */ getSourceElements() { return Array.from(this.videoEl.getElementsByTagName('source')); } /** * Return the main element this component was instantiated with. * @return {Element} */ getElement() { return this.element; } /** * Returns the <video> element associated with this component. * @return {HTMLVideoElement} */ getVideoElement() { return this.videoEl; } /** * Retrieve an element from within the main element by a class name. * @param {string} className Class name to search for. * @return {?Element} The element or null if it isn't found. */ getElementByClass(className) { return this.element.getElementsByClassName(className)[0]; } /** * Play the current video. * https://googlechrome.github.io/samples/play-return-promise/ * @return {?Promise<void>} Chrome 50+, Firefox 53+, and Safari 10+ return a * promise which is rejected if the device cannot autoplay. */ play() { return this.videoEl.play(); } /** * Pause the currently playing video. */ pause() { this.videoEl.pause(); } /** * If playing, pause. If paused, play. */ togglePlayback() { if (this.isPlaying) { this.pause(); } else { this.play(); } } /** * Get the current time, in seconds. * @return {number} */ getCurrentTime() { return this.videoEl.currentTime; } /** * Go to a specified time in the video. * @param {number} time Time in seconds. */ setCurrentTime(time) { this.videoEl.currentTime = time; } /** * Mute the video. */ mute() { this.videoEl.volume = 0; this.element.classList.add(Video.Classes.IS_MUTED); } /** * Unmute the video. */ unmute() { this.videoEl.volume = 1; this.element.classList.remove(Video.Classes.IS_MUTED); } /** * Whether the video is currently muted. * @return {boolean} */ isMuted() { return this.videoEl.volume === 0; } /** * Mute the video if it's playing audio or unmute it if it's already muted. */ toggleVolume() { if (this.isMuted()) { this.unmute(); } else { this.mute(); } } /** * Update the current video source. This method changes the appropriate <source> * `src` property. * * IE9 does not work when the <video>s `src` property or attribute is updated. * * @param {string} src Absolute or relative path to the video, without the extension. * e.g. "/videos/cool-feature". */ updateSource(src) { const { extension } = Video.getVideoType(); const sources = this.getSourceElements(); // Find the <source> element by Id. const source = sources.filter(source => source.id === this.id + extension)[0]; const videoSource = src + '.' + extension; // Update the source's src. source.setAttribute('src', videoSource); this.videoEl.load(); // Reset progress bars. this._setProgressDisplay(); this._setBufferDisplay(); // When metadata loads, update the times. this._waitForMetadata(); } /** * Show the video is currently buffering. * @protected */ showBuffering() { this.element.classList.add(Video.Classes.IS_BUFFERING); } /** * Show the video is done buffering. * @protected */ hideBuffering() { this.element.classList.remove(Video.Classes.IS_BUFFERING); } /** * Converts a time value to MMSS, so 91 seconds is "1:31". * @param {number} currentTime Time in seconds. * @return {string} Human readable time. */ static getPrettyTime(currentTime) { const hours = Math.floor(currentTime / 3600); const minutes = Math.floor((currentTime - (hours * 3600)) / 60); let seconds = currentTime - (hours * 3600) - (minutes * 60); if (seconds < 10) { seconds = '0' + seconds; } let time = minutes + ':' + seconds; if (hours > 0) { if (minutes < 10) { time = '0' + time; } time = hours + ':' + time; } return time; } /** * Generate the controls elemenet and append it to the main element. * @private */ _createControls() { const controls = new Video.ControlsCreator(); const mainElement = controls.create(this.options.controls, this.options.layoutControls); this.element.appendChild(mainElement); } /** * Update the controls state/display with the new current time. * @private */ _updateControls() { const seconds = this.getCurrentTime(); this._setCurrentTimeDisplay(seconds); this._setProgressDisplay(); // Give a hook to update custom controls. if (this.options.updateControls) { this.options.updateControls(seconds); } } /** * Sets the current time element's text to a pretty time given a float. * @param {number} seconds The current time of the video. */ _setCurrentTimeDisplay(seconds) { this.currentTimeEl.textContent = Video.getPrettyTime(Math.round(seconds)); } /** * Set the progress elements to a percentage. * @param {number} percentage Number between 0 and 1. If undefined, the percentage * will be calculated from the video's current time and duration. * @private */ _setProgressDisplay(percentage = this.videoEl.currentTime / this.videoEl.duration) { this.progressEl.style.width = percentage * 100 + '%'; } /** * Determine the amount the video has buffered and set the buffer element's width * as that percentage. * @private */ _setBufferDisplay() { // Progress events can be emitted before there is anything buffered. const percentage = this.videoEl.buffered.length > 0 ? this.videoEl.buffered.end(0) / this.videoEl.duration : 0; this.bufferEl.style.width = percentage * 100 + '%'; } /** * Clicked the fullscreen icon. Request or exit from fullscreen. */ toggleFullscreen() { if (Video.screenfull) { // Handle it the standardized way. Video.screenfull.toggle(this.element); } else if (this._noFullscreen) { // Support: IE<11. Since there won't be any entered/exited fullscreen events, // that callback must be invoked immediately. this._fullscreenChanged(); } else { // Since the native video controls will be shown and not the Video // custom controls, it can be assumed that this event handler will only // be triggered by requesting fullscreen. this.videoEl.webkitEnterFullscreen(); } } /** * Event handler for both fullscreenchange and fullscreenerror. The state of * fullscreen has changed and styles need to be updated. * @private */ _fullscreenChanged() { this.isFullscreen = !this.isFullscreen; this._toggleFullscreenState(); } _toggleFullscreenState() { this.element.classList.toggle(Video.Classes.IS_FULLSCREEN, this.isFullscreen); if (this.isFullscreen) { this._startIdleTimer(); // Take focus off the fullscreen button because the spacebar should pause // the video, but when it's focused, it will exit fullscreen. document.activeElement.blur(); this.element.focus(); this.element.addEventListener('mousemove', this._onMouseMove); // Use keyup instead of keypress because special keys (control, shift, alt, etc.) // do not trigger the keypress event. document.addEventListener('keyup', this._onKeyboardPlaybackToggle); } else { this._returnFromIdle(); // Return from idle starts a new timer, which isn't wanted when closing fullscreen. clearTimeout(this._idleTimeout); this.element.removeEventListener('mousemove', this._onMouseMove); document.removeEventListener('keyup', this._onKeyboardPlaybackToggle); } } /** * When the user stops interacting with the page, the cursor should disappear and * the controls should be hidden. * @private */ _wentIdle() { this.element.classList.add(Video.Classes.IS_IDLE); } /** * When returning from the idle state, the cursor and controls should be shown. * Another timer is started to wait for idle state again. * @private */ _returnFromIdle() { clearTimeout(this._idleTimeout); this._startIdleTimer(); this.element.classList.remove(Video.Classes.IS_IDLE); } /** * Sets a timeout which will trigger the `wentIdle` method when it expires. * @private */ _startIdleTimer() { this._idleTimeout = setTimeout(this._onIdleTimeout, Video.IDLE_TIMEOUT); } /** * Calculate the position of the click relative to the element the click listener * was bound to. * @param {MouseEvent} event Event object. * @return {{x: number, y: number}} * @private */ static _getClickOffset(event) { if ('offsetX' in event) { return { x: event.offsetX, y: event.offsetY, }; } const rect = event.currentTarget.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top, }; } /** * Event handler for the `loadedmetadata` event. Update UI with new data. * @private */ _handleMetadataLoaded() { this.videoEl.removeEventListener('loadedmetadata', this._onMetadataLoaded); this._setCurrentTimeDisplay(this.videoEl.duration); } /** * Clicked on the main element. Toggle video playback. */ _handleClick() { this.togglePlayback(); } /** * Event handler for the `playing` event. Add the is-playing class. * @private */ _handlePlaying() { this.isPlaying = true; this.element.classList.toggle(Video.Classes.IS_PLAYING, this.isPlaying); } /** * Event handler for the `paused` event. Remove the is-playing class. * @private */ _handlePaused() { this.isPlaying = false; this.element.classList.toggle(Video.Classes.IS_PLAYING, this.isPlaying); } /** * Event handler for the `timeupdate` event. Update the UI. * @private */ _handleTimeUpdate() { this._updateControls(); } /** * Event handler for the `progress` event. Update the buffer display bar. * @private */ _handleProgress() { this._setBufferDisplay(); } /** * Clicked the progress bar track. Seek to the location of the click. * @param {MouseEvent} evt Event object. * @private */ _handleProgressClick(evt) { const offset = Video._getClickOffset(evt); const width = evt.currentTarget.offsetWidth; const percent = offset.x / width; this._setProgressDisplay(percent); this.setCurrentTime(percent * this.videoEl.duration); } /** * Video started seeking. Show the user it's buffering. * @private */ _handleSeeking() { // Avoid adding the buffering class when the video is looping - issue #1 if (this.videoEl.buffered.length === 0 || this.videoEl.buffered.end(0) < this.videoEl.currentTime) { this.showBuffering(); } } /** * Video stopped seeking. Show the user it's ready to play. * @private */ _handleSeeked() { this.hideBuffering(); } /** * If the spacebar is pressed, toggle playback. * @param {KeyboardEvent} evt Event object. * @private */ _handleKeyboardPlaybackToggle(evt) { // Trigger the controls to show again. this._returnFromIdle(); // Spacebar. if (Video._getWhichKey(evt) === 32) { this.togglePlayback(); evt.preventDefault(); } } /** * Crossbrowser way to get the key which was pressed in a keyboard event. * @param {KeyboardEvent} event Event object. * @return {number} * @private */ static _getWhichKey(event) { if (event.which) { return event.which; } return event.charCode || event.keyCode; } /** * Handler for triggering events on readyState or video data events * @param {Object} event Object containing event name and readyState. * @param {function():void} cb Callback function for event. * @public */ listenOnData(event, cb) { let loaded; if (this.videoEl.readyState > event.readyState) { cb(); } else { this.videoEl.addEventListener(event.name, loaded = () => { this.videoEl.removeEventListener(event.name, loaded); cb(); }); } } /** * Remove all event listeners, references to DOM elements, and generated elements. */ dispose() { this.videoEl.removeEventListener('click', this._onClick); this.videoEl.removeEventListener('pause', this._onPause); this.videoEl.removeEventListener('playing', this._onPlay); this.videoEl.removeEventListener('timeupdate', this._onTimeUpdate); this.videoEl.removeEventListener('progress', this._onProgress); this.videoEl.removeEventListener('seeking', this._onSeeking); this.videoEl.removeEventListener('seeked', this._onSeeked); this.videoEl.removeEventListener('loadedmetadata', this._onMetadataLoaded); this.getElementByClass(Video.Classes.PLAY_TOGGLE) .removeEventListener('click', this._onClick); this.getElementByClass(Video.Classes.VOLUME) .removeEventListener('click', this._onVolumeToggle); this.getElementByClass(Video.Classes.PROGRESS_HOLDER) .removeEventListener('click', this._onProgressClick); this.getElementByClass(Video.Classes.FULLSCREEN) .removeEventListener('click', this._onFullscreenToggle); const fullscreenChange = this._onFullscreenChange; if (Video.screenfull.enabled) { document.removeEventListener(Video.screenfull.raw.fullscreenchange, fullscreenChange); document.removeEventListener(Video.screenfull.raw.fullscreenerror, fullscreenChange); } else if (this.videoEl.webkitSupportsFullscreen) { this.videoEl.removeEventListener('webkitbeginfullscreen', fullscreenChange); this.videoEl.removeEventListener('webkitendfullscreen', fullscreenChange); } this.element.removeChild(this.getElementByClass(Video.Classes.CONTROLS)); this.currentTimeEl = null; this.progressEl = null; this.bufferEl = null; this.element = null; this.videoEl = null; } /** * Get the media type and extension for the currently supported video types. * @return {{extension: string, type: string}} */ static getVideoType() { // Webm is best. if (Video.support.webm) { return { extension: 'webm', type: 'video/webm', }; } return { extension: 'mp4', type: 'video/mp4', }; } } /* Merge in settings */ Object.assign(Video, settings); /** * Determine support for videos. It is a boolean value with properties for * h264, webm, vp9, ogg, hls. * @type {Boolean} */ Video.support = supportTest(); /** * An async test for autoplay feature. * @type {Promise} */ Video.autoplay = autoplayTest(Video.support); Video._autoplayTest = autoplayTest; /** @type {Controls} */ Video.ControlsCreator = Controls; /** @type {boolean} IE9 is the only supported browser without flexbox. */ /* istanbul ignore next */ Video.NO_FLEXBOX = document.all && document.addEventListener && !window.atob; /** @type {boolean|Object} Expose for testing. */ Video.screenfull = screenfull; export default Video;