reveal-embed-video.js

/**
 * reveal-embed-video.js is a plugin to include an live video stream (from a webcam) in
 * reveal.js slides.
 *
 * @namespace EmbedVideo
 * @author Thomas Weinert
 * @license MIT
 * @see {@link http://thomas.weinert.info/reveal-embed-video/|GitHub} for documentation, bug reports and more.
 */

'use strict';

/**
 * Plugin initialization
 * @function
 */
(function () {

  /**
   * @param {HTMLVideoElement} video
   * @param {boolean} persistent Keep stream for disabled video
   * @constructor
   * @memberOf EmbedVideo
   */
  var LiveStream = function (video, persistent) {
    /**
     * @type {HTMLVideoElement}
     * @private
     */
    this._video = video;
    /**
     * @type {boolean}
     * @private
     */
    this._persistent = persistent;
    /**
     * @type {EmbedVideo.LiveStream.STATUS}
     * @private
     */
    this._status = LiveStream.STATUS.DISABLED;
    /**
     * @type {?(MediaStream|MediaSource|Blob|File)}
     * @private
     */
    this._stream = null;
    /**
     * @type {string[]}
     * @private
     */
    this._devices = null;
    /**
     * @type {?string}
     * @private
     */
    this._currentDeviceId = null;
  };

  /**
   * @typedef {number} EmbedVideo.LiveStream.STATUS
   */

  /**
   * @enum {EmbedVideo.LiveStream.STATUS}
   */
  LiveStream.STATUS = {
    DISABLED: 0,
    PENDING: 1,
    ACTIVE: 2,
    ERROR: -1
  };

  /**
   * Start streaming, activate an existing stream or create a new one.
   * If here is an active stream this call will do nothing.
   */
  LiveStream.prototype.start = function () {
    if (this._status === LiveStream.STATUS.DISABLED) {
      if (this._stream) {
        this._enable();
      } else {
        this._create();
      }
    }
  };

  /**
   * Check if the stream is active
   * @returns {boolean}
   */
  LiveStream.prototype.isActive = function () {
    console.log(this._status);
    return this._status === LiveStream.STATUS.ACTIVE;
  };

  /**
   * Stop video stream and disable video
   */
  LiveStream.prototype.stop = function () {
    if (this.isActive()) {
      this._disable();
    }
  };

  /**
   * Switch to the next video device
   */
  LiveStream.prototype.next = function () {
    var deviceId = null;
    if (
      this._devices instanceof Array &&
      this._devices.length > 1
    ) {
      deviceId = this._devices[0];
      if (this._currentDeviceId) {
        var index = this._devices.indexOf(this._currentDeviceId);
        if (index >= 0 && index + 1 < this._devices.length) {
          deviceId = this._devices[index + 1];
        }
      }
    }
    if (deviceId && deviceId !== this._currentDeviceId) {
      // noinspection JSUnusedGlobalSymbols
      this._currentDeviceId = deviceId;
      this._destroy();
      if (this.isActive()) {
        this._create();
      }
    }
  };

  /**
   * Activate video after Reveal is ready, wait with video activation until then
   * @private
   */
  LiveStream.prototype._enable = function () {
    var video;
    if (!Reveal.isReady()) {
      Reveal.addEventListener(
        'ready',
        /**
         * @this EmbedVideo.LiveStream
         */
        function () {
          this._enable()
        }.bind(this)
      );
    } else if (this._stream) {
      video = this._video;
      if (video.srcObject !== this._stream) {
        video.pause();
        video.srcObject = this._stream;
      }
      video.setAttribute('data-enabled', 'true');
      if (!video.playing) {
        video.play();
      }
      // noinspection JSUnusedGlobalSymbols
      this._status = LiveStream.STATUS.ACTIVE;
    }
  };

  /**
   * Fetch device list and create user media stream
   * @private
   */
  LiveStream.prototype._create = function () {
    var constraints = {
      audio: false,
      video: true
    };
    // noinspection JSUnusedGlobalSymbols
    this._status = LiveStream.STATUS.PENDING;
    if (null === this._devices) {
      // noinspection JSUnusedGlobalSymbols
      this._devices = [];
      navigator
        .mediaDevices
        .enumerateDevices()
        .then(
          /**
           * @param {Array.<MediaStream|MediaSource|Blob|File>} devices
           * @this EmbedVideo.LiveStream
           */
          function (devices) {
            for (var i = 0, c = devices.length; i < c; i++) {
              if (devices[i].kind.toLowerCase() === 'videoinput') {
                this._devices.push(devices[i].deviceId);
              }
            }
          }.bind(this)
        );
    }
    if (this._currentDeviceId) {
      constraints.video = {deviceId: this._currentDeviceId};
    }
    navigator
      .mediaDevices
      .getUserMedia(constraints)
      .then(
        /**
         * @this EmbedVideo.LiveStream
         */
        function (stream) {
          this._stream = stream;
          this._currentDeviceId = stream.getVideoTracks()[0].getSettings().deviceId;
          this._enable();
        }.bind(this)
      )
      .catch(
        /**
         * @this EmbedVideo.LiveStream
         */
        function (error) {
          console.log('getUserMedia error: ', error);
          this._status = LiveStream.STATUS.ERROR;
        }.bind(this)
      );
  };

  /**
   * Pause video, remove enabled status and stop stream
   * @private
   */
  LiveStream.prototype._disable = function () {
    var video = this._video;
    if (video instanceof HTMLVideoElement) {
      if (video.playing) {
        video.pause();
      }
      video.srcObject = null;
      video.removeAttribute('data-enabled');
      video.load();
      if (!this._persistent) {
        this._destroy();
      }
    }
    // noinspection JSUnusedGlobalSymbols
    this._status = LiveStream.STATUS.DISABLED;
  };

  /**
   * @private
   */
  LiveStream.prototype._destroy = function() {
    if (this._stream) {
      this._stream.getTracks().forEach(
        function (track) {
          track.stop();
        }
      );
      // noinspection JSUnusedGlobalSymbols
      this._stream = null;
    }
  };

  /**
   * @param {EmbedVideo.Plugin.Options} options
   * @constructor
   * @memberOf EmbedVideo
   */
  var Plugin = function (options) {
    var style;
    var _isEnabled = options.enabled;

    /**
     * is video the video display enabled
     * @returns {boolean}
     */
    this.isEnabled = function() {
      return _isEnabled;
    };

    /**
     * enabled/disable the video display
     * @method
     * @returns {boolean}
     */
    this.toggle = function() {
      _isEnabled = !_isEnabled;
      this.update();
      return _isEnabled;
    }.bind(this);

    /**
     * CSS class to identify the video element (avoid conflicts with other videos)
     * @type {string}
     * @private
     */
    this._identfierClass = 'live-video';
    /**
     * @type {HTMLVideoElement}
     * @private
     */
    this._video = document.querySelector('.reveal').appendChild(
      document.createElement('video')
    );
    this._video.setAttribute('class', this._identfierClass);
    this._video.addEventListener(
      'click',
      /**
       * @this EmbedVideo.Plugin
       */
      function () {
        this._stream.next();
      }.bind(this)
    );
    /**
     * @type {EmbedVideo.LiveStream}
     * @private
     */
    this._stream = new LiveStream(this._video, options.persistent);

    style = document.createElement('link');
    style.rel = 'stylesheet';
    style.href = options.path + '/reveal-embed-video.css';
    document.querySelector('head').appendChild(style);

    Reveal.addEventListener(
      'ready',
      /**
       * @this EmbedVideo.Plugin
       */
      function() {
        Reveal.registerKeyboardShortcut('C', 'Toggle speaker camera');
        Reveal.configure(
          {
            keyboard: {
              67: this.toggle.bind(this)
            }
          }
        );
      }.bind(this)
    );
    Reveal.addEventListener(
      'slidechanged',
      this.update.bind(this)
    );
  };

  /**
   * Update plugin status in DOM
   */
  Plugin.prototype.update = function () {
    var newVideoClass, enable;
    newVideoClass = this.getVideoClass(Reveal.getCurrentSlide());
    enable = this.isEnabled() && newVideoClass;
    if (this._stream.isActive() && !enable) {
      this._video.setAttribute('class', this._identfierClass);
      this._stream.stop();
    }
    if (enable) {
      this._video.setAttribute('class', this._identfierClass + ' ' + newVideoClass);
      this._stream.start();
    }
  };

  /**
   * Fetch the slide specific style class for the video element
   * from the `data-video` attribute.
   *
   * @param {HTMLElement} element
   * @returns {?string}
   */
  Plugin.prototype.getVideoClass = function (element) {
    if (element instanceof Element) {
      var nodeVideoClass = element.getAttribute('data-video');
      /**
       * @type {HTMLElement|ParentNode}
       */
      var node = element;
      do {
        nodeVideoClass = node.getAttribute('data-video');
        node = node.parentNode;
      } while (!nodeVideoClass && node);
      element.setAttribute('data-video', nodeVideoClass || 'false');
      return (
        nodeVideoClass &&
        nodeVideoClass !== 'false' &&
        nodeVideoClass !== 'blank'
      ) ? nodeVideoClass : null;
    }
    return null;
  };

  /**
   * obtain plugin path from the script element
   * @returns {string}
   * @memberOf EmbedVideo
   */
  var getScriptPath = function () {
    var path;
    var end = -('/reveal-embed-video.js'.length);
    if (document.currentScript && document.currentScript['src']) {
      path = document.currentScript['src'].slice(0, end);
    } else {
      var scriptTag = document.querySelector('script[src$="/reveal-embed-video.js"]');
      if (scriptTag) {
        path = scriptTag.src.slice(0, end);
      }
    }
    return path;
  };

  /**
   * @type {Object.<string, *>}
   */
  var config = Reveal.getConfig();

  /**
   * @typedef EmbedVideo.Plugin.Options
   * @property {boolean} enabled Enable the plugin on startup
   * @property {boolean} persistent Keep stream active on disable
   * @property {string} path Script path
   */

  /**
   * @type {EmbedVideo.Plugin.Options}
   */
  var options = config['embed-video'] || {};
  options.enabled = !!options.enabled; // enable live video (toggle with [C])
  options.persistent = !!options.persistent; // keep camera active if hidden
  options.path = options.path || getScriptPath() || 'plugin/reveal-embed-video';

  new Plugin(options);
})();