Home Reference Source

src/js/videojs.wavesurfer.js

/**
 * @file videojs.wavesurfer.js
 *
 * The main file for the videojs-wavesurfer project.
 * MIT license: https://github.com/collab-project/videojs-wavesurfer/blob/master/LICENSE
 */

import Event from './event';
import log from './utils/log';
import formatTime from './utils/format-time';
import pluginDefaultOptions from './defaults';
import WavesurferMiddleware from './middleware';
import window from 'global/window';

import videojs from 'video.js';
import WaveSurfer from 'wavesurfer.js';

const Plugin = videojs.getPlugin('plugin');

const wavesurferPluginName = 'wavesurfer';
const wavesurferClassName = 'vjs-wavedisplay';
const wavesurferStyleName = 'vjs-wavesurfer';

// wavesurfer.js backends
const WEBAUDIO = 'WebAudio';
const MEDIAELEMENT = 'MediaElement';
const MEDIAELEMENT_WEBAUDIO = 'MediaElementWebAudio';

/**
 * Draw a waveform for audio and video files in a video.js player.
 *
 * @class
 * @augments videojs.Plugin
 */
class Wavesurfer extends Plugin {
    /**
     * The constructor function for the class.
     *
     * @param {(videojs.Player|Object)} player - video.js Player object.
     * @param {Object} options - Player options.
     */
    constructor(player, options) {
        super(player, options);

        // add plugin style
        player.addClass(wavesurferStyleName);

        // parse options
        if (videojs.obj !== undefined) {
            // video.js v8 and newer
            options = videojs.obj.merge(pluginDefaultOptions, options);
        } else {
            options = videojs.mergeOptions(pluginDefaultOptions, options);
        }
        this.waveReady = false;
        this.waveFinished = false;
        this.liveMode = false;
        this.backend = null;
        this.debug = (options.debug.toString() === 'true');
        this.textTracksEnabled = (this.player.options_.tracks.length > 0);
        this.displayMilliseconds = options.displayMilliseconds;

        // use custom time format for video.js player
        if (options.formatTime && typeof options.formatTime === 'function') {
            // user-supplied formatTime
            this.setFormatTime(options.formatTime);
        } else {
            // plugin's default formatTime
            this.setFormatTime((seconds, guide) => {
                return formatTime(seconds, guide, this.displayMilliseconds);
            });
        }

        // wait until player ui is ready
        this.player.one(Event.READY, this.initialize.bind(this));
    }

