Source: flash-source-buffer.js

/**
 * @file flash-source-buffer.js
 */
import window from 'global/window';
import videojs from 'video.js';
import flv from 'mux.js/lib/flv';
import removeCuesFromTrack from './remove-cues-from-track';
import createTextTracksIfNecessary from './create-text-tracks-if-necessary';
import addTextTrackData from './add-text-track-data';
import FlashConstants from './flash-constants';

/**
 * A wrapper around the setTimeout function that uses
 * the flash constant time between ticks value.
 *
 * @param {Function} func the function callback to run
 * @private
 */
const scheduleTick = function(func) {
  // Chrome doesn't invoke requestAnimationFrame callbacks
  // in background tabs, so use setTimeout.
  window.setTimeout(func, FlashConstants.TIME_BETWEEN_TICKS);
};

/**
 * Round a number to a specified number of places much like
 * toFixed but return a number instead of a string representation.
 *
 * @param {Number} num A number
 * @param {Number} places The number of decimal places which to
 * round
 * @private
 */
const toDecimalPlaces = function(num, places) {
  if (typeof places !== 'number' || places < 0) {
    places = 0;
  }

  let scale = Math.pow(10, places);

  return Math.round(num * scale) / scale;
};

/**
 * A SourceBuffer implementation for Flash rather than HTML.
 *
 * @link https://developer.mozilla.org/en-US/docs/Web/API/MediaSource
 * @param {Object} mediaSource the flash media source
 * @class FlashSourceBuffer
 * @extends videojs.EventTarget
 */
export default class FlashSourceBuffer extends videojs.EventTarget {
  constructor(mediaSource) {
    super();
    let encodedHeader;

    // Start off using the globally defined value but refine
    // as we append data into flash
    this.chunkSize_ = FlashConstants.BYTES_PER_CHUNK;

    // byte arrays queued to be appended
    this.buffer_ = [];

    // the total number of queued bytes
    this.bufferSize_ = 0;

    // to be able to determine the correct position to seek to, we
    // need to retain information about the mapping between the
    // media timeline and PTS values
    this.basePtsOffset_ = NaN;

    this.mediaSource = mediaSource;

    // indicates whether the asynchronous continuation of an operation
    // is still being processed
    // see https://w3c.github.io/media-source/#widl-SourceBuffer-updating
    this.updating = false;
    this.timestampOffset_ = 0;

    // TS to FLV transmuxer
    this.segmentParser_ = new flv.Transmuxer();
    this.segmentParser_.on('data', this.receiveBuffer_.bind(this));
    encodedHeader = window.btoa(
      String.fromCharCode.apply(
        null,
        Array.prototype.slice.call(
          this.segmentParser_.getFlvHeader()
        )
      )
    );
    this.mediaSource.swfObj.vjs_appendBuffer(encodedHeader);

    this.one('updateend', () => {
      this.mediaSource.tech_.trigger('loadedmetadata');
    });

    Object.defineProperty(this, 'timestampOffset', {
      get() {
        return this.timestampOffset_;
      },
      set(val) {
        if (typeof val === 'number' && val >= 0) {
          this.timestampOffset_ = val;
          this.segmentParser_ = new flv.Transmuxer();
          this.segmentParser_.on('data', this.receiveBuffer_.bind(this));
          // We have to tell flash to expect a discontinuity
          this.mediaSource.swfObj.vjs_discontinuity();
          // the media <-> PTS mapping must be re-established after
          // the discontinuity
          this.basePtsOffset_ = NaN;
        }
      }
    });

    Object.defineProperty(this, 'buffered', {
      get() {
        if (!this.mediaSource ||
            !this.mediaSource.swfObj ||
            !('vjs_getProperty' in this.mediaSource.swfObj)) {
          return videojs.createTimeRange();
        }

        let buffered = this.mediaSource.swfObj.vjs_getProperty('buffered');

        if (buffered && buffered.length) {
          buffered[0][0] = toDecimalPlaces(buffered[0][0], 3);
          buffered[0][1] = toDecimalPlaces(buffered[0][1], 3);
        }
        return videojs.createTimeRanges(buffered);
      }
    });

    // On a seek we remove all text track data since flash has no concept
    // of a buffered-range and everything else is reset on seek
    this.mediaSource.player_.on('seeked', () => {
      removeCuesFromTrack(0, Infinity, this.metadataTrack_);
      removeCuesFromTrack(0, Infinity, this.inbandTextTrack_);
    });
  }

