nypublicradio/ember-hifi

View on GitHub
addon/hifi-connections/native-audio.js

Summary

Maintainability
C
7 hrs
Test Coverage
B
82%
import { A } from '@ember/array';
import { run } from '@ember/runloop';
import Mixin from '@ember/object/mixin';
import BaseSound from './base';
import Ember from 'ember';
// These are the events we're watching for
const AUDIO_EVENTS = ['loadstart', 'durationchange', 'loadedmetadata', 'loadeddata', 'progress', 'canplay', 'canplaythrough', 'error', 'playing', 'pause', 'ended', 'emptied'];

// Ready state values
// const HAVE_NOTHING = 0;
// const HAVE_METADATA = 1;
const HAVE_CURRENT_DATA = 2;
// const HAVE_FUTURE_DATA = 3;
// const HAVE_ENOUGH_DATA = 4;

let ClassMethods = Mixin.create({
  canPlayMimeType(mimeType) {
    let audio = new Audio();
    // it returns "probably" and "maybe". Both are worth trying. Empty is bad.
    return (audio.canPlayType(mimeType) !== "");
  },

  toString() {
    return 'Native Audio';
  }
});

/**
* This class connects with the native audio element to create sounds.
*
* @class NativeAudio
* @extends BaseSound
* @constructor
*/
let Sound = BaseSound.extend({
  setup() {
    let audio = this.requestControl();

    audio.src = this.get('url');
    this._registerEvents(audio);

    if (Ember.testing) {
      console.warn('setting audio element volume to zero for testing, to get around autoplay restrictions'); // eslint-disable-line
      audio.muted = true;
    }

    audio.load();
  },

  _registerEvents(audio) {
    AUDIO_EVENTS.forEach(eventName => {
      audio.addEventListener(eventName, e => run(() => this._handleAudioEvent(eventName, e)));
    });
  },

  _unregisterEvents(audio) {
    AUDIO_EVENTS.forEach(eventName => audio.removeEventListener(eventName));
  },

  _handleAudioEvent(eventName, e) {
    if (!this.urlsAreEqual(e.target.src, this.get('url')) && e.target.src !== '') {
      // This event is not for us if our srcs aren't equal

      // but if the target src is empty it means we've been stopped and in
      // that case should allow the event through.
      return;
    }

    this.debug(`Handling '${eventName}' event from audio element`);

    switch(eventName) {
      case 'loadeddata':
        var audio = this.audioElement();
        // Firefox doesn't fire a 'canplay' event until after you call *play* on
        // the audio, but it does fire 'loadeddata' when it's ready
        if (audio.readyState >= HAVE_CURRENT_DATA) {
          this._onAudioReady();
        }
        break;
      case 'canplay':
      case 'canplaythrough':
        this._onAudioReady();
        break;
      case 'error':
        this._onAudioError(e.target.error);
        break;
      case 'playing':
        this._onAudioPlayed();
        break;
      // the emptied event is triggered by our more reliable stream pause method
      case 'emptied':
        this._onAudioEmptied();
        break;
      case 'pause':
        this._onAudioPaused();
        break;
      case 'durationchange':
        this._onAudioDurationChanged();
        break;
      case 'ended':
        this._onAudioEnded();
        break;
      case 'progress':
        this._onAudioProgress(e);
        break;
    }
  },

  audioElement() {
    // If we have control, return the shared element
    // if we don't have control, return the internal cloned element

    let sharedAudioAccess  = this.get('sharedAudioAccess');

    if (sharedAudioAccess && sharedAudioAccess.hasControl(this)) {
      return sharedAudioAccess.get('audioElement');
    }
    else {
      let audioElement = (this.get('_audioElement') || document.createElement('audio'));
      this.set('_audioElement', audioElement);

      return audioElement;
    }
  },

  releaseControl() {
    if (!this.get('sharedAudioAccess')) {
      return;
    }

    if (this.get('isPlaying')) {
      // send a pause event so anyone subscribed to hifi's relayed events gets the message
      this._onAudioPaused(this);
    }

    this.get('sharedAudioAccess').releaseControl(this);

    // save current state of audio element to the internal element that won't be played
    this._saveState(this.get('sharedAudioAccess.audioElement'));
  },

  _saveState(audio) {
    this.debug('Saving audio state');
    let shadowAudio = document.createElement('audio');
    this.set('_audioElement', shadowAudio);
    shadowAudio.preload = 'none';
    shadowAudio.src = audio.src;

    try {
      shadowAudio.currentTime = audio.currentTime;
    }
    catch(e) {
      this.debug('Errored while trying to save audio current time');
      this.debug(e);
    }

    shadowAudio.volume = audio.volume;
    this.debug('Saved audio state');
  },

  requestControl() {
    if (this.get('sharedAudioAccess')) {
      return this.get('sharedAudioAccess').requestControl(this);
    } else {
      return this.audioElement();
    }
  },

  restoreState() {
    let sharedElement     = this.audioElement();
    let internalElement   = this.get('_audioElement');

    if (this.get('sharedAudioAccess') && internalElement) {
      this.debug('Restoring audio stateā€¦');
      try {
        // restore the state of the shared element from the dummy element
        if (internalElement.currentTime) {
          sharedElement.currentTime = internalElement.currentTime;
        }
        if (internalElement.volume) {
          sharedElement.volume      = internalElement.volume;
        }
        this.debug('Restored audio state');
      }
      catch(e) {
        this.debug('Errored while trying to restore audio state');
        this.debug(e);
      }
    }
  },

  _onAudioProgress() {
    this.trigger('audio-loading', this._calculatePercentLoaded());
  },

  _onAudioDurationChanged() {
    this.trigger('audio-duration-changed', this);
  },

  _onAudioPlayed() {
    if (!this.get('isPlaying')) {
      this.trigger('audio-played', this);
    }
  },

  _onAudioEnded() {
    this.trigger('audio-ended', this);
  },

  _onAudioError(error) {
    let message = "";
    switch (error.code) {
      case error.MEDIA_ERR_ABORTED:
        message = 'You aborted the audio playback.';
        break;
      case error.MEDIA_ERR_NETWORK:
        message = 'A network error caused the audio download to fail.';
        break;
      case error.MEDIA_ERR_DECODE:
        message = 'Decoder error.';
        break;
      case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
        message = 'Audio source format is not supported.';
        break;
      default:
        message = error.message;
        break;
    }

    this.debug(`audio element threw error ${message}`);
    this.trigger('audio-load-error', message);
  },

  _onAudioEmptied() {
    this.trigger('audio-paused', this);
  },

  _onAudioPaused() {
    this.trigger('audio-paused', this);
  },

  _onAudioReady() {
    this.trigger('audio-ready', this);
    this.trigger('audio-loaded', this);
  },

  _calculatePercentLoaded() {
    let audio = this.audioElement();

    if (audio && audio.buffered && audio.buffered.length) {
      let ranges = audio.buffered;
      let totals = [];
      for( var index = 0; index < ranges.length; index++ ) {
        totals.push(ranges.end(index) - ranges.start(index));
      }

      let total = A(totals).reduce((a, b) => (a + b), 0);

      this.debug(`ms loaded: ${total * 1000}`);
      this.debug(`duration: ${this._audioDuration()}`);
      this.debug(`percent loaded = ${(total / audio.duration) * 100}`);

      return {percentLoaded: (total / audio.duration)};
    }
    else {
      return 0;
    }
  },

  /* Public interface */

  _audioDuration() {
    let audio = this.audioElement();

    if (audio.duration > 172800000) {
      // if audio is longer than 3 days in milliseconds,
      // assume it's a stream, and set duration to infinity as it should be
      // this is a bug in Opera and was reported on 5/25/2017

      return Infinity;
    }

    return audio.duration * 1000;
  },

  _currentPosition() {
    let audio = this.audioElement();
    return audio.currentTime * 1000;
  },

  _setPosition(position) {
    let audio = this.audioElement();
    audio.currentTime = (position / 1000);
    return this._currentPosition();
  },

  _setVolume(volume) {
    let audio = this.audioElement();
    audio.volume = (volume/100);
  },

  play({position} = {}) {
    let audio = this.requestControl();

    // since we clear the `src` attr on pause, restore it here
    this.loadAudio(audio);
    this.restoreState();

    if (typeof position !== 'undefined') {
      this._setPosition(position);
    }

    this.debug('telling audio to play');
    return audio.play().catch(e => this._onAudioError(e));
  },

  pause() {
    let audio = this.audioElement();

    if (this.get('isStream')) {
      this.stop(); // we don't want to the stream to continue loading while paused
    }
    else {
      audio.pause();
    }
  },

  stop() {
    let audio = this.audioElement();
    audio.pause();

    // calling pause halts playback but does not stop downloading streaming
    // media. this is the method recommended by MDN: https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Using_HTML5_audio_and_video#Stopping_the_download_of_media
    // NOTE: this fires an `'emptied'` event, which we treat the same way as `'pause'`
    audio.removeAttribute('src');
    audio.load();
  },

  loadAudio(audio) {
    if (!this.urlsAreEqual(audio.src, this.get('url'))) {
      audio.setAttribute('src', this.get('url'));
    }
  },

  urlsAreEqual(url1, url2) {
    // GOTCHA: audio.src is a fully qualified URL, and this.get('url') may be a relative url
    // So when comparing, make sure we're dealing in absolutes

    let parser1 = document.createElement('a');
    let parser2 = document.createElement('a');
    parser1.href = url1;
    parser2.href = url2;

    return (parser1.href === parser2.href);
  },

  teardown() {
    let audio = this.requestControl();
    this.trigger('_will_destroy');
    this._unregisterEvents(audio);
  }
});

Sound.reopenClass(ClassMethods);

export default Sound;