    /**
     * Player UI is ready: customize controls.
     *
     * @private
     */
    initialize() {
        // hide big play button
        if (this.player.bigPlayButton !== undefined) {
            this.player.bigPlayButton.hide();
        }

        // parse options
        let mergedOptions = this.parseOptions(this.player.options_.plugins.wavesurfer);

        // controls
        if (this.player.options_.controls === true) {
            // make sure controlBar is showing.
            // video.js hides the controlbar by default because it expects
            // the user to click on the 'big play button' first.
            this.player.controlBar.show();
            this.player.controlBar.el_.style.display = 'flex';

            // progress control is only supported with the MediaElement backend
            if (this.backend === WEBAUDIO &&
                this.player.controlBar.progressControl !== undefined) {
                this.player.controlBar.progressControl.hide();
            }

            // disable Picture-In-Picture toggle introduced in video.js 7.6.0
            // until there is support for canvas in the Picture-In-Picture
            // browser API (see https://www.chromestatus.com/features/4844605453369344)
            if (this.player.controlBar.pictureInPictureToggle !== undefined) {
                this.player.controlBar.pictureInPictureToggle.hide();
            }

            // make sure time displays are visible
            let uiElements = ['currentTimeDisplay', 'timeDivider', 'durationDisplay'];
            uiElements.forEach((element) => {
                // ignore and show when essential elements have been disabled
                // by user
                element = this.player.controlBar[element];
                if (element !== undefined) {
                    element.el_.style.display = 'block';
                    element.show();
                }
            });
            if (this.player.controlBar.remainingTimeDisplay !== undefined) {
                this.player.controlBar.remainingTimeDisplay.hide();
            }

            if (this.backend === WEBAUDIO &&
                this.player.controlBar.playToggle !== undefined) {
                // handle play toggle interaction
                this.player.controlBar.playToggle.on(['tap', 'click'],
                    this.onPlayToggle.bind(this));

                // disable play button until waveform is ready
                this.player.controlBar.playToggle.hide();
            }
        }

        // wavesurfer.js setup
        this.surfer = WaveSurfer.create(mergedOptions);
        this.surfer.on(Event.ERROR, this.onWaveError.bind(this));
        this.surfer.on(Event.FINISH, this.onWaveFinish.bind(this));
        this.backend = this.surfer.params.backend;
        this.log('Using wavesurfer.js ' + this.backend + ' backend.');

        // check if the wavesurfer.js microphone plugin is enabled
        if ('microphone' in this.player.wavesurfer().surfer.getActivePlugins()) {
            // enable audio input from a microphone
            this.liveMode = true;
            this.waveReady = true;
            this.log('wavesurfer.js microphone plugin enabled.');

            // in live mode, show play button at startup
            this.player.controlBar.playToggle.show();

            // listen for wavesurfer.js microphone plugin events
            this.surfer.microphone.on(Event.DEVICE_ERROR,
                this.onWaveError.bind(this));
        }

        // listen for wavesurfer.js events
        this.surferReady = this.onWaveReady.bind(this);
        if (this.backend === WEBAUDIO) {
            this.surferProgress = this.onWaveProgress.bind(this);
            this.surferSeek = this.onWaveSeek.bind(this);

            // make sure volume is muted when requested
            if (this.player.muted()) {
                this.setVolume(0);
            }
        }

        // only listen to the wavesurfer.js playback events when not
        // in live mode
        if (!this.liveMode) {
            this.setupPlaybackEvents(true);
        }

        // video.js player events
        this.player.on(Event.VOLUMECHANGE, this.onVolumeChange.bind(this));
        this.player.on(Event.FULLSCREENCHANGE, this.onScreenChange.bind(this));

        // video.js fluid option
        if (this.player.options_.fluid === true) {
            // give wave element a classname so it can be styled
            this.surfer.drawer.wrapper.className = wavesurferClassName;
        }
    }

    /**
     * Initializes the waveform options.
     *
     * @private
     * @param {Object} surferOpts - Plugin options.
     * @returns {Object} - Updated `surferOpts` object.
     */
    parseOptions(surferOpts = {}) {
        let rect = this.player.el_.getBoundingClientRect();
        this.originalWidth = this.player.options_.width || rect.width;
        this.originalHeight = this.player.options_.height || rect.height;

        // controlbar
        let controlBarHeight = this.player.controlBar.height();
        if (this.player.options_.controls === true && controlBarHeight === 0) {
            // the dimensions of the controlbar are not known yet, but we
            // need it now, so we can calculate the height of the waveform.
            // The default height is 30px, so use that instead.
            controlBarHeight = 30;
        }

        // set waveform element and dimensions
        // Set the container to player's container if "container" option is
        // not provided. If a waveform needs to be appended to your custom
        // element, then use below option. For example:
        // container: document.querySelector("#vjs-waveform")
        if (surferOpts.container === undefined) {
            surferOpts.container = this.player.el_;
        }

        // set the height of generated waveform if user has provided height
        // from options. If height of waveform need to be customized then use
        // option below. For example: waveformHeight: 30
        if (surferOpts.waveformHeight === undefined) {
            let playerHeight = rect.height;
            surferOpts.height = playerHeight - controlBarHeight;
        } else {
            surferOpts.height = surferOpts.waveformHeight;
        }

        // split channels
        if (surferOpts.splitChannels && surferOpts.splitChannels === true) {
            surferOpts.height /= 2;
        }

        // use MediaElement as default wavesurfer.js backend if one is not
        // specified
        if ('backend' in surferOpts) {
            this.backend = surferOpts.backend;
        } else {
            surferOpts.backend = this.backend = MEDIAELEMENT;
        }

        return surferOpts;
    }

