Source: new-segment-loader.js

import window from 'global/window';
import videojs from 'video.js';
import SourceUpdater from './source-updater';
import Config from './config';
import {inspect as inspectSegment} from 'mux.js/lib/tools/ts-inspector.js';
import Ranges from './ranges';
import {getMediaIndexForTime_} from './playlist';

// milliseconds
const GET_SOME_VIDEO_DELAY = 500;

const abortXhr = (xhr) => {
  if (!xhr) {
    return;
  }

  // Prevent error handler from running.
  xhr.onreadystatechange = null;
  xhr.abort();
};

/**
 * Turns segment byterange into a string suitable for use in
 * HTTP Range requests
 */
const byterangeStr = function(byterange) {
  let byterangeStart;
  let byterangeEnd;

  // `byterangeEnd` is one less than `offset + length` because the HTTP range
  // header uses inclusive ranges
  byterangeEnd = byterange.offset + byterange.length - 1;
  byterangeStart = byterange.offset;
  return 'bytes=' + byterangeStart + '-' + byterangeEnd;
};

/**
 * Defines headers for use in the xhr request for a particular segment.
 */
const segmentXhrHeaders = function(segment) {
  let headers = {};

  if ('byterange' in segment) {
    headers.Range = byterangeStr(segment.byterange);
  }
  return headers;
};

export default class NewSegmentLoader extends videojs.EventTarget {
  constructor({mediaSource, currentTime, bandwidth, hls, mimeType, setCurrentTime}) {
    super();

    // TODO bandwidth and tracking

    this.currentTime_ = currentTime;
    this.setCurrentTime_ = setCurrentTime;
    this.mediaSource_ = mediaSource;
    this.xhr_ = hls.xhr;

    // start paused
    this.pause();

    this.getSomeVideoInterval = window.setInterval(
      this.getSomeVideo.bind(this), GET_SOME_VIDEO_DELAY);
  }

  mimeType(mimeType) {
    this.sourceUpdater_ = new SourceUpdater(this.mediaSource_, mimeType);
    this.clearEverything();
    this.getSomeVideo();
  }

  playlist(playlist, xhrOptions) {
    this.playlist_ = playlist;
    this.xhrOptions_ = xhrOptions;
  }

  expired(expiredTime) {
    this.expired_ = expiredTime;
  }

  pause() {
    this.paused_ = true;
    this.clearEverything();
  }

  resume() {
    this.paused_ = false;
  }

  clearEverything() {
    this.alreadyGettingSomeVideo_ = false;
    abortXhr(this.keyXhr_);
    abortXhr(this.segmentXhr_);
    this.segmentInfo_ = null;
  }

  /**
   * set an error on the segment loader
   *
   * @param {Error} error the error to set on the SegmentLoader
   * @return {Error} the error that was set or that is currently set
   */
  error(error) {
    if (error) {
      this.error_ = error;
    }
    return this.error_;
  }

  buffered() {
    return this.sourceUpdater_.buffered();
  }

  plentyOfBuffer() {
    let buffered = this.buffered();
    let endOfBuffer = buffered.length ? buffered.end(buffered.length - 1) : 0;
    let currentTime = this.currentTime_();

    if (currentTime > endOfBuffer) {
      return false;
    }

    let currentBuffered = Ranges.findRange(buffered, currentTime);

    if (currentBuffered.length === 0) {
      return false;
    }

    return currentBuffered.end(0) - currentTime >= Config.GOAL_BUFFER_LENGTH;
  }

  getSomeVideo() {
    if (this.paused_ ||
        this.alreadyGettingSomeVideo_ ||
        !this.sourceUpdater_ ||
        !this.playlist_ ||
        !this.playlist_.segments.length ||
        this.plentyOfBuffer()) {
      return;
    }

    this.alreadyGettingSomeVideo_ = true;

    let mediaIndex = this.getNextMediaIndex({
      playlist: this.playlist_,
      currentTime: this.currentTime_(),
      buffered: this.buffered(),
    });

    console.log(mediaIndex);

    if (mediaIndex < 0) {
      return;
    }

    let segment = this.playlist_.segments[mediaIndex];

    this.requestSegment({
      segment,
      mediaIndex,
      playlist: this.playlist_,
    });
  }

