nypublicradio/ember-hifi

View on GitHub
addon/hifi-connections/hls.js

Summary

Maintainability
A
1 hr
Test Coverage
F
46%
import Mixin from '@ember/object/mixin';
import BaseSound from './base';
import HLS from 'hls';

let ClassMethods = Mixin.create({
  acceptMimeTypes:  ['application/vnd.apple.mpegurl'],

  canUseConnection(/* audioUrl */) {
    // We basically never want to use this on a mobile device
    return HLS.isSupported();
  },

  toString() {
    return 'HLS';
  }
});


/**
* This class connects with HLS.js to create sounds.
*
* @class HLS
* @extends BaseSound
* @constructor
*/
let Sound = BaseSound.extend({
  loaded: false,
  mediaRecoveryAttempts: 0,
  id3TagMetadata: null,

  setup() {
    let hls   = new HLS({debug: false, startFragPrefetch: true});
    let video = document.createElement('video');

    this.set('video', video);
    this.set('hls', hls);
    hls.attachMedia(video);
    this._setupHLSEvents(hls);
    this._setupPlayerEvents(video);
  },

  _setupHLSEvents(hls) {
    hls.on(HLS.Events.MEDIA_ATTACHED, () => {
      this.debug('media attached');
      hls.loadSource(this.get('url'));

      hls.on(HLS.Events.MANIFEST_PARSED, (e, data) => {
        this.debug(`manifest parsed and loaded, found ${data.levels.length} quality level(s)`);
        this.set('manifest', data);
      });

      hls.on(HLS.Events.LEVEL_LOADED, (e, data) => {
        this.debug(`level ${data.level} loaded`);
        this.set('live', data.details.live)
        this._checkIfAudioIsReady();
      });

      hls.on(HLS.Events.AUDIO_TRACK_LOADED, () => {
        this.debug('audio track loaded');
        this._checkIfAudioIsReady();
      });

      hls.on(HLS.Events.ERROR, (e, data) => this._onHLSError(e, data));

      var self = this;
      hls.on(HLS.Events.FRAG_CHANGED, (e, f) => {
        let newId3TagMetadata = {
          title: f.frag.title
        }

        if (JSON.stringify(self.get('id3TagMetadata')) !== JSON.stringify(newId3TagMetadata)) {
          this.debug('hls metadata changed');
          this.trigger('audio-metadata-changed', this, {
            old: self.get('id3TagMetadata'),
            new: newId3TagMetadata
          });
          self.set('id3TagMetadata', newId3TagMetadata);
        }
      });
    });
  },

  _setupPlayerEvents(video) {
    video.addEventListener('playing',         () => {
      if (this.get('loaded')) {
        this.trigger('audio-played', this);
      }
      else {
        this._signalAudioIsReady();
      }
    });

    video.addEventListener('pause',           ()  => this.trigger('audio-paused', this));
    video.addEventListener('durationchange',  ()  => this.trigger('audio-duration-changed', this));
    video.addEventListener('seeked',          ()  => this.trigger('audio-position-changed', this));
    video.addEventListener('progress',        ()  => this.trigger('audio-loading'));
    video.addEventListener('error',           (e) => this._onVideoError(e));
  },

  _checkIfAudioIsReady() {
    if (!this.get('loaded')) {
      // The only reliable way to check if this thing is actually ready
      // is to play it. If we get a play signal we're golden, but if we
      // get an error, we're outta here

      this.debug('Testing if audio is ready');
      this.get('video').volume = 0;
      this.get('video').play();
    }
  },

  _signalAudioIsReady() {
    this.debug('Test succeeded, signaling audio-ready');
    this.set('loaded', true);
    this.get('video').pause();
    this.trigger('audio-ready');
  },

  _onVideoError(e) {
    switch (e.target.error.code) {
      case e.target.error.MEDIA_ERR_ABORTED:
        this.debug("video element error: playback aborted");
        this._giveUpAndDie("unknown error");
        break;
      case e.target.error.MEDIA_ERR_NETWORK:
        this.debug("video element error: network error");
        this._giveUpAndDie("Network error caused download to fail");
        break;
      case e.target.error.MEDIA_ERR_DECODE:
        this.debug("video element error: decoding error");
        this._tryToRecoverFromMediaError(e.target.error.MEDIA_ERR_DECODE);
        break;
      case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
        this.debug("video element error: source format not supported");
        this._giveUpAndDie("audio source format is not supported");
        break;
      default:
        this._giveUpAndDie("unknown error");
        break;
    }
  },

  _onHLSError(error, data) {
    if (data.fatal) {
      switch(data.type) {
        case HLS.ErrorTypes.NETWORK_ERROR:
          this.debug(data);
          this._giveUpAndDie(`${data.details}`);
          break;
        case HLS.ErrorTypes.MEDIA_ERROR:
          this._tryToRecoverFromMediaError(`${data.details}`);
          break;
        default:
          this._giveUpAndDie(`${data.details}`);
          break;
      }
    }
  },

  _tryToRecoverFromMediaError(error) {
    let mediaRecoveryAttempts = this.get('mediaRecoveryAttempts');
    let hls = this.get('hls');

    switch(mediaRecoveryAttempts) {
      case 0:
        this.debug(`First attempt at media error recovery for error: ${error}`);
        hls.recoverMediaError();
        break;
      case 1:
        this.debug(`Second attempt at media error recovery: switching codecs for error: ${error}`);
        hls.swapAudioCodec();
        hls.recoverMediaError();
        break;
      case 2:
        this.debug(`We tried our best and we failed: ${error}`);
        this._giveUpAndDie(error);
        break;
    }

    this.incrementProperty('mediaRecoveryAttempts');
  },

  _giveUpAndDie(error) {
    this.get('hls').destroy();
    this.trigger('audio-load-error', error);
  },


  /* Public interface to sound */

  _audioDuration() {
    if (this.get('live')) {
      return Infinity
    }
    else {
      return this.get('video').duration * 1000;
    }
  },

  _currentPosition() {
    return this.get('video').currentTime * 1000;
  },

  _setPosition(position) {
    this.get('video').currentTime = (position / 1000);

    return position;
  },

  _setVolume(volume) {
    this.get('video').volume = (volume/100);
  },

  play() {
    if (!this.get('video').src) {
      this.setup(); // the stream was stopped before
    }

    this.get('video').play();

    if (this.get('loadStopped')) {
      this.get('hls').startLoad();
      this.set('loadStopped', false);
    }
  },

  pause() {
    this.get('video').pause();
    this.get('hls').stopLoad();
    this.set('loadStopped', true);
  },

  stop() {
    this.pause();
    this.get('video').removeAttribute('src')
  },

  teardown() {
    this.get('hls').destroy();
  }
});

Sound.reopenClass(ClassMethods);

export default Sound;