    /**
     * Starts or stops listening to events related to audio-playback.
     *
     * @param {boolean} enable - Start or stop listening to playback
     *     related events.
     * @private
     */
    setupPlaybackEvents(enable) {
        if (enable === false) {
            this.surfer.un(Event.READY, this.surferReady);
            if (this.backend === WEBAUDIO) {
                this.surfer.un(Event.AUDIOPROCESS, this.surferProgress);
                this.surfer.un(Event.SEEK, this.surferSeek);
            }
        } else if (enable === true) {
            this.surfer.on(Event.READY, this.surferReady);
            if (this.backend === WEBAUDIO) {
                this.surfer.on(Event.AUDIOPROCESS, this.surferProgress);
                this.surfer.on(Event.SEEK, this.surferSeek);
            }
        }
    }

    /**
     * Start loading waveform data.
     *
     * @param {string|blob|file} url - Either the URL of the audio file,
     *     a Blob or a File object.
     * @param {string|number[]} peaks - Either the URL of peaks
     *     data for the audio file, or an array with peaks data.
     */
    load(url, peaks) {
        if (url instanceof Blob || url instanceof File) {
            this.log('Loading object: ' + JSON.stringify(url));
            this.surfer.loadBlob(url);
        } else {
            // load peak data from array or file
            if (peaks !== undefined) {
                this.loadPeaks(url, peaks);
            } else {
                // no peaks
                if (typeof url === 'string') {
                    this.log('Loading URL: ' + url);
                } else {
                    this.log('Loading element: ' + url);
                }
                this.surfer.load(url);
            }
        }
    }

    /**
     * Start loading waveform data.
     *
     * @param {string|blob|file} url - Either the URL of the audio file,
     *     a Blob or a File object.
     * @param {string|number[]} peaks - Either the URL of peaks
     *     data for the audio file, or an array with peaks data.
     */
    loadPeaks(url, peaks) {
        if (Array.isArray(peaks)) {
            // use supplied peaks data
            this.log('Loading URL with array of peaks: ' + url);
            this.surfer.load(url, peaks);
        } else {
            // load peak data from file
            let requestOptions = {
                url: peaks,
                responseType: 'json'
            };

            // supply xhr options, if any
            if (this.player.options_.plugins.wavesurfer.xhr !== undefined) {
                requestOptions.xhr = this.player.options_.plugins.wavesurfer.xhr;
            }
            let request = WaveSurfer.util.fetchFile(requestOptions);

            request.once('success', data => {
                this.log('Loaded Peak Data URL: ' + peaks);
                // check for data property containing peaks
                if (data && data.data) {
                    this.surfer.load(url, data.data);
                } else {
                    this.player.trigger(Event.ERROR,
                        'Could not load peaks data from ' + peaks);
                    this.log(err, 'error');
                }
            });
            request.once('error', e => {
                this.player.trigger(Event.ERROR,
                    'Unable to retrieve peak data from ' + peaks +
                    '. Status code: ' + request.response.status);
            });
        }
    }

    /**
     * Start/resume playback or microphone.
     */
    play() {
        // show pause button
        if (this.player.controlBar.playToggle !== undefined &&
            this.player.controlBar.playToggle.contentEl()) {
            this.player.controlBar.playToggle.handlePlay();
        }

        if (this.liveMode) {
            // start/resume microphone visualization
            if (!this.surfer.microphone.active)
            {
                this.log('Start microphone');
                this.surfer.microphone.start();
            } else {
                // toggle paused
                let paused = !this.surfer.microphone.paused;

                if (paused) {
                    this.pause();
                } else {
                    this.log('Resume microphone');
                    this.surfer.microphone.play();
                }
            }
        } else {
            this.log('Start playback');

            // put video.js player UI in playback mode
            this.player.play();

            // start surfer playback
            this.surfer.play();
        }
    }