  /**
   * Append bytes to the sourcebuffers buffer, in this case we
   * have to append it to swf object.
   *
   * @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/appendBuffer
   * @param {Array} bytes
   */
  appendBuffer(bytes) {
    let error;
    let chunk = 512 * 1024;
    let i = 0;

    if (this.updating) {
      error = new Error('SourceBuffer.append() cannot be called ' +
                        'while an update is in progress');
      error.name = 'InvalidStateError';
      error.code = 11;
      throw error;
    }

    this.updating = true;
    this.mediaSource.readyState = 'open';
    this.trigger({ type: 'update' });

    // this is here to use recursion
    let chunkInData = () => {
      this.segmentParser_.push(bytes.subarray(i, i + chunk));
      i += chunk;
      if (i < bytes.byteLength) {
        scheduleTick(chunkInData);
      } else {
        scheduleTick(this.segmentParser_.flush.bind(this.segmentParser_));
      }
    };

    chunkInData();
  }

  /**
   * Reset the parser and remove any data queued to be sent to the SWF.
   *
   * @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/abort
   */
  abort() {
    this.buffer_ = [];
    this.bufferSize_ = 0;
    this.mediaSource.swfObj.vjs_abort();

    // report any outstanding updates have ended
    if (this.updating) {
      this.updating = false;
      this.trigger({ type: 'updateend' });
    }
  }

  /**
   * Flash cannot remove ranges already buffered in the NetStream
   * but seeking clears the buffer entirely. For most purposes,
   * having this operation act as a no-op is acceptable.
   *
   * @link https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer/remove
   * @param {Double} start start of the section to remove
   * @param {Double} end end of the section to remove
   */
  remove(start, end) {
    removeCuesFromTrack(start, end, this.metadataTrack_);
    removeCuesFromTrack(start, end, this.inbandTextTrack_);
    this.trigger({ type: 'update' });
    this.trigger({ type: 'updateend' });
  }

  /**
   * Receive a buffer from the flv.
   *
   * @param {Object} segment
   * @private
   */
  receiveBuffer_(segment) {
    // create an in-band caption track if one is present in the segment
    createTextTracksIfNecessary(this, this.mediaSource, segment);
    addTextTrackData(this, segment.captions, segment.metadata);

    // Do this asynchronously since convertTagsToData_ can be time consuming
    scheduleTick(() => {
      let flvBytes = this.convertTagsToData_(segment);

      if (this.buffer_.length === 0) {
        scheduleTick(this.processBuffer_.bind(this));
      }

      if (flvBytes) {
        this.buffer_.push(flvBytes);
        this.bufferSize_ += flvBytes.byteLength;
      }
    });
  }

