Home Reference Source

js/videojs.record.js

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

import videojs from 'video.js';

import AnimationDisplay from './controls/animation-display';
import RecordCanvas from './controls/record-canvas';
import DeviceButton from './controls/device-button';
import CameraButton from './controls/camera-button';
import RecordToggle from './controls/record-toggle';
import RecordIndicator from './controls/record-indicator';
import PictureInPictureToggle from './controls/picture-in-picture-toggle';

import Event from './event';
import defaultKeyHandler from './hot-keys';
import pluginDefaultOptions from './defaults';
import formatTime from './utils/format-time';
import setSrcObject from './utils/browser-shim';
import compareVersion from './utils/compare-version';
import {detectBrowser} from './utils/detect-browser';

import {getAudioEngine, isAudioPluginActive, getVideoEngine, getConvertEngine} from './engine/engine-loader';
import {IMAGE_ONLY, AUDIO_ONLY, VIDEO_ONLY, AUDIO_VIDEO, AUDIO_SCREEN, ANIMATION, SCREEN_ONLY, getRecorderMode} from './engine/record-mode';

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

const AUTO = 'auto';

/**
 * Record audio/video/images using the Video.js player.
 *
 * @class
 * @augments videojs.Plugin
 */
class Record 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);

        // monkey-patch play (#152)
        Player.prototype.play = function play() {
            let retval = this.techGet_('play');
            // silence errors (unhandled promise from play)
            if (retval !== undefined && typeof retval.then === 'function') {
                retval.then(null, (e) => {});
            }
            return retval;
        };

        // add plugin style
        player.addClass('vjs-record');

        // setup plugin options
        this.loadOptions();

        // (re)set recorder state
        this.resetState();

        // 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);
            });
        }

        // add device button with icon based on type
        let deviceIcon = 'av-perm';
        switch (this.getRecordType()) {
            case IMAGE_ONLY:
            case VIDEO_ONLY:
            case ANIMATION:
                deviceIcon = 'video-perm';
                break;
            case AUDIO_ONLY:
                deviceIcon = 'audio-perm';
                break;
            case SCREEN_ONLY:
                deviceIcon = 'screen-perm';
                break;
            case AUDIO_SCREEN:
                deviceIcon = 'sv-perm';
                break;
        }

        // add custom interface elements
        DeviceButton.prototype.buildCSSClass = () => {
            // use dynamic icon class
            return 'vjs-record vjs-device-button vjs-control vjs-icon-' + deviceIcon;
        };
        player.deviceButton = new DeviceButton(player, options);
        player.addChild(player.deviceButton);

        // add blinking record indicator
        player.recordIndicator = new RecordIndicator(player, options);
        player.recordIndicator.hide();
        player.addChild(player.recordIndicator);

        // add canvas for recording and displaying image
        player.recordCanvas = new RecordCanvas(player, options);
        player.recordCanvas.hide();
        player.addChild(player.recordCanvas);

        // add image for animation display
        player.animationDisplay = new AnimationDisplay(player, options);
        player.animationDisplay.hide();
        player.addChild(player.animationDisplay);

        // add camera button
        player.cameraButton = new CameraButton(player, options);
        player.cameraButton.hide();

        // add record toggle button
        player.recordToggle = new RecordToggle(player, options);
        player.recordToggle.hide();

        // picture-in-picture
        let oldVideoJS = videojs.VERSION === undefined || compareVersion(videojs.VERSION, '7.6.0') === -1;
        if (!('pictureInPictureEnabled' in document)) {
            // no support for picture-in-picture, disable pip
            this.pictureInPicture = false;
        }
        if (this.pictureInPicture === true) {
            if (oldVideoJS) {
                // add picture-in-picture toggle button for older video.js versions
                // in browsers that support PIP
                player.pipToggle = new PictureInPictureToggle(player, options);
                player.pipToggle.hide();
            }
            // define Picture-in-Picture event handlers once
            this.onEnterPiPHandler = this.onEnterPiP.bind(this);
            this.onLeavePiPHandler = this.onLeavePiP.bind(this);
        }

        // exclude custom UI elements
        if (this.player.options_.controlBar) {
            let customUIElements = ['deviceButton', 'recordIndicator',
                'cameraButton', 'recordToggle'];
            if (player.pipToggle) {
                customUIElements.push('pipToggle');
            }

            customUIElements.forEach((element) => {
                if (this.player.options_.controlBar[element] !== undefined) {
                    this.player[element].layoutExclude = true;
                    this.player[element].hide();
                }
            });
        }

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

    /**
     * Setup plugin options.
     *
     * @param {Object} newOptions - Optional new player options.
     */
    loadOptions(newOptions = {}) {
        let merge;
        if (videojs.obj !== undefined) {
            // video.js v8 and newer
            merge = videojs.obj.merge;
        } else {
            merge = videojs.mergeOptions;
        }
        let recordOptions = merge(pluginDefaultOptions,
            this.player.options_.plugins.record, newOptions);

        // record settings
        this.recordImage = recordOptions.image;
        this.recordAudio = recordOptions.audio;
        this.recordVideo = recordOptions.video;
        this.recordAnimation = recordOptions.animation;
        this.recordScreen = recordOptions.screen;
        this.maxLength = recordOptions.maxLength;
        this.maxFileSize = recordOptions.maxFileSize;
        this.displayMilliseconds = recordOptions.displayMilliseconds;
        this.debug = recordOptions.debug;
        this.pictureInPicture = recordOptions.pip;
        this.recordTimeSlice = recordOptions.timeSlice;
        this.autoMuteDevice = recordOptions.autoMuteDevice;
        this.pluginLibraryOptions = recordOptions.pluginLibraryOptions;

        // video/canvas settings
        this.videoFrameWidth = recordOptions.frameWidth;
        this.videoFrameHeight = recordOptions.frameHeight;
        this.videoFrameRate = recordOptions.videoFrameRate;
        this.videoBitRate = recordOptions.videoBitRate;
        this.videoEngine = recordOptions.videoEngine;
        this.videoRecorderType = recordOptions.videoRecorderType;
        this.videoMimeType = recordOptions.videoMimeType;
        this.videoWorkerURL = recordOptions.videoWorkerURL;
        this.videoWebAssemblyURL = recordOptions.videoWebAssemblyURL;

        // convert settings
        this.convertEngine = recordOptions.convertEngine;
        this.convertAuto = recordOptions.convertAuto;
        this.convertWorkerURL = recordOptions.convertWorkerURL;
        this.convertOptions = recordOptions.convertOptions;

        // audio settings
        this.audioEngine = recordOptions.audioEngine;
        this.audioRecorderType = recordOptions.audioRecorderType;
        this.audioWorkerURL = recordOptions.audioWorkerURL;
        this.audioWebAssemblyURL = recordOptions.audioWebAssemblyURL;
        this.audioBufferSize = recordOptions.audioBufferSize;
        this.audioSampleRate = recordOptions.audioSampleRate;
        this.audioBitRate = recordOptions.audioBitRate;
        this.audioChannels = recordOptions.audioChannels;
        this.audioMimeType = recordOptions.audioMimeType;
        this.audioBufferUpdate = recordOptions.audioBufferUpdate;

        // image settings
        this.imageOutputType = recordOptions.imageOutputType;
        this.imageOutputFormat = recordOptions.imageOutputFormat;
        this.imageOutputQuality = recordOptions.imageOutputQuality;

        // animation settings
        this.animationFrameRate = recordOptions.animationFrameRate;
        this.animationQuality = recordOptions.animationQuality;
    }

    /**
     * Player UI is ready.
     * @private
     */
    setupUI() {
        // insert custom controls on left-side of controlbar
        this.player.controlBar.addChild(this.player.cameraButton);
        this.player.controlBar.el().insertBefore(
            this.player.cameraButton.el(),
            this.player.controlBar.el().firstChild);
        this.player.controlBar.el().insertBefore(
            this.player.recordToggle.el(),
            this.player.controlBar.el().firstChild);

        // picture-in-picture
        if (this.pictureInPicture === true) {
            if (this.player.controlBar.pictureInPictureToggle === undefined &&
                this.player.pipToggle !== undefined) {
                // add custom PiP toggle
                this.player.controlBar.addChild(this.player.pipToggle);
            } else if (this.player.controlBar.pictureInPictureToggle !== undefined) {
                // use video.js PiP toggle
                this.player.pipToggle = this.player.controlBar.pictureInPictureToggle;
                this.player.pipToggle.hide();
            }
        } else if (
            this.pictureInPicture === false &&
            this.player.controlBar.pictureInPictureToggle !== undefined) {
            this.player.controlBar.pictureInPictureToggle.hide();
        }

        // get rid of unused controls
        if (this.player.controlBar.remainingTimeDisplay !== undefined) {
            this.player.controlBar.remainingTimeDisplay.el().style.display = 'none';
        }
        if (this.player.controlBar.liveDisplay !== undefined) {
            this.player.controlBar.liveDisplay.el().style.display = 'none';
        }

        // loop feature is never used in this plugin
        this.player.loop(false);

        // tweak player UI based on type
        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                // reference to videojs-wavesurfer plugin
                this.surfer = this.player.wavesurfer();

                // use same time format as this plugin
                this.surfer.setFormatTime(this._formatTime);
                break;

            case IMAGE_ONLY:
            case VIDEO_ONLY:
            case AUDIO_VIDEO:
            case ANIMATION:
            case SCREEN_ONLY:
            case AUDIO_SCREEN:
                // customize controls
                if (this.player.bigPlayButton !== undefined) {
                    this.player.bigPlayButton.hide();
                }

                // 'loadedmetadata' and 'loadstart' events reset the
                // durationDisplay for the first time: prevent this
                this.player.one(Event.LOADEDMETADATA, () => {
                    // display max record time
                    this.setDuration(this.maxLength);
                });
                this.player.one(Event.LOADSTART, () => {
                    // display max record time
                    this.setDuration(this.maxLength);
                });

                // the native controls don't work for this UI so disable
                // them no matter what
                if (this.player.usingNativeControls_ === true) {
                    if (this.player.tech_.el_ !== undefined) {
                        this.player.tech_.el_.controls = false;
                    }
                }

                // clicking or tapping the player video element should not try
                // to start playback
                this.player.removeTechControlsListeners_();

                if (this.player.options_.controls) {
                    // progress control isn't used by this plugin, hide if present
                    if (this.player.controlBar.progressControl !== undefined) {
                        this.player.controlBar.progressControl.hide();
                    }

                    // prevent controlbar fadeout
                    this.player.on(Event.USERINACTIVE, (event) => {
                        this.player.userActive(true);
                    });

                    // videojs automatically hides the controls when no valid 'source'
                    // element is included in the video or audio tag. Don't. Ever again.
                    this.player.controlBar.show();
                    this.player.controlBar.el().style.display = 'flex';
                }
                break;
        }

        // disable time display events that constantly try to reset the current time
        // and duration values
        this.player.off(Event.TIMEUPDATE);
        this.player.off(Event.DURATIONCHANGE);
        this.player.off(Event.LOADEDMETADATA);
        this.player.off(Event.LOADSTART);
        this.player.off(Event.ENDED);

        // display max record time
        this.setDuration(this.maxLength);

        // hot keys
        if (this.player.options_.plugins.record &&
            this.player.options_.plugins.record.hotKeys &&
            (this.player.options_.plugins.record.hotKeys !== false)) {

            let handler = this.player.options_.plugins.record.hotKeys;
            if (handler === true) {
                handler = defaultKeyHandler;
            }
            // enable video.js user action
            this.player.options_.userActions = {
                hotkeys: handler
            };
        }

        // hide play control (if present)
        if (this.player.controlBar.playToggle !== undefined) {
            this.player.controlBar.playToggle.hide();
        }
    }

    /**
     * Indicates whether the plugin is currently recording or not.
     *
     * @return {boolean} Plugin currently recording or not.
     */
    isRecording() {
        return this._recording;
    }

    /**
     * Indicates whether the plugin is currently processing recorded data
     * or not.
     *
     * @return {boolean} Plugin processing or not.
     */
    isProcessing() {
        return this._processing;
    }

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

    /**
     * Open the browser's recording device selection dialog and start the
     * device.
     */
    getDevice() {
        // define device callbacks once
        if (this.deviceReadyCallback === undefined) {
            this.deviceReadyCallback = this.onDeviceReady.bind(this);
        }
        if (this.deviceErrorCallback === undefined) {
            this.deviceErrorCallback = this.onDeviceError.bind(this);
        }
        if (this.engineStopCallback === undefined) {
            this.engineStopCallback = this.onRecordComplete.bind(this);
        }
        if (this.streamVisibleCallback === undefined) {
            this.streamVisibleCallback = this.onStreamVisible.bind(this);
        }

        // check for support because some browsers still do not support
        // getDisplayMedia or getUserMedia (like Chrome iOS, see:
        // https://bugs.chromium.org/p/chromium/issues/detail?id=752458)
        if (this.getRecordType() === SCREEN_ONLY || this.getRecordType() === AUDIO_SCREEN) {
            if (navigator.mediaDevices === undefined ||
                navigator.mediaDevices.getDisplayMedia === undefined) {
                this.player.trigger(Event.ERROR,
                    'This browser does not support navigator.mediaDevices.getDisplayMedia');
                return;
            }
        } else {
            if (navigator.mediaDevices === undefined ||
                navigator.mediaDevices.getUserMedia === undefined) {
                this.player.trigger(Event.ERROR,
                    'This browser does not support navigator.mediaDevices.getUserMedia');
                return;
            }
        }

        // ask the browser to give the user access to the media device
        // and get a stream reference in the callback function
        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                // setup microphone
                this.mediaType = {
                    audio: (this.audioRecorderType === AUTO) ? true : this.audioRecorderType,
                    video: false
                };
                // remove existing microphone listeners
                this.surfer.surfer.microphone.un(Event.DEVICE_READY,
                    this.deviceReadyCallback);
                this.surfer.surfer.microphone.un(Event.DEVICE_ERROR,
                    this.deviceErrorCallback);

                // setup new microphone listeners
                this.surfer.surfer.microphone.on(Event.DEVICE_READY,
                    this.deviceReadyCallback);
                this.surfer.surfer.microphone.on(Event.DEVICE_ERROR,
                    this.deviceErrorCallback);

                // disable existing playback events
                this.surfer.setupPlaybackEvents(false);

                // (re)set surfer liveMode
                this.surfer.liveMode = true;
                this.surfer.surfer.microphone.paused = false;

                // resume AudioContext when it's suspended by the browser, due to
                // autoplay rules. Chrome warns with the following message:
                // "The AudioContext was not allowed to start. It must be resumed
                // (or created) after a user gesture on the page."
                if (this.surfer.surfer.backend.ac.state === 'suspended') {
                    this.surfer.surfer.backend.ac.resume();
                }

                // assign custom reloadBufferFunction for microphone plugin to
                // obtain AudioBuffer chunks
                if (this.audioBufferUpdate === true) {
                    this.surfer.surfer.microphone.reloadBufferFunction = (event) => {
                        if (!this.surfer.surfer.microphone.paused) {
                            // redraw
                            this.surfer.surfer.empty();
                            this.surfer.surfer.loadDecodedBuffer(event.inputBuffer);

                            // store data and notify others
                            this.player.recordedData = event.inputBuffer;
                            this.player.trigger(Event.AUDIO_BUFFER_UPDATE);
                        }
                    };
                }
                // open browser device selection/permissions dialog
                this.surfer.surfer.microphone.start();
                break;

            case IMAGE_ONLY:
            case VIDEO_ONLY:
                if (this.getRecordType() === IMAGE_ONLY) {
                    // using player.el() here because this.mediaElement is not available yet
                    this.player.el().firstChild.addEventListener(Event.PLAYING,
                        this.streamVisibleCallback);
                }

                // setup camera
                this.mediaType = {
                    audio: false,
                    video: (this.videoRecorderType === AUTO) ? true : this.videoRecorderType
                };
                navigator.mediaDevices.getUserMedia({
                    audio: false,
                    video: (this.getRecordType() === IMAGE_ONLY) ? this.recordImage : this.recordVideo
                }).then(
                    this.onDeviceReady.bind(this)
                ).catch(
                    this.onDeviceError.bind(this)
                );
                break;

            case AUDIO_SCREEN:
                // setup camera and microphone
                this.mediaType = {
                    audio: (this.audioRecorderType === AUTO) ? true : this.audioRecorderType,
                    video: (this.videoRecorderType === AUTO) ? true : this.videoRecorderType
                };
                let audioScreenConstraints = {};
                if (this.recordScreen === true) {
                    audioScreenConstraints = {
                        video: true // needs to be true for it to work in Firefox
                    };
                } else if (typeof this.recordScreen === 'object' &&
                    this.recordScreen.constructor === Object) {
                    audioScreenConstraints = this.recordScreen;
                }
                navigator.mediaDevices.getDisplayMedia(audioScreenConstraints).then(screenStream => {
                    navigator.mediaDevices.getUserMedia({
                        audio: this.recordAudio
                    }).then((mic) => {
                        // join microphone track with screencast stream (order matters)
                        screenStream.addTrack(mic.getTracks()[0]);
                        this.onDeviceReady.bind(this)(screenStream);
                    }).catch((code) => {
                        // here the screen sharing is in progress as successful result of navigator.mediaDevices.getDisplayMedia and
                        // needs to be stopped because microphone permissions are not acquired by navigator.mediaDevices.getUserMedia
                        if (screenStream.active) {
                            screenStream.stop();
                        }
                        this.onDeviceError(code);
                    });
                }).catch(
                    this.onDeviceError.bind(this)
                );
                break;

            case AUDIO_VIDEO:
                // setup camera and microphone
                this.mediaType = {
                    audio: (this.audioRecorderType === AUTO) ? true : this.audioRecorderType,
                    video: (this.videoRecorderType === AUTO) ? true : this.videoRecorderType
                };
                navigator.mediaDevices.getUserMedia({
                    audio: this.recordAudio,
                    video: this.recordVideo
                }).then(
                    this.onDeviceReady.bind(this)
                ).catch(
                    this.onDeviceError.bind(this)
                );
                break;

            case ANIMATION:
                // setup camera
                this.mediaType = {
                    // animated GIF
                    audio: false,
                    video: false,
                    gif: true
                };
                navigator.mediaDevices.getUserMedia({
                    audio: false,
                    video: this.recordAnimation
                }).then(
                    this.onDeviceReady.bind(this)
                ).catch(
                    this.onDeviceError.bind(this)
                );
                break;

            case SCREEN_ONLY:
                // setup screen
                this.mediaType = {
                    // screen capture
                    audio: false,
                    video: false,
                    screen: true,
                    gif: false
                };
                let screenOnlyConstraints = {};
                if (this.recordScreen === true) {
                    screenOnlyConstraints = {
                        video: true
                    };
                } else if (typeof this.recordScreen === 'object' &&
                    this.recordScreen.constructor === Object) {
                    screenOnlyConstraints = this.recordScreen;
                }
                navigator.mediaDevices.getDisplayMedia(screenOnlyConstraints).then(
                    this.onDeviceReady.bind(this)
                ).catch(
                    this.onDeviceError.bind(this)
                );
                break;
        }
    }

    /**
     * Invoked when the device is ready.
     *
     * @private
     * @param {LocalMediaStream} stream - Local media stream from device.
     */
    onDeviceReady(stream) {
        this._deviceActive = true;

        // stop previous stream if it is active
        if (this.stream !== undefined && this.stream.active) {
            this.stream.stop();
        }

        // store reference to stream for stopping etc.
        this.stream = stream;

        // hide device selection button
        this.player.deviceButton.hide();

        // reset time (e.g. when stopDevice was used)
        this.setDuration(this.maxLength);
        this.setCurrentTime(0);

        // hide play/pause control (e.g. when stopDevice was used)
        if (this.player.controlBar.playToggle !== undefined) {
            this.player.controlBar.playToggle.hide();
        }

        // reset playback listeners
        this.off(this.player, Event.TIMEUPDATE, this.playbackTimeUpdate);
        this.off(this.player, Event.ENDED, this.playbackTimeUpdate);

        // setup recording engine
        if (this.getRecordType() !== IMAGE_ONLY) {
            // currently record plugins are only supported in audio-only mode
            if (this.getRecordType() !== AUDIO_ONLY && isAudioPluginActive(this.audioEngine)) {
                throw new Error('Currently ' + this.audioEngine +
                    ' is only supported in audio-only mode.');
            }

            // load plugins, if any
            let EngineClass, engineType;
            switch (this.getRecordType()) {
                case AUDIO_ONLY:
                    // get audio plugin engine class (or default recordrtc engine)
                    EngineClass = getAudioEngine(this.audioEngine);
                    engineType = this.audioEngine;
                    break;

                default:
                    // get video plugin engine class (or default recordrtc engine)
                    EngineClass = getVideoEngine(this.videoEngine);
                    engineType = this.videoEngine;
            }

            // create recording engine
            try {
                // connect stream to recording engine
                this.engine = new EngineClass(this.player, this.player.options_);
            } catch (err) {
                throw new Error('Could not load ' + engineType + ' plugin');
            }

            // listen for events
            this.engine.on(Event.RECORD_COMPLETE, this.engineStopCallback);

            // audio settings
            this.engine.bufferSize = this.audioBufferSize;
            this.engine.sampleRate = this.audioSampleRate;
            this.engine.bitRate = this.audioBitRate;
            this.engine.audioChannels = this.audioChannels;
            this.engine.audioWorkerURL = this.audioWorkerURL;
            this.engine.audioWebAssemblyURL = this.audioWebAssemblyURL;

            // mime type
            this.engine.mimeType = {
                video: this.videoMimeType,
                gif: 'image/gif'
            };
            if (this.audioMimeType !== null &&
                this.audioMimeType !== AUTO) {
                this.engine.mimeType.audio = this.audioMimeType;
            }

            // video/canvas settings
            this.engine.videoWorkerURL = this.videoWorkerURL;
            this.engine.videoWebAssemblyURL = this.videoWebAssemblyURL;
            this.engine.videoBitRate = this.videoBitRate;
            this.engine.videoFrameRate = this.videoFrameRate;
            this.engine.video = {
                width: this.videoFrameWidth,
                height: this.videoFrameHeight
            };
            this.engine.canvas = {
                width: this.videoFrameWidth,
                height: this.videoFrameHeight
            };

            // animated GIF settings
            this.engine.quality = this.animationQuality;
            this.engine.frameRate = this.animationFrameRate;

            // timeSlice
            if (this.recordTimeSlice && this.recordTimeSlice > 0) {
                this.engine.timeSlice = this.recordTimeSlice;
                this.engine.maxFileSize = this.maxFileSize;
            }

            // additional 3rd-party library options
            this.engine.pluginLibraryOptions = this.pluginLibraryOptions;

            // initialize recorder
            this.engine.setup(this.stream, this.mediaType, this.debug);

            // create converter engine
            if (this.convertEngine !== '') {
                let ConvertEngineClass = getConvertEngine(this.convertEngine);
                try {
                    this.converter = new ConvertEngineClass(this.player,
                        this.player.options_);
                }
                catch (err) {
                    throw new Error('Could not load ' + this.convertEngine +
                        ' plugin');
                }

                // convert settings
                this.converter.convertAuto = this.convertAuto;
                this.converter.convertWorkerURL = this.convertWorkerURL;
                this.converter.convertOptions = this.convertOptions;
                this.converter.pluginLibraryOptions = this.pluginLibraryOptions;

                // initialize converter
                this.converter.setup(this.mediaType, this.debug);
            }

            // show elements that should never be hidden in animation,
            // audio and/or video modus
            let uiElements = ['currentTimeDisplay', 'timeDivider', 'durationDisplay'];
            uiElements.forEach((element) => {
                element = this.player.controlBar[element];
                if (element !== undefined) {
                    element.el().style.display = 'block';
                    element.show();
                }
            });

            // show record button
            this.player.recordToggle.show();
        } else {
            // disable record indicator
            this.player.recordIndicator.disable();

            // setup UI for retrying snapshot (e.g. when stopDevice was
            // used)
            this.retrySnapshot();

            // camera button will be displayed as soon as this.onStreamVisible fires
        }

        // setup preview
        if (this.getRecordType() !== AUDIO_ONLY) {
            // show live preview
            this.mediaElement = this.player.el().firstChild;
            this.mediaElement.controls = false;

            // mute incoming audio for feedback loops
            this.mediaElement.muted = true;

            // hide the volume bar while it's muted
            this.displayVolumeControl(false);

            // picture-in-picture
            if (this.pictureInPicture === true) {
                // show button
                this.player.pipToggle.show();

                // listen to and forward Picture-in-Picture events
                this.mediaElement.removeEventListener(Event.ENTERPICTUREINPICTURE,
                    this.onEnterPiPHandler);
                this.mediaElement.removeEventListener(Event.LEAVEPICTUREINPICTURE,
                    this.onLeavePiPHandler);
                this.mediaElement.addEventListener(Event.ENTERPICTUREINPICTURE,
                    this.onEnterPiPHandler);
                this.mediaElement.addEventListener(Event.LEAVEPICTUREINPICTURE,
                    this.onLeavePiPHandler);
            }
            // load stream
            this.load(this.stream);

            // stream loading is async, so we wait until it's ready to play
            // the stream
            this.player.one(Event.LOADEDMETADATA, () => {
                // start stream
                this.mediaElement.play();

                // forward to listeners
                this.player.trigger(Event.DEVICE_READY);
            });
        } else {
            // forward to listeners
            this.player.trigger(Event.DEVICE_READY);
        }
    }

    /**
     * Invoked when an device error occurred.
     *
     * @private
     * @param {(string|number)} code - Error code/description.
     */
    onDeviceError(code) {
        this._deviceActive = false;

        if (!this.isDestroyed()) {
            // store code
            this.player.deviceErrorCode = code;

            // forward error to player
            this.player.trigger(Event.DEVICE_ERROR);
        }
    }

    /**
     * Start recording.
     */
    start() {
        if (!this.isProcessing()) {
            // check if user didn't revoke permissions after a previous recording
            if (this.stream && this.stream.active === false) {
                // ask for permissions again
                this.getDevice();
                return;
            }
            this._recording = true;

            // hide play/pause control
            if (this.player.controlBar.playToggle !== undefined) {
                this.player.controlBar.playToggle.hide();
            }

            // reset playback listeners
            this.off(this.player, Event.TIMEUPDATE, this.playbackTimeUpdate);
            this.off(this.player, Event.ENDED, this.playbackTimeUpdate);

            // start preview
            switch (this.getRecordType()) {
                case AUDIO_ONLY:
                    // disable playback events
                    this.surfer.setupPlaybackEvents(false);

                    // start/resume live audio visualization
                    this.surfer.surfer.microphone.paused = false;
                    this.surfer.liveMode = true;
                    this.surfer.surfer.microphone.play();
                    break;

                case VIDEO_ONLY:
                case AUDIO_VIDEO:
                case AUDIO_SCREEN:
                case SCREEN_ONLY:
                    // preview video stream in video element
                    this.startVideoPreview();
                    break;

                case ANIMATION:
                    // hide the first frame
                    this.player.recordCanvas.hide();

                    // hide the animation
                    this.player.animationDisplay.hide();

                    // show preview video
                    this.mediaElement.style.display = 'block';

                    // for animations, capture the first frame
                    // that can be displayed as soon as recording
                    // is complete
                    this.captureFrame().then((result) => {
                        // start video preview **after** capturing first frame
                        this.startVideoPreview();
                    });
                    break;
            }

            if (this.autoMuteDevice) {
                // unmute device
                this.muteTracks(false);
            }

            // start recording
            switch (this.getRecordType()) {
                case IMAGE_ONLY:
                    // create snapshot
                    this.createSnapshot();

                    // notify UI
                    this.player.trigger(Event.START_RECORD);
                    break;

                case VIDEO_ONLY:
                case AUDIO_VIDEO:
                case AUDIO_SCREEN:
                case ANIMATION:
                case SCREEN_ONLY:
                    // wait for media stream on video element to actually load
                    this.player.one(Event.LOADEDMETADATA, () => {
                        // start actually recording process
                        this.startRecording();
                    });
                    break;

                default:
                    // all resources have already loaded, so we can start
                    // recording right away
                    this.startRecording();
            }
        }
    }

    /**
     * Start recording.
     * @private
     */
    startRecording() {
        // register starting point
        this.paused = false;
        this.pauseTime = this.pausedTime = 0;
        this.startTime = performance.now();

        // start countdown
        const COUNTDOWN_SPEED = 100; // ms
        this.countDown = this.player.setInterval(
            this.onCountDown.bind(this), COUNTDOWN_SPEED);

        // cleanup previous recording
        if (this.engine !== undefined) {
            this.engine.dispose();
        }

        // start recording stream
        this.engine.start();

        // notify UI
        this.player.trigger(Event.START_RECORD);
    }

    /**
     * Stop recording.
     */
    stop() {
        if (!this.isProcessing()) {
            this._recording = false;
            this._processing = true;

            if (this.getRecordType() !== IMAGE_ONLY) {
                // notify UI
                this.player.trigger(Event.STOP_RECORD);

                // stop countdown
                this.player.clearInterval(this.countDown);

                // stop recording stream (result will be available async)
                if (this.engine) {
                    this.engine.stop();
                }

                if (this.autoMuteDevice) {
                    // mute device
                    this.muteTracks(true);
                }
            } else {
                if (this.player.recordedData) {
                    // notify listeners that image data is (already) available
                    this.player.trigger(Event.FINISH_RECORD);
                }
            }
        }
    }

    /**
     * Stop device(s) and recording if active.
     */
    stopDevice() {
        if (this.isRecording()) {
            // stop stream once recorded data is available,
            // otherwise it'll break recording
            this.player.one(Event.FINISH_RECORD, this.stopStream.bind(this));

            // stop recording
            this.stop();
        } else {
            // stop stream now, since there's no recorded data available
            this.stopStream();
        }
    }

    /**
     * Stop stream and device.
     */
    stopStream() {
        // stop stream and device
        if (this.stream) {
            this._deviceActive = false;

            if (this.getRecordType() === AUDIO_ONLY) {
                // make the microphone plugin stop it's device
                this.surfer.surfer.microphone.stopDevice();
                return;
            }
            this.stream.getTracks().forEach((stream) => {
                stream.stop();
            });
        }
    }

    /**
     * Pause recording.
     */
    pause() {
        if (!this.paused) {
            this.pauseTime = performance.now();
            this.paused = true;

            this.engine.pause();
        }
    }

    /**
     * Resume recording.
     */
    resume() {
        if (this.paused) {
            this.pausedTime += performance.now() - this.pauseTime;

            this.engine.resume();
            this.paused = false;
        }
    }

    /**
     * Invoked when recording completed and the resulting stream is
     * available.
     * @private
     */
    onRecordComplete() {
        // store reference to recorded stream data
        this.player.recordedData = this.engine.recordedData;

        // change the replay button back to a play button
        if (this.player.controlBar.playToggle !== undefined) {
            this.player.controlBar.playToggle.removeClass('vjs-ended');
            this.player.controlBar.playToggle.show();
        }

        // start converter
        if (this.convertAuto === true) {
            this.convert();
        }

        // notify listeners that data is available
        this.player.trigger(Event.FINISH_RECORD);

        // skip loading when player is destroyed after finishRecord event
        if (this.isDestroyed()) {
            return;
        }

        // load and display recorded data
        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                // pause player so user can start playback
                this.surfer.pause();

                // setup events for playback
                this.surfer.setupPlaybackEvents(true);

                // display loader
                this.player.loadingSpinner.show();

                // restore interaction with controls after waveform
                // rendering is complete
                this.surfer.surfer.once(Event.READY, () => {
                    this._processing = false;
                });

                // visualize recorded stream
                this.load(this.player.recordedData);
                break;

            case VIDEO_ONLY:
            case AUDIO_VIDEO:
            case AUDIO_SCREEN:
            case SCREEN_ONLY:
                // pausing the player so we can visualize the recorded data
                // will trigger an async video.js 'pause' event that we
                // have to wait for.
                this.player.one(Event.PAUSE, () => {
                    // video data is ready
                    this._processing = false;

                    // hide loader
                    this.player.loadingSpinner.hide();

                    // show stream total duration
                    this.setDuration(this.streamDuration);

                    // update time during playback and at end
                    this.on(this.player, Event.TIMEUPDATE,
                        this.playbackTimeUpdate);
                    this.on(this.player, Event.ENDED,
                        this.playbackTimeUpdate);

                    // unmute local audio during playback
                    if (this.getRecordType() === AUDIO_VIDEO || this.getRecordType() === AUDIO_SCREEN) {
                        this.mediaElement.muted = false;

                        // show the volume bar when it's unmuted
                        this.displayVolumeControl(true);
                    }

                    // load recorded media
                    this.load(this.player.recordedData);
                });

                // pause player so user can start playback
                this.player.pause();
                break;

            case ANIMATION:
                // animation data is ready
                this._processing = false;

                // hide loader
                this.player.loadingSpinner.hide();

                // show animation total duration
                this.setDuration(this.streamDuration);

                // hide preview video
                this.mediaElement.style.display = 'none';

                // show the first frame
                this.player.recordCanvas.show();

                // pause player so user can start playback
                this.player.pause();

                // show animation on play
                this.on(this.player, Event.PLAY, this.showAnimation);

                // hide animation on pause
                this.on(this.player, Event.PAUSE, this.hideAnimation);
                break;
        }
    }

    /**
     * Invoked during recording and displays the remaining time.
     * @private
     */
    onCountDown() {
        if (!this.paused) {
            let now = performance.now();
            let duration = this.maxLength;
            let currentTime = (now - (this.startTime +
                this.pausedTime)) / 1000; // buddy ignore:line

            this.streamDuration = currentTime;

            if (currentTime >= duration) {
                // at the end
                currentTime = duration;

                // stop recording
                this.stop();
            }

            // update duration
            this.setDuration(duration);

            // update current time
            this.setCurrentTime(currentTime, duration);

            // notify listeners
            this.player.trigger(Event.PROGRESS_RECORD);
        }
    }

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

        if (this.getRecordType() === AUDIO_ONLY) {
            currentTime = this.surfer.getCurrentTime();
        }

        return currentTime;
    }

    /**
     * Updates the player's element displaying the current time.
     *
     * @private
     * @param {number} [currentTime=0] - Current position of the
     *    playhead (in seconds).
     * @param {number} [duration=0] - Duration in seconds.
     */
    setCurrentTime(currentTime, duration) {
        currentTime = isNaN(currentTime) ? 0 : currentTime;
        duration = isNaN(duration) ? 0 : duration;

        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                this.surfer.setCurrentTime(currentTime, duration);
                break;

            case VIDEO_ONLY:
            case AUDIO_VIDEO:
            case AUDIO_SCREEN:
            case ANIMATION:
            case SCREEN_ONLY:
                if (this.player.controlBar.currentTimeDisplay &&
                    this.player.controlBar.currentTimeDisplay.contentEl() &&
                    this.player.controlBar.currentTimeDisplay.contentEl().lastChild) {
                    this.streamCurrentTime = Math.min(currentTime, duration);

                    // update current time display component
                    this.player.controlBar.currentTimeDisplay.formattedTime_ =
                        this.player.controlBar.currentTimeDisplay.contentEl().lastChild.textContent =
                            this._formatTime(this.streamCurrentTime, duration, this.displayMilliseconds);
                }
                break;
        }
    }

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

        return duration;
    }

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

        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                this.surfer.setDuration(duration);
                break;

            case VIDEO_ONLY:
            case AUDIO_VIDEO:
            case AUDIO_SCREEN:
            case ANIMATION:
            case SCREEN_ONLY:
                // 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);
                }
                break;
        }
    }

    /**
     * Start loading data.
     *
     * @param {(string|blob|file)} url - Either the URL of the media file,
     *     a Blob, a File object or MediaStream.
     */
    load(url) {
        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                // visualize recorded Blob stream
                this.surfer.load(url);
                break;

            case IMAGE_ONLY:
            case VIDEO_ONLY:
            case AUDIO_VIDEO:
            case AUDIO_SCREEN:
            case ANIMATION:
            case SCREEN_ONLY:
                if (url instanceof Blob || url instanceof File) {
                    // make sure to reset it (#312)
                    this.mediaElement.srcObject = null;
                    // assign blob using createObjectURL
                    this.mediaElement.src = URL.createObjectURL(url);
                } else {
                    // assign stream with srcObject
                    setSrcObject(url, this.mediaElement);
                }
                break;
        }
    }

    /**
     * Show save as dialog in browser so the user can store the recorded or
     * converted media locally.
     *
     * @param {Object} name - Object with names for the particular blob(s)
     *     you want to save. File extensions are added automatically. For
     *     example: {'video': 'name-of-video-file'}. Supported keys are
     *     'audio', 'video' and 'gif'.
     * @param {String} type - Type of media to save. Legal values are 'record'
     *     (default) and 'convert'.
     * @example
     * // save recorded video file as 'foo.webm'
     * player.record().saveAs({'video': 'foo'});
     *
     * // save converted video file as 'bar.mp4'
     * player.record().saveAs({'video': 'bar'}, 'convert');
     * @returns {void}
     */
    saveAs(name, type = 'record') {
        if (type === 'record') {
            if (this.engine && name !== undefined) {
                this.engine.saveAs(name);
            }
        } else if (type === 'convert') {
            if (this.converter && name !== undefined) {
                this.converter.saveAs(name);
            }
        }
    }

    /**
     * Destroy plugin only.
     *
     * Use [destroy]{@link Record#destroy} to remove the plugin and the player
     * as well.
     */
    dispose() {
        // disable common event listeners
        this.player.off(Event.READY);
        this.player.off(Event.USERINACTIVE);
        this.player.off(Event.LOADEDMETADATA);

        // prevent callbacks if recording is in progress
        if (this.engine) {
            this.engine.dispose();
            this.engine.destroy();
            this.engine.off(Event.RECORD_COMPLETE, this.engineStopCallback);
        }

        // stop recording and device
        this.stop();
        this.stopDevice();

        // garbage collect recording
        this.removeRecording();

        // stop countdown
        this.player.clearInterval(this.countDown);

        // dispose wavesurfer.js
        if (this.getRecordType() === AUDIO_ONLY) {
            if (this.surfer) {
                // also disposes player
                this.surfer.destroy();
            }
        } else if (this.getRecordType() === IMAGE_ONLY) {
            if (this.mediaElement && this.streamVisibleCallback) {
                // cleanup listeners
                this.mediaElement.removeEventListener(Event.PLAYING,
                    this.streamVisibleCallback);
            }
        }

        this.resetState();

        super.dispose();
    }

    /**
     * Destroy plugin and players and cleanup resources.
     */
    destroy() {
        this.player.dispose();
    }

    /**
     * Reset the plugin.
     */
    reset() {
        // prevent callbacks if recording is in progress
        if (this.engine) {
            this.engine.dispose();
            this.engine.off(Event.RECORD_COMPLETE, this.engineStopCallback);
        }

        // stop recording and device
        this.stop();
        this.stopDevice();

        // stop countdown
        this.player.clearInterval(this.countDown);

        // garbage collect recording
        this.removeRecording();

        // reset options
        this.loadOptions();

        // reset recorder state
        this.resetState();

        // reset record time
        this.setDuration(this.maxLength);
        this.setCurrentTime(0);

        // reset player
        this.player.reset();
        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                if (this.surfer && this.surfer.surfer) {
                    // empty last frame
                    this.surfer.surfer.empty();
                }
                break;

            case IMAGE_ONLY:
            case ANIMATION:
                // reset UI
                this.player.recordCanvas.hide();
                this.player.cameraButton.hide();
                break;
        }

        // hide play control
        if (this.player.controlBar.playToggle !== undefined) {
            this.player.controlBar.playToggle.hide();
        }

        // show device selection button
        this.player.deviceButton.show();

        // hide record button
        this.player.recordToggle.hide();

        // loadedmetadata resets the durationDisplay for the
        // first time
        this.player.one(Event.LOADEDMETADATA, () => {
            // display max record time
            this.setDuration(this.maxLength);
        });
    }

    /**
     * Reset the plugin recorder state.
     * @private
     */
    resetState() {
        this._recording = false;
        this._processing = false;
        this._deviceActive = false;
        this.devices = [];
    }

    /**
     * Removes recorded `Blob` from cache.
     * @private
     */
    removeRecording() {
        if (this.mediaElement &&
            this.mediaElement.src &&
            this.mediaElement.src.startsWith('blob:') === true
        ) {
            URL.revokeObjectURL(this.mediaElement.src);
            this.mediaElement.src = '';
        }
    }

    /**
     * Export image data of waveform (audio-only) or current video frame.
     *
     * 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'`.
     * @return {Promise} Returns a `Promise` resolving with an
     * array of `Blob` instances.
     */
    exportImage(format = 'image/png', quality = 1) {
        if (this.getRecordType() === AUDIO_ONLY) {
            return this.surfer.surfer.exportImage(format, quality, 'blob');
        } else {
            // get a frame and copy it onto the canvas
            let recordCanvas = this.player.recordCanvas.el().firstChild;
            this.drawCanvas(recordCanvas, this.mediaElement);

            return new Promise(resolve => {
                recordCanvas.toBlob(resolve, format, quality);
            });
        }
    }

    /**
     * Mute LocalMediaStream audio and video tracks.
     *
     * @param {boolean} mute - Whether or not the mute the track(s).
     */
    muteTracks(mute) {
        if ((this.getRecordType() === AUDIO_ONLY ||
            this.getRecordType() === AUDIO_SCREEN ||
            this.getRecordType() === AUDIO_VIDEO) &&
            this.stream.getAudioTracks().length > 0) {
            this.stream.getAudioTracks()[0].enabled = !mute;
        }

        if (this.getRecordType() !== AUDIO_ONLY &&
            this.stream.getVideoTracks().length > 0) {
            this.stream.getVideoTracks()[0].enabled = !mute;
        }
    }

    /**
     * Get recorder type.
     *
     * @returns {string} Recorder type constant.
     * @example
     * console.log(player.record().getRecordType()); // 'audio_video'
     */
    getRecordType() {
        return getRecorderMode(this.recordImage, this.recordAudio,
            this.recordVideo, this.recordAnimation, this.recordScreen);
    }

    /**
     * Start converter.
     */
    convert() {
        if (this.converter !== undefined) {
            this.converter.convert(this.player.recordedData);
        }
    }

    /**
     * Create and display snapshot image.
     * @private
     */
    createSnapshot() {
        this.captureFrame().then((result) => {
            if (this.imageOutputType === 'blob') {
                // turn the canvas data into a Blob
                result.toBlob((blob) => {
                    this.player.recordedData = blob;

                    // display the snapshot
                    this.displaySnapshot();
                });
            } else if (this.imageOutputType === 'dataURL') {
                // turn the canvas data into base64 data
                this.player.recordedData = result.toDataURL(
                    this.imageOutputFormat, this.imageOutputQuality);

                // display the snapshot
                this.displaySnapshot();
            }
        }, this.imageOutputFormat, this.imageOutputQuality);
    }

    /**
     * Display snapshot image.
     * @private
     */
    displaySnapshot() {
        // hide preview video
        this.mediaElement.style.display = 'none';

        // show the snapshot
        this.player.recordCanvas.show();

        // stop recording
        this.stop();
    }

    /**
     * Reset UI for retrying a snapshot image.
     * @private
     */
    retrySnapshot() {
        this._processing = false;

        // retry: hide the snapshot
        this.player.recordCanvas.hide();

        // show preview video
        this.player.el().firstChild.style.display = 'block';
    }

    /**
     * Capture frame from camera and copy data to canvas.
     * @private
     * @returns {void}
     */
    captureFrame() {
        let detected = detectBrowser();
        let recordCanvas = this.player.recordCanvas.el().firstChild;
        let track = this.stream.getVideoTracks()[0];
        let settings = track.getSettings();

        // set the canvas size to the dimensions of the camera,
        // which also wipes the content of the canvas
        recordCanvas.width = settings.width;
        recordCanvas.height = settings.height;

        return new Promise((resolve, reject) => {
            const cameraAspectRatio = settings.width / settings.height;
            const playerAspectRatio = this.player.width() / this.player.height();
            let imagePreviewHeight = 0;
            let imagePreviewWidth = 0;
            let imageXPosition = 0;
            let imageYPosition = 0;

            // determine orientation
            // buddy ignore:start
            if (cameraAspectRatio >= playerAspectRatio) {
                // camera feed wider than player
                imagePreviewHeight = settings.height * (this.player.width() / settings.width);
                imagePreviewWidth = this.player.width();
                imageYPosition = (this.player.height() / 2) - (imagePreviewHeight / 2);
            } else {
                // player wider than camera feed
                imagePreviewHeight = this.player.height();
                imagePreviewWidth = settings.width * (this.player.height() / settings.height);
                imageXPosition = (this.player.width() / 2) - (imagePreviewWidth / 2);
            }
            // buddy ignore:end

            // MediaCapture is only supported on:
            // - Chrome 60 and newer (see
            // https://github.com/w3c/mediacapture-image/blob/gh-pages/implementation-status.md)
            // - Firefox behind flag (https://bugzilla.mozilla.org/show_bug.cgi?id=888177)
            //
            // importing ImageCapture can fail when enabling chrome flag is still required.
            // if so; ignore and continue
            if ((detected.browser === 'chrome' && detected.version >= 60) &&
               (typeof ImageCapture === typeof Function)) {
                try {
                    let imageCapture = new ImageCapture(track);
                    // take picture
                    imageCapture.grabFrame().then((imageBitmap) => {
                        // get a frame and copy it onto the canvas
                        this.drawCanvas(recordCanvas, imageBitmap, imagePreviewWidth,
                            imagePreviewHeight, imageXPosition, imageYPosition);

                        // notify others
                        resolve(recordCanvas);
                    }).catch((error) => {
                        // ignore, try oldskool
                    });
                } catch (err) {}
            }
            // no ImageCapture available: do it the oldskool way

            // get a frame and copy it onto the canvas
            this.drawCanvas(recordCanvas, this.mediaElement, imagePreviewWidth,
                imagePreviewHeight, imageXPosition, imageYPosition);

            // notify others
            resolve(recordCanvas);
        });
    }

    /**
     * Draw image frame on canvas element.
     * @private
     * @param {HTMLCanvasElement} canvas - Canvas to draw on.
     * @param {HTMLElement} element - Element to draw onto the canvas.
     * @param {Number} width - Width of drawing on canvas.
     * @param {Number} height - Height of drawing on canvas.
     * @param {Number} x - X position on canvas where drawing starts.
     * @param {Number} y - Y position on canvas where drawing starts.
     */
    drawCanvas(canvas, element, width, height, x = 0, y = 0) {
        if (width === undefined) {
            width = canvas.width;
        }
        if (height === undefined) {
            height = canvas.height;
        }
        canvas.getContext('2d').drawImage(element, x, y, width, height);
    }

    /**
     * Start preview of video stream.
     * @private
     */
    startVideoPreview() {
        // disable playback events
        this.off(Event.TIMEUPDATE);
        this.off(Event.DURATIONCHANGE);
        this.off(Event.LOADEDMETADATA);
        this.off(Event.PLAY);

        // mute local audio
        this.mediaElement.muted = true;

        // hide volume control to prevent feedback
        this.displayVolumeControl(false);

        // garbage collect previous recording
        this.removeRecording();

        // start or resume live preview
        this.load(this.stream);
        this.mediaElement.play();
    }

    /**
     * Show animated GIF.
     * @private
     */
    showAnimation() {
        let animationDisplay = this.player.animationDisplay.el().firstChild;

        // set the image size to the dimensions of the recorded animation
        animationDisplay.width = this.player.width();
        animationDisplay.height = this.player.height();

        // hide the first frame
        this.player.recordCanvas.hide();

        // show the animation
        setSrcObject(this.player.recordedData, animationDisplay);
        this.player.animationDisplay.show();
    }

    /**
     * Hide animated GIF.
     * @private
     */
    hideAnimation() {
        // show the first frame
        this.player.recordCanvas.show();

        // hide the animation
        this.player.animationDisplay.hide();
    }

    /**
     * Update time during playback.
     * @private
     */
    playbackTimeUpdate() {
        this.setCurrentTime(this.player.currentTime(),
            this.streamDuration);
    }

    /**
     * Collects information about the media input and output devices
     * available on the system.
     */
    enumerateDevices() {
        if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
            this.player.enumerateErrorCode = 'enumerateDevices() not supported.';
            this.player.trigger(Event.ENUMERATE_ERROR);
            return;
        }

        // list video and audio devices
        navigator.mediaDevices.enumerateDevices(this).then((devices) => {
            this.devices = [];
            devices.forEach((device) => {
                this.devices.push(device);
            });

            // notify listeners
            this.player.trigger(Event.ENUMERATE_READY);
        }).catch((err) => {
            this.player.enumerateErrorCode = err;
            this.player.trigger(Event.ENUMERATE_ERROR);
        });
    }

    /**
     * Change the video input device.
     *
     * @param {string} deviceId - Id of the video input device.
     */
    setVideoInput(deviceId) {
        if (this.recordVideo === Object(this.recordVideo)) {
            // already using video constraints
            this.recordVideo.deviceId = {exact: deviceId};

        } else if (this.recordVideo === true) {
            // not using video constraints already, so force it
            this.recordVideo = {
                deviceId: {exact: deviceId}
            };
        }

        // release existing device
        this.stopDevice();

        // ask for video input device permissions and start device
        this.getDevice();
    }

    /**
     * Change the audio input device.
     *
     * @param {string} deviceId - Id of the audio input device.
     */
    setAudioInput(deviceId) {
        if (this.recordAudio === Object(this.recordAudio)) {
            // already using audio constraints
            this.recordAudio.deviceId = {exact: deviceId};

        } else if (this.recordAudio === true) {
            // not using audio constraints already, so force it
            this.recordAudio = {
                deviceId: {exact: deviceId}
            };
        }

        // update wavesurfer microphone plugin constraints
        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                this.surfer.surfer.microphone.constraints = {
                    video: false,
                    audio: this.recordAudio
                };
                break;
        }

        // release existing device
        this.stopDevice();

        // ask for audio input device permissions and start device
        this.getDevice();
    }

    /**
     * Change the audio output device.
     *
     * @param {string} deviceId - Id of audio output device.
     */
    setAudioOutput(deviceId) {
        let errorMessage;
        switch (this.getRecordType()) {
            case AUDIO_ONLY:
                // use wavesurfer
                this.surfer.surfer.setSinkId(deviceId).then((result) => {
                    // notify listeners
                    this.player.trigger(Event.AUDIO_OUTPUT_READY);
                    return;
                }).catch((err) => {
                    errorMessage = err;
                });
                break;

            default:
                let element = player.tech_.el_;
                if (deviceId) {
                    if (typeof element.sinkId !== 'undefined') {
                        element.setSinkId(deviceId).then((result) => {
                            // notify listeners
                            this.player.trigger(Event.AUDIO_OUTPUT_READY);
                            return;
                        }).catch((err) => {
                            errorMessage = err;
                        });
                    } else {
                        errorMessage = 'Browser does not support audio output device selection.';
                    }
                } else {
                    errorMessage = `Invalid deviceId: ${deviceId}`;
                }
                break;
        }

        // error if we get here: notify listeners
        this.player.trigger(Event.ERROR, errorMessage);
    }

    /**
     * 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 !== undefined) {
            // video.js v8 and newer
            videojs.time.setFormatTime(this._formatTime);
        } else {
            videojs.setFormatTime(this._formatTime);
        }

        // audio-only
        if (this.surfer) {
            // use same time format as this plugin
            this.surfer.setFormatTime(this._formatTime);
        }
    }

    /**
     * Show or hide the volume menu.
     *
     * @private
     * @param {boolean} display - Hide/show volume control.
     */
    displayVolumeControl(display) {
        if (this.player.controlBar.volumePanel !== undefined) {
            if (display === true) {
                display = 'flex';
            } else {
                display = 'none';
            }
            this.player.controlBar.volumePanel.el().style.display = display;
        }
    }

    /**
     * Invoked when the video device is ready and stream is visible.
     *
     * @private
     * @param {Event} event - `playing` event
     */
    onStreamVisible(event) {
        // only listen for this once; remove listener
        this.mediaElement.removeEventListener(Event.PLAYING, this.streamVisibleCallback);

        // reset and show camera button
        this.player.cameraButton.onStop();
        this.player.cameraButton.show();
    }

    /**
     * Invoked when entering picture-in-picture mode.
     *
     * @private
     * @param {object} event - Event data.
     */
    onEnterPiP(event) {
        this.player.trigger(Event.ENTER_PIP, event);
    }

    /**
     * Invoked when leaving picture-in-picture mode.
     *
     * @private
     * @param {object} event - Event data.
     */
    onLeavePiP(event) {
        this.player.trigger(Event.LEAVE_PIP);
    }
}

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

// register plugin
videojs.Record = Record;
if (videojs.getPlugin('record') === undefined) {
    videojs.registerPlugin('record', Record);
}

// export plugin
export {Record};