    /**
     * Pauses playback or microphone visualization.
     */
    pause() {
        // show play button
        if (this.player.controlBar.playToggle !== undefined &&
            this.player.controlBar.playToggle.contentEl()) {
            this.player.controlBar.playToggle.handlePause();
        }

        if (this.liveMode) {
            // pause microphone visualization
            this.log('Pause microphone');
            this.surfer.microphone.pause();
        } else {
            // pause playback
            this.log('Pause playback');

            if (!this.waveFinished) {
                // pause wavesurfer playback
                this.surfer.pause();
            } else {
                this.waveFinished = false;
            }

            this.setCurrentTime();
        }
    }

    /**
     * @private
     */
    dispose() {
        if (this.surfer) {
            if (this.liveMode && this.surfer.microphone) {
                // destroy microphone plugin
                this.surfer.microphone.destroy();
                this.log('Destroyed microphone plugin');
            }
            // destroy wavesurfer instance
            this.surfer.destroy();
        }
        this.log('Destroyed plugin');
    }

    /**
     * Indicates whether the plugin is destroyed or not.
     *
     * @return {boolean} Plugin destroyed or not.
     */
    isDestroyed() {
        return this.player && (this.player.children() === null);
    }

    /**
     * Remove the player and waveform.
     */
    destroy() {
        this.player.dispose();
    }

    /**
     * Set the volume level.
     *
     * @param {number} volume - The new volume level.
     */
    setVolume(volume) {
        if (volume !== undefined) {
            this.log('Changing volume to: ' + volume);

            // update player volume
            this.player.volume(volume);
        }
    }

    /**
     * Save waveform image as data URI.
     *
     * The default format is `'image/png'`. Other supported types are
     * `'image/jpeg'` and `'image/webp'`.
     *
     * @param {string} format='image/png' A string indicating the image format.
     * The default format type is `'image/png'`.
     * @param {number} quality=1 A number between 0 and 1 indicating the image
     * quality to use for image formats that use lossy compression such as
     * `'image/jpeg'`` and `'image/webp'`.
     * @param {string} type Image data type to return. Either 'blob' (default)
     * or 'dataURL'.
     * @return {string|string[]|Promise} When using `'dataURL'` `type` this returns
     * a single data URL or an array of data URLs, one for each canvas. The `'blob'`
     * `type` returns a `Promise` resolving with an array of `Blob` instances, one
     * for each canvas.
     */
    exportImage(format, quality, type = 'blob') {
        return this.surfer.exportImage(format, quality, type);
    }

    /**
     * Change the audio output device.
     *
     * @param {string} deviceId - Id of audio output device.
     */
    setAudioOutput(deviceId) {
        if (deviceId) {
            this.surfer.setSinkId(deviceId).then((result) => {
                // notify listeners
                this.player.trigger(Event.AUDIO_OUTPUT_READY);
            }).catch((err) => {
                // notify listeners
                this.player.trigger(Event.ERROR, err);

                this.log(err, 'error');
            });
        }
    }

    /**
     * Get the current time (in seconds) of the stream during playback.
     *
     * Returns 0 if no stream is available (yet).
     *
     * @returns {float} Current time of the stream.
     */
    getCurrentTime() {
        let currentTime = this.surfer.getCurrentTime();
        currentTime = isNaN(currentTime) ? 0 : currentTime;

        return currentTime;
    }

    /**
     * Updates the player's element displaying the current time.
     *
     * @param {number} [currentTime] - Current position of the playhead
     *     (in seconds).
     * @param {number} [duration] - Duration of the waveform (in seconds).
     * @private
     */
    setCurrentTime(currentTime, duration) {
        if (currentTime === undefined) {
            currentTime = this.surfer.getCurrentTime();
        }

        if (duration === undefined) {
            duration = this.surfer.getDuration();
        }

        currentTime = isNaN(currentTime) ? 0 : currentTime;
        duration = isNaN(duration) ? 0 : duration;

        // update current time display component
        if (this.player.controlBar.currentTimeDisplay &&
            this.player.controlBar.currentTimeDisplay.contentEl() &&
            this.player.controlBar.currentTimeDisplay.contentEl().lastChild) {
            let time = Math.min(currentTime, duration);

            this.player.controlBar.currentTimeDisplay.formattedTime_ =
                this.player.controlBar.currentTimeDisplay.contentEl().lastChild.textContent =
                    this._formatTime(time, duration, this.displayMilliseconds);
        }

        if (this.textTracksEnabled && this.player.tech_ && this.player.tech_.el_) {
            // only needed for text tracks
            this.player.tech_.setCurrentTime(currentTime);
        }
    }