  /**
   * Append a portion of the current buffer to the SWF.
   *
   * @private
   */
  processBuffer_() {
    let chunk;
    let i;
    let length;
    let binary;
    let b64str;
    let startByte = 0;
    let appendIterations = 0;
    let startTime = +(new Date());
    let appendTime;

    if (!this.buffer_.length) {
      if (this.updating !== false) {
        this.updating = false;
        this.trigger({ type: 'updateend' });
      }
      // do nothing if the buffer is empty
      return;
    }

    do {
      appendIterations++;
      // concatenate appends up to the max append size
      chunk = this.buffer_[0].subarray(startByte, startByte + this.chunkSize_);

      // requeue any bytes that won't make it this round
      if (chunk.byteLength < this.chunkSize_ ||
          this.buffer_[0].byteLength === startByte + this.chunkSize_) {
        startByte = 0;
        this.buffer_.shift();
      } else {
        startByte += this.chunkSize_;
      }

      this.bufferSize_ -= chunk.byteLength;

      // base64 encode the bytes
      binary = '';
      length = chunk.byteLength;
      for (i = 0; i < length; i++) {
        binary += String.fromCharCode(chunk[i]);
      }
      b64str = window.btoa(binary);

      // bypass normal ExternalInterface calls and pass xml directly
      // IE can be slow by default
      this.mediaSource.swfObj.CallFunction(
        '<invoke name="vjs_appendBuffer"' +
        'returntype="javascript"><arguments><string>' +
        b64str +
        '</string></arguments></invoke>'
      );
      appendTime = (new Date()) - startTime;
    } while (this.buffer_.length &&
        appendTime < FlashConstants.TIME_PER_TICK);

    if (this.buffer_.length && startByte) {
      this.buffer_[0] = this.buffer_[0].subarray(startByte);
    }

    if (appendTime >= FlashConstants.TIME_PER_TICK) {
      // We want to target 4 iterations per time-slot so that gives us
      // room to adjust to changes in Flash load and other externalities
      // such as garbage collection while still maximizing throughput
      this.chunkSize_ = Math.floor(this.chunkSize_ * (appendIterations / 4));
    }

    // We also make sure that the chunk-size doesn't drop below 1KB or
    // go above 1MB as a sanity check
    this.chunkSize_ = Math.max(
      FlashConstants.MIN_CHUNK,
      Math.min(this.chunkSize_, FlashConstants.MAX_CHUNK));

    // schedule another append if necessary
    if (this.bufferSize_ !== 0) {
      scheduleTick(this.processBuffer_.bind(this));
    } else {
      this.updating = false;
      this.trigger({ type: 'updateend' });

    }
  }

  /**
   * Turns an array of flv tags into a Uint8Array representing the
   * flv data. Also removes any tags that are before the current
   * time so that playback begins at or slightly after the right
   * place on a seek
   *
   * @private
   * @param {Object} segmentData object of segment data
   */
  convertTagsToData_(segmentData) {
    let segmentByteLength = 0;
    let tech = this.mediaSource.tech_;
    let targetPts = 0;
    let i;
    let j;
    let segment;
    let filteredTags = [];
    let tags = this.getOrderedTags_(segmentData);

    // Establish the media timeline to PTS translation if we don't
    // have one already
    if (isNaN(this.basePtsOffset_) && tags.length) {
      this.basePtsOffset_ = tags[0].pts;
    }

    // Trim any tags that are before the end of the end of
    // the current buffer
    if (tech.buffered().length) {
      targetPts = tech.buffered().end(0) - this.timestampOffset;
    }
    // Trim to currentTime if it's ahead of buffered or buffered doesn't exist
    targetPts = Math.max(targetPts, tech.currentTime() - this.timestampOffset);

    // PTS values are represented in milliseconds
    targetPts *= 1e3;
    targetPts += this.basePtsOffset_;

    // skip tags with a presentation time less than the seek target
    for (i = 0; i < tags.length; i++) {
      if (tags[i].pts >= targetPts) {
        filteredTags.push(tags[i]);
      }
    }

    if (filteredTags.length === 0) {
      return;
    }

    // concatenate the bytes into a single segment
    for (i = 0; i < filteredTags.length; i++) {
      segmentByteLength += filteredTags[i].bytes.byteLength;
    }
    segment = new Uint8Array(segmentByteLength);
    for (i = 0, j = 0; i < filteredTags.length; i++) {
      segment.set(filteredTags[i].bytes, j);
      j += filteredTags[i].bytes.byteLength;
    }

    return segment;
  }

  /**
   * Assemble the FLV tags in decoder order.
   *
   * @private
   * @param {Object} segmentData object of segment data
   */
  getOrderedTags_(segmentData) {
    let videoTags = segmentData.tags.videoTags;
    let audioTags = segmentData.tags.audioTags;
    let tag;
    let tags = [];

    while (videoTags.length || audioTags.length) {
      if (!videoTags.length) {
        // only audio tags remain
        tag = audioTags.shift();
      } else if (!audioTags.length) {
        // only video tags remain
        tag = videoTags.shift();
      } else if (audioTags[0].dts < videoTags[0].dts) {
        // audio should be decoded next
        tag = audioTags.shift();
      } else {
        // video should be decoded next
        tag = videoTags.shift();
      }

      tags.push(tag.finalize());
    }

    return tags;
  }
}