  requestSegment(segmentInfo) {
    if (segmentInfo.segment.key) {
      let keyRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
        uri: segmentInfo.segment.key.resolvedUri,
        responseType: 'arraybuffer'
      });

      this.keyXhr_ = this.xhr_(keyRequestOptions, this.handleResponse.bind(this));
    }

    let segmentRequestOptions = videojs.mergeOptions(this.xhrOptions_, {
      uri: segmentInfo.segment.resolvedUri,
      responseType: 'arraybuffer',
      headers: segmentXhrHeaders(segmentInfo.segment)
    });

    this.segmentXhr_ = this.xhr_(segmentRequestOptions, this.handleResponse.bind(this));

    this.segmentInfo_ = segmentInfo;
  }

  handleResponse(err, request) {
    if (!this.segmentXhr_ ||
        (request !== this.segmentXhr_ && request !== this.keyXhr_) ||
        this.paused_) {
      return;
    }

    // if a request times out, reset bandwidth tracking
    if (request.timedout) {
      this.clearEverything();
      this.trigger('progress');
      return;
    }

    // trigger an event for other errors
    if (!request.aborted && err) {
      this.pause();
      this.error({
        status: request.status,
        message: request === this.keyXhr_ ?
          'HLS key request error at URL: ' + this.segmentInfo_.segment.key.resolvedUri :
          'HLS segment request error at URL: ' + this.segmentInfo_.segment.resolvedUri,
        code: 2,
        xhr: request,
        err: err
      });
      this.trigger('error');
      return;
    }

    // stop processing if the request was aborted
    if (!request.response) {
      this.clearEverything();
      return;
    }

    if (request === this.segmentXhr_) {
      // the segment request is no longer outstanding
      this.segmentXhr_ = null;

      // calculate the download bandwidth based on segment request
      this.roundTrip = request.roundTripTime;
      this.bandwidth = request.bandwidth;
      this.mediaBytesTransferred += request.bytesReceived || 0;
      this.mediaRequests += 1;
      this.mediaTransferDuration += request.roundTripTime || 0;

      let encrypted = this.segmentInfo_.segment.key;

      this.segmentInfo_[encrypted ? 'encryptedBytes' : 'bytes'] =
        new Uint8Array(request.response);
    }

    if (request === this.keyXhr_) {
      keyXhrRequest = this.xhr_.segmentXhr;
      // the key request is no longer outstanding
      this.xhr_.keyXhr = null;

      if (request.response.byteLength !== 16) {
        this.abort_();
        this.error({
          status: request.status,
          message: 'Invalid HLS key at URL: ' + this.segmentInfo_.segment.key.uri,
          code: 2,
          xhr: request
        });
        this.state = 'READY';
        this.pause();
        return this.trigger('error');
      }

      view = new DataView(request.response);
      this.segmentInfo_.segment.key.bytes = new Uint32Array([
        view.getUint32(0),
        view.getUint32(4),
        view.getUint32(8),
        view.getUint32(12)
      ]);

      // if the media sequence is greater than 2^32, the IV will be incorrect
      // assuming 10s segments, that would be about 1300 years
      this.segmentInfo_.segment.key.iv = this.segmentInfo_.segment.key.iv ||
        new Uint32Array([
          0,
          0,
          0,
          this.segmentInfo_.mediaIndex + this.segmentInfo_.playlist.mediaSequence
        ]);
    }

    if (!this.segmentXhr_ && !this.keyXhr_) {
      this.processResponse(this.segmentInfo_);
    }
  }

  processResponse(segmentInfo) {
    if (segmentInfo.segment.key) {
      let decryptedSegmentInfo = segmentInfo;

      // this is an encrypted segment
      // incrementally decrypt the segment
      /* eslint-disable no-new, handle-callback-err */
      new Decrypter(
        segmentInfo.encryptedBytes, segmentInfo.segment.key.bytes,
        segmentInfo.segment.key.iv, (err, bytes) => {
        // err always null

        if (this.segmentInfo_ !== decryptedSegmentInfo) {
          return;
        }

        segmentInfo.bytes = bytes;
        this.appendSegment(segmentInfo);
      });
      /* eslint-enable */
    } else {
      this.appendSegment(segmentInfo);
    }
  }

  updateTimestampOffset(segment) {
    if (this.playlist_ !== this.bufferPlaylist_ ||
        this.playlist_.discontinuityStarts[segment.uri]) {
      console.log('Setting timestamp offset to: ' + segment.timeInfo.start);
      this.sourceUpdater_.timestampOffset(segment.timeInfo.start);
      this.bufferPlaylist_ = this.playlist_;
    }
  }

  appendSegment(segmentInfo) {
    let timeInfo = inspectSegment(segmentInfo.bytes, this.inspectCache_);

    if (timeInfo.video && timeInfo.video.length === 2) {
      timeInfo.start = timeInfo.video[0].dts;
      timeInfo.end = timeInfo.video[1].dts;
      this.inspectCache_ = timeInfo.end;
    } else if (timeInfo.audio && timeInfo.audio.length === 2) {
      timeInfo.start = timeInfo.audio[0].dts;
      timeInfo.end = timeInfo.audio[1].dts;
      this.inspectCache_ = timeInfo.end;
    }

    segmentInfo.segment.timeInfo = timeInfo;

    console.log(timeInfo);

    this.updateTimestampOffset(segmentInfo.segment);

    this.sourceUpdater_.appendBuffer(segmentInfo.bytes, () => {
      this.trigger('progress');

      // TODO check end of stream

      this.alreadyGettingSomeVideo_ = false;

      this.getSomeVideo();
    });
  }

  destroy() {
    this.clearEverything();
    window.clearInterval(this.getSomeVideoInterval);
  }

  findNextNonBuffered({playlist, buffered, index}) {
    for (; index <= playlist.segments.length; index++) {
      let segment = playlist.segments[index];

      if (!segment.timeInfo) {
        return index;
      }

      let midpoint = (segment.timeInfo.end - segment.timeInfo.start) / 2;

      if (!Ranges.findRange(buffered, midpoint)) {
        return index;
      }
    }

    return index;
  }

  // TODO remove
  bufferStr(buffer) {
    let bufferStr = ''

    for (let i = 0; i < buffer.length; i++) {
      bufferStr += ` ${buffer.start(i)} => ${buffer.end(i)}`;
    }

    return bufferStr;
  }

  indexForTime(playlist, time, expired) {
    let index = playlist.segments.findIndex((segment) => {
      return segment.timeInfo &&
        segment.timeInfo.video &&
        segment.timeInfo.start <= time &&
        segment.timeInfo.end >= time;
    });

    if (index === -1) {
      return getMediaIndexForTime_(playlist, time, expired);
    }
    return index;
  }

  getNextMediaIndex({playlist, currentTime, buffered}) {
    console.log('Getting with: ', {
      currentTime,
      seg: playlist.segments[0],
      buffered: this.bufferStr(buffered)
    });

    let currentBuffered = Ranges.findRange(buffered, currentTime);

    if (currentBuffered.length > 0) {
      // playing, grab next non buffered range from current buffered range
      let currentMediaIndex = playlist.segments.findIndex((segment) => {
        return segment.timeInfo &&
          segment.timeInfo.start <= currentTime &&
          segment.timeInfo.end >= currentTime;
      });

      if (currentMediaIndex < 0) {
        return 0;
      }

      console.log('Finding with ' + currentMediaIndex);
      return this.findNextNonBuffered({playlist, buffered, index: currentMediaIndex});
    }

    let index = this.indexForTime(playlist, currentTime, this.expired_);

    // find CLOSEST non buffered range
    let jump = 1;

    console.log('Jumping with ' + index);
    while (index < playlist.segments.length && index >= 0) {
      let segment = playlist.segments[index];

      if (!segment.timeInfo) {
        return index;
      }

      let midpoint = (segment.timeInfo.end - segment.timeInfo.start) / 2;

      if (!Ranges.findRange(buffered, midpoint)) {
        console.log('Jumped: ' + jump);
        return index;
      }

      index += jump;
      jump = -1 * (jump + 1);
    }

    console.log('OUT');
    return -1;
  }
}