    /**
     * Get the duration of the stream in seconds.
     *
     * Returns 0 if no stream is available (yet).
     *
     * @returns {float} Duration of the stream.
     */
    getDuration() {
        let duration = this.surfer.getDuration();
        duration = isNaN(duration) ? 0 : duration;

        return duration;
    }

    /**
     * Updates the player's element displaying the duration time.
     *
     * @param {number} [duration] - Duration of the waveform (in seconds).
     * @private
     */
    setDuration(duration) {
        if (duration === undefined) {
            duration = this.surfer.getDuration();
        }
        duration = isNaN(duration) ? 0 : duration;

        // update duration display component
        if (this.player.controlBar.durationDisplay &&
            this.player.controlBar.durationDisplay.contentEl() &&
            this.player.controlBar.durationDisplay.contentEl().lastChild) {
            this.player.controlBar.durationDisplay.formattedTime_ =
                this.player.controlBar.durationDisplay.contentEl().lastChild.textContent =
                    this._formatTime(duration, duration, this.displayMilliseconds);
        }
    }

    /**
     * Audio is loaded, decoded and the waveform is drawn.
     *
     * @fires waveReady
     * @private
     */
    onWaveReady() {
        this.waveReady = true;
        this.waveFinished = false;
        this.liveMode = false;

        this.log('Waveform is ready');
        this.player.trigger(Event.WAVE_READY);

        if (this.backend === WEBAUDIO) {
            // update time display
            this.setCurrentTime();
            this.setDuration();

            // enable and show play button
            if (this.player.controlBar.playToggle !== undefined &&
                this.player.controlBar.playToggle.contentEl()) {
                this.player.controlBar.playToggle.show();
            }
        }

        // hide loading spinner
        if (this.player.loadingSpinner.contentEl()) {
            this.player.loadingSpinner.hide();
        }

        // auto-play when ready (if enabled)
        if (this.player.options_.autoplay === true) {
            // autoplay is only allowed when audio is muted
            this.setVolume(0);

            // try auto-play
            if (this.backend === WEBAUDIO) {
                this.play();
            } else {
                this.player.play().catch(e => {
                    this.onWaveError(e);
                });
            }
        }
    }

    /**
     * Fires when audio playback completed.
     *
     * @fires playbackFinish
     * @private
     */
    onWaveFinish() {
        this.log('Finished playback');

        // notify listeners
        this.player.trigger(Event.PLAYBACK_FINISH);

        // check if loop is enabled
        if (this.player.options_.loop === true) {
            if (this.backend === WEBAUDIO) {
                // reset waveform
                this.surfer.stop();
                this.play();
            }
        } else {
            // finished
            this.waveFinished = true;

            if (this.backend === WEBAUDIO) {
                // pause player
                this.pause();

                // show the replay state of play toggle
                this.player.trigger(Event.ENDED);

                // this gets called once after the clip has ended and the user
                // seeks so that we can change the replay button back to a play
                // button
                this.surfer.once(Event.SEEK, () => {
                    if (this.player.controlBar.playToggle !== undefined) {
                        this.player.controlBar.playToggle.removeClass('vjs-ended');
                    }
                    this.player.trigger(Event.PAUSE);
                });
            }
        }
    }

    /**
     * Fires continuously during audio playback.
     *
     * @param {number} time - Current time/location of the playhead.
     * @private
     */
    onWaveProgress(time) {
        this.setCurrentTime();
    }

    /**
     * Fires during seeking of the waveform.
     *
     * @private
     */
    onWaveSeek() {
        this.setCurrentTime();
    }

    /**
     * Waveform error.
     *
     * @param {string} error - The wavesurfer error.
     * @private
     */
    onWaveError(error) {
        // notify listeners
        if (error.name && error.name === 'AbortError' ||
            error.name === 'DOMException' && error.message.startsWith('The operation was aborted'))
        {
            this.player.trigger(Event.ABORT, error);
        } else {
            this.player.trigger(Event.ERROR, error);

            this.log(error, 'error');
        }
    }

    /**
     * Fired when the play toggle is clicked.
     * @private
     */
    onPlayToggle() {
        if (this.player.controlBar.playToggle !== undefined &&
            this.player.controlBar.playToggle.hasClass('vjs-ended')) {
            this.player.controlBar.playToggle.removeClass('vjs-ended');
        }
        if (this.surfer.isPlaying()) {
            this.pause();
        } else {
            this.play();
        }
    }

    /**
     * Fired when the volume in the video.js player changes.
     * @private
     */
    onVolumeChange() {
        let volume = this.player.volume();
        if (this.player.muted()) {
            // muted volume
            volume = 0;
        }

        // update wavesurfer.js volume
        this.surfer.setVolume(volume);
    }

    /**
     * Fired when the video.js player switches in or out of fullscreen mode.
     * @private
     */
    onScreenChange() {
        // execute with tiny delay so the player element completes
        // rendering and correct dimensions are reported
        let fullscreenDelay = this.player.setInterval(() => {
            let isFullscreen = this.player.isFullscreen();
            let newWidth, newHeight;
            if (!isFullscreen) {
                // restore original dimensions
                newWidth = this.originalWidth;
                newHeight = this.originalHeight;
            }

            if (this.waveReady) {
                if (this.liveMode && !this.surfer.microphone.active) {
                    // we're in live mode but the microphone hasn't been
                    // started yet
                    return;
                }
                // redraw
                this.redrawWaveform(newWidth, newHeight);
            }

            // stop fullscreenDelay interval
            this.player.clearInterval(fullscreenDelay);

        }, 100);
    }

    /**
     * Redraw waveform.
     *
     * @param {number} [newWidth] - New width for the waveform.
     * @param {number} [newHeight] - New height for the waveform.
     * @private
     */
    redrawWaveform(newWidth, newHeight) {
        if (!this.isDestroyed()) {
            if (this.player.el_) {
                let rect = this.player.el_.getBoundingClientRect();
                if (newWidth === undefined) {
                    // get player width
                    newWidth = rect.width;
                }
                if (newHeight === undefined) {
                    // get player height
                    newHeight = rect.height;
                }
            }

            // destroy old drawing
            this.surfer.drawer.destroy();

            // set new dimensions
            this.surfer.params.width = newWidth;
            this.surfer.params.height = newHeight - this.player.controlBar.height();

            // redraw waveform
            this.surfer.createDrawer();
            this.surfer.drawer.wrapper.className = wavesurferClassName;
            this.surfer.drawBuffer();

            // make sure playhead is restored at right position
            this.surfer.drawer.progress(this.surfer.backend.getPlayedPercents());
        }
    }

    /**
     * Log message to console (if the debug option is enabled).
     *
     * @private
     * @param {Array} args - The arguments to be passed to the matching console
     *     method.
     * @param {string} logType - The name of the console method to use.
     */
    log(args, logType) {
        log(args, logType, this.debug);
    }

    /**
     * Replaces the default `formatTime` implementation with a custom implementation.
     *
     * @param {function} customImplementation - A function which will be used in place
     *     of the default `formatTime` implementation. Will receive the current time
     *     in seconds and the guide (in seconds) as arguments.
     */
    setFormatTime(customImplementation) {
        this._formatTime = customImplementation;

        if (videojs.time) {
            // video.js v8 and newer
            videojs.time.setFormatTime(this._formatTime);
        } else {
            videojs.setFormatTime(this._formatTime);
        }
    }
}

// version nr is injected during build
Wavesurfer.VERSION = __VERSION__;

// register plugin once
videojs.Wavesurfer = Wavesurfer;
if (videojs.getPlugin(wavesurferPluginName) === undefined) {
    videojs.registerPlugin(wavesurferPluginName, Wavesurfer);
}

// register a star-middleware
videojs.use('*', player => {
    // make player available on middleware
    WavesurferMiddleware.player = player;

    return WavesurferMiddleware;
});

export {Wavesurfer};