nypublicradio/ember-hifi

View on GitHub
addon/services/hifi.js

Summary

Maintainability
D
2 days
Test Coverage
B
88%
import { or, readOnly, equal, reads, alias } from '@ember/object/computed';
import { later, cancel } from '@ember/runloop';
import { isEmpty } from '@ember/utils';
import { assign } from '@ember/polyfills';
import { getOwner } from '@ember/application';
import Evented from '@ember/object/evented';
import Service, { inject as service } from '@ember/service';
import { assert } from '@ember/debug';
import { bind } from "@ember/runloop";

import {
  set,
  get,
  getProperties,
  getWithDefault,
  computed
} from '@ember/object';
import { A as emberArray, makeArray } from '@ember/array';
import { dasherize } from '@ember/string';
import OneAtATime from '../helpers/one-at-a-time';
import RSVP from 'rsvp';
import PromiseRace from '../utils/promise-race';
import SharedAudioAccess from '../utils/shared-audio-access';
import DebugLogging from '../mixins/debug-logging';

export const EVENT_MAP = [
  {event: 'audio-played',               handler: '_relayPlayedEvent'},
  {event: 'audio-paused',               handler: '_relayPausedEvent'},
  {event: 'audio-ended',                handler: '_relayEndedEvent'},
  {event: 'audio-duration-changed',     handler: '_relayDurationChangedEvent'},
  {event: 'audio-position-changed',     handler: '_relayPositionChangedEvent'},
  {event: 'audio-loaded',               handler: '_relayLoadedEvent'},
  {event: 'audio-loading',              handler: '_relayLoadingEvent'},
  {event: 'audio-position-will-change', handler: '_relayPositionWillChangeEvent'},
  {event: 'audio-will-rewind',          handler: '_relayWillRewindEvent'},
  {event: 'audio-will-fast-forward',    handler: '_relayWillFastForwardEvent'},
  {event: 'audio-metadata-changed',     handler: '_relayMetadataChangedEvent'}
]

export const SERVICE_EVENT_MAP = [
  {event: 'current-sound-changed' },
  {event: 'current-sound-interrupted' },
  {event: 'new-load-request' },
  {event: 'pre-load' }
]


/**
* This is the hifi service class.
*
* @class hifi
* @constructor
*/

export default Service.extend(Evented, DebugLogging, {
  debugName: 'ember-hifi',
  poll:              service(),
  soundCache:        service('hifi-cache'),
  isMobileDevice:    computed({
    get() {
      return ('ontouchstart' in window);
    },
    set(k, v) { return v; }
  }),

  useSharedAudioAccess: or('isMobileDevice', 'alwaysUseSingleAudioElement'),

  currentSound:      null,
  currentMetadata:   computed('currentSound.metadata', {
    get() {
      return this.get('currentSound.metadata');
    },
    set: (k, v) => v
  }),
  isPlaying:         readOnly('currentSound.isPlaying'),
  isLoading:         computed('currentSound.isLoading', {
    get() {
      return this.get('currentSound.isLoading');
    },
    set(k, v) { return v; }
  }),

  isStream:          readOnly('currentSound.isStream'),
  isFastForwardable: readOnly('currentSound.isFastForwardable'),
  isRewindable:      readOnly('currentSound.isRewindable'),
  isMuted:           equal('volume', 0),
  duration:          readOnly('currentSound.duration'),
  percentLoaded:     readOnly('currentSound.percentLoaded'),
  pollInterval:      reads('options.emberHifi.positionInterval'),
  id3TagMetadata:    reads('currentSound.id3TagMetadata'),

  defaultVolume: 100,

  position:          alias('currentSound.position'),

  volume: computed({
    get() {
      return this.get('currentSound.volume') || this.get('defaultVolume');
    },
    set(k, v) {
      if (this.get('currentSound')) {
        this.get('currentSound')._setVolume(v);
      }

      if (v > 0) {
        this.set('unmuteVolume', v);
      }

      return v;
    }
  }),

  /**
   * When the Service is created, activate connections that were specified in the
   * configuration. This config is injected into the Service as `options`.
   *
   * @method init
   */

  init() {
    const connections = getWithDefault(this, 'options.emberHifi.connections', emberArray());
    const owner = getOwner(this);

    owner.registerOptionsForType('ember-hifi@hifi-connection', { instantiate: false });
    owner.registerOptionsForType('hifi-connection', { instantiate: false });

    set(this, 'alwaysUseSingleAudioElement',  getWithDefault(this, 'options.emberHifi.alwaysUseSingleAudioElement', false));
    set(this, 'appEnvironment', getWithDefault(this, 'options.environment', 'development'));
    set(this, '_connections', {});
    set(this, 'oneAtATime', OneAtATime.create());
    set(this, 'volume', 100);
    this._activateConnections(connections);

    this.set('isReady', true);

    // Polls the current sound for position. We wanted to make it easy/flexible
    // for connection authors, and since we only play one sound at a time, we don't
    // need other non-active sounds telling us position info
     this.get('poll').addPoll({
       interval: get(this, 'pollInterval') || 500,
       callback: bind(this, this._setCurrentPosition)
     });

    this._super(...arguments);
  },

  /**
   * Returns the list of activated and available connections
   *
   * @method availableConnections
   */

  availableConnections() {
    return Object.keys(this.get('_connections'));
  },

  /**
   * Given an array of URLS, return a sound ready for playing
   *
   * @method load
   * @async
   * @param urlsOrPromise [..{Promise|String}]
   * Provide an array of urls to try, or a promise that will resolve to an array of urls
   * @return {Sound} A sound that's ready to be played, or an error
   */

  load(urlsOrPromise, options) {
    let sharedAudioAccess = this._createAndUnlockAudio();

    options = assign({
      debugName: `load-${Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 3)}`,
      metadata: {},
    }, options);

    let promise = new RSVP.Promise((resolve, reject) => {
      return this._resolveUrls(urlsOrPromise).then(urlsToTry => {
        if (isEmpty(urlsToTry)) {
          return reject(new Error('[ember-hifi] URLs must be provided'));
        }

        this.trigger('pre-load', urlsToTry);
        let sound = this.get('soundCache').find(urlsToTry);
        if (sound) {
          this.debug('ember-hifi', 'retreived sound from cache');
          return resolve({sound});
        }
        else {
          let strategies = [];
          if (options.useConnections) {
            // If the consumer has specified a connection to prefer, use it
            let connectionNames  = options.useConnections;
            strategies = this._prepareStrategies(urlsToTry, connectionNames);
          }
          else if (this.get('isMobileDevice')) {
            // If we're on a mobile device, we want to try NativeAudio first
            strategies  = this._prepareMobileStrategies(urlsToTry);
          }
          else {
            strategies  = this._prepareStandardStrategies(urlsToTry);
          }

          if (this.get('useSharedAudioAccess')) {
            // If we're on a mobile device or have specified to always use a single audio element,
            // pass in sharedAudioAccess into each connection.

            // Using a single audio element combats autoplay blocking issues on touch devices, and resolves
            // some issues when using a cookied content provider (adswizz)

            strategies  = strategies.map(s => {
              s.sharedAudioAccess = sharedAudioAccess;
              return s;
            });
          }

          let search = this._findFirstPlayableSound(strategies, options);
          search.then(results  => resolve({sound: results.success, failures: results.failures}));
          search.catch(e => {
            // reset the UI since trying to play that sound failed
            this.set('isLoading', false);
            let err = new Error(`[ember-hifi] URL Promise failed because: ${e.message}`);
            err.failures = e.failures;
            reject(err);
          });

          return search;
        }
      });
    });

    this.trigger('new-load-request', {loadPromise:promise, urlsOrPromise, options});

    promise.then(({sound}) => sound.set('metadata', options.metadata));
    promise.then(({sound}) => this.get('soundCache').cache(sound));

    // On audio-played this pauses all the other sounds. One at a time!
    promise.then(({sound}) => this.get('oneAtATime').register(sound));

    promise.then(({sound}) => sound.on('audio-played', () => {
      let previousSound = this.get('currentSound');
      let currentSound  = sound;

      if (previousSound !== currentSound) {
        if (previousSound && get(previousSound, 'isPlaying')) {
          this.trigger('current-sound-interrupted', previousSound);
        }

        this.trigger('current-sound-changed', currentSound, previousSound);
        this.setCurrentSound(sound);
      }
    }));

    return promise;
  },

  /**
   * Given an array of URLs, return a sound and play it.
   *
   * @method play
   * @async
   * @param urlsOrPromise [..{Promise|String}]
   * Provide an array of urls to try, or a promise that will resolve to an array of urls
   * @return {Sound} A sound that's playing, or an error
   */

  play(urlsOrPromise, options = {}) {
    if (this.get('isPlaying')) {
      this.trigger('current-sound-interrupted', get(this, 'currentSound'));
      this.pause();
    }
    // update the UI immediately while `.load` figures out which sound is playable
    this.set('isLoading', true);
    this.set('currentMetadata', options.metadata);

    let load = this.load(urlsOrPromise, options);

    // We want to keep this chainable elsewhere
    return new RSVP.Promise((resolve, reject) => {
      load.then(({sound, failures}) => {
        this.debug("ember-hifi", "Finished load, trying to play sound");
        sound.one('audio-played', () => resolve({sound, failures}));

        this._registerEvents(sound);
        this._attemptToPlaySound(sound, options);
      });
      load.catch(reject);
    });
  },

  /**
   * Pauses the current sound
   *
   * @method pause
   */

  pause() {
    assert('[ember-hifi] Nothing is playing.', this.get('currentSound'));
    this.get('currentSound').pause();
  },

  /**
   * Stops the current sound
   *
   * @method stop
   */

  stop() {
    assert('[ember-hifi] Nothing is playing.', this.get('currentSound'));
    this.get('currentSound').stop();
  },

  /**
   * Toggles play/pause state of the current sound
   *
   * @method togglePause
   */

  togglePause() {
    assert('[ember-hifi] Nothing is playing.', this.get('currentSound'));

    if (this.get('isPlaying')) {
      this.get('currentSound').pause();
    }
    else {
      this.set('isLoading', true);
      this.get('currentSound').play();
    }
  },

  /**
   * Toggles mute state. Sets volume to zero on mute, resets volume to the last level it was before mute, unless
   * unless the last level was zero, in which case it sets it to the default volume
   *
   * @method toggleMute
   */

  toggleMute() {
    if (this.get('isMuted')) {
      this.set('volume', this.get('unmuteVolume'));
    }
    else {
      this.set('volume', 0);
    }
  },

  /**
   * Fast forwards current sound if able
   *
   * @method fastForward
   * @param {Integer} duration in ms
   */

  fastForward(duration) {
    assert('[ember-hifi] Nothing is playing.', this.get('currentSound'));
    this.get('currentSound').fastForward(duration);
  },

  /**
   * Rewinds current sound if able
   *
   * @method rewind
   * @param {Integer} duration in ms
   */

  rewind(duration) {
    assert('[ember-hifi] Nothing is playing.', this.get('currentSound'));
    this.get('currentSound').rewind(duration);
  },

  /**
   * Set the current sound and wire up all the events the sound fires so they
   * trigger through the service, remove the ones on the previous current sound,
   * and set the new current sound to the system volume
   *
   * @method setCurrentSound
   * @param {Sound} sound
   */

  setCurrentSound(sound) {
    if (this.get('isDestroyed') || this.get('isDestroying')) {
      return; // should use ember-concurrency to cancel any pending promises in willDestroy
    }
    this._unregisterEvents(this.get('currentSound'));
    this._registerEvents(sound);
    sound._setVolume(this.get('volume'));
    this.set('currentSound', sound);
    this.debug('ember-hifi', `setting current sound -> ${sound.get('url')}`);
  },

/* ------------------------ PRIVATE(ISH) METHODS ---------------------------- */
/* -------------------------------------------------------------------------- */
/* -------------------------------------------------------------------------- */

  /**
   * Sets the current sound with its current position, so the sound doesn't have
   * to deal with timers. The service runs the show.
   *
   * @method _setCurrentSoundForPosition
   * @private
   */

  _setCurrentPosition() {
    let sound = this.get('currentSound');
    if (sound) {
      try {
        set(sound, '_position', sound._currentPosition());
      }
      catch(e) {
        // continue regardless of error
        // TODO: why is this wrapped in a try catch?
      }
    }
  },

  /**
   * Register events on a current sound. Audio events triggered on that sound
   * will be relayed and triggered on this service
   *
   * @method _registerEvents
   * @param {Object} sound
   * @private
   */

  _registerEvents(sound) {
    let service = this;
    EVENT_MAP.forEach(item => {
      sound.on(item.event, service, service[item.handler]);
    });

    // Internal event for cleanup
    sound.on('_will_destroy', () => {
      this._unregisterEvents(sound);
    })
  },

  /**
   * Register events on a current sound. Audio events triggered on that sound
   * will be relayed and triggered on this service
   *
   * @method _unregisterEvents
   * @param {Object} sound
   * @private
   */

  _unregisterEvents(sound) {
    if (!sound) {
      return;
    }

    let service = this;
    EVENT_MAP.forEach(item => {
      if (sound.has(item.event)) {
        sound.off(item.event, service, service[item.handler]);
      }
    });
  },

  /**
   * Relays an audio event on the sound to an event on the service
   *
   * @method relayEvent
   * @param {String, Object} eventName, sound
   * @private
   */

  _relayEvent(eventName, sound, info = {}) {
    this.trigger(eventName, sound, info);
  },

  /**
    Named functions so Ember Evented can successfully register/unregister them
  */

  _relayPlayedEvent(sound) {
    this._relayEvent('audio-played', sound);
  },
  _relayPausedEvent(sound) {
    this._relayEvent('audio-paused', sound);
  },
  _relayEndedEvent(sound) {
    this._relayEvent('audio-ended', sound);
  },
  _relayDurationChangedEvent(sound) {
    this._relayEvent('audio-duration-changed', sound);
  },
  _relayPositionChangedEvent(sound) {
    this._relayEvent('audio-position-changed', sound);
  },
  _relayLoadedEvent(sound) {
    this._relayEvent('audio-loaded', sound);
  },
  _relayLoadingEvent(sound) {
    this._relayEvent('audio-loading', sound);
  },
  _relayPositionWillChangeEvent(sound,  info = {}) {
    this._relayEvent('audio-position-will-change', sound, info);
  },
  _relayWillRewindEvent(sound,  info) {
    this._relayEvent('audio-will-rewind', sound, info);
  },
  _relayWillFastForwardEvent(sound, info) {
    this._relayEvent('audio-will-fast-forward', sound, info);
  },
  _relayMetadataChangedEvent(sound, info) {
    this._relayEvent('audio-metadata-changed', sound, info);
  },
  /**
   * Activates the connections as specified in the config options
   *
   * @method _activateConnections
   * @private
   * @param {Array} connectionOptions
   * @return {Object} instantiated connections
   */

  _activateConnections(options = []) {
    const cachedConnections = get(this, '_connections');
    const activatedConnections = {};

    options.forEach((connectionOption) => {
      const { name } = connectionOption;
      const connection = cachedConnections[name] ? cachedConnections[name] : this._activateConnection(connectionOption);

      set(activatedConnections, name, connection);
    });

    return set(this, '_connections', activatedConnections);
  },

 /**
  * Activates the a single connection
  *
  * @method _activateConnection
  * @private
  * @param {Object} {name, config}
  * @return {Connection} instantiated Connection
  */

  _activateConnection({ name, config } = {}) {
    const Connection = this._lookupConnection(name);
    assert('[ember-hifi] Could not find hifi connection ${name}.', name);
    Connection.setup(config);
    return Connection;
  },

  /**
   * Looks up the connection from the container. Prioritizes the consuming app's
   * connections over the addon's connections.
   *
   * @method _lookupConnection
   * @param {string} connectionName
   * @private
   * @return {Connection} a local connection or a connection from the addon
   */

  _lookupConnection(connectionName) {
    assert('[ember-hifi] Could not find a hifi connection without a name.', connectionName);

    const dasherizedConnectionName = dasherize(connectionName);
    const availableConnection      = getOwner(this).lookup(`ember-hifi@hifi-connection:${dasherizedConnectionName}`);
    const localConnection          = getOwner(this).lookup(`hifi-connection:${dasherizedConnectionName}`);

    assert(`[ember-hifi] Could not load hifi connection ${dasherizedConnectionName}`, (localConnection || availableConnection));

    return localConnection ? localConnection : availableConnection;
  },

  /**
   * URLs given to load or play may be a promise, resolve this promise and get the urls
   * or promisify an array/string and
   * @method _resolveUrls
   * @param {Array or String or Promise} urlOrPromise
   * @private
   * @return {Promise.<urls>} a promise resolving to a cleaned up array of URLS
   */

  _resolveUrls(urlsOrPromise) {
    let prepare = (urls) => {
      return emberArray(makeArray(urls)).uniq().reject(i => isEmpty(i));
    };

    if (urlsOrPromise && urlsOrPromise.then) {
      this.debug('ember-hifi', "#load passed URL promise");
    }

    return RSVP.Promise.resolve(urlsOrPromise).then(urls => {
      urls = prepare(urls);
      this.debug('ember-hifi', `given urls: ${urls.join(', ')}`);
      return urls;
    });
  },

  /**
   * Given an array of strategies with {connection, url} try the connection and url
   * return the first thing that works
   *
   * @method _findFirstPlayableSound
   * @param {Array} urlsToTry
   * @private
   * @return {Promise.<Sound|error>} A sound that's ready to be played, or an error with a failures property
   */

  _findFirstPlayableSound(strategies, options) {
    this.timeStart(options.debugName, "_findFirstPlayableSound");

    let promise = PromiseRace.start(strategies, (strategy, returnSuccess, markAsFailure) => {
      let Connection         = strategy.connection;

      let connectionOptions  = getProperties(strategy, 'url', 'connectionName', 'sharedAudioAccess', 'options');
      let sound              = Connection.create(connectionOptions);
      this.debug('ember-hifi', `TRYING: [${strategy.connectionName}] -> ${strategy.url}`);

      sound.one('audio-load-error', (error) => {
        strategy.error = error;
        markAsFailure(strategy);
        this.debug('ember-hifi', `FAILED: [${strategy.connectionName}] -> ${error} (${strategy.url})`);
      });

      sound.one('audio-ready',      () => {
        returnSuccess(sound);
        this.debug('ember-hifi', `SUCCESS: [${strategy.connectionName}] -> (${strategy.url})`);
      });
    });
    promise.catch(({failures}) => {
      this.debug('ember-hifi', `All promises failed:`);
      failures.forEach(f => {
        this.debug('ember-hifi', `${f.connectionName}: ${f.error}`);
      });
    });

    promise.finally(() => this.timeEnd(options.debugName, "_findFirstPlayableSound"));

    return promise;
  },

  /**
   * Given some urls, it prepares an array of connection and url pairs to try
   *
   * @method _prepareParamsForLoadWorkingAudio
   * @param {Array} urlsToTry
   * @private
   * @return {Array} {connection, url}
   */

  /**
   * Take our standard strategy and reorder it to prioritize native audio
   * first since it's most likely to succeed and play immediately with our
   * audio unlock logic

   * we try each url on each compatible connection in order
   * [{connection: NativeAudio, url: url1},
   *  {connection: NativeAudio, url: url2},
   *  {connection: HLS, url: url1},
   *  {connection: Other, url: url1},
   *  {connection: HLS, url: url2},
   *  {connection: Other, url: url2}]

   * @method _prepareMobileStrategies
   * @param {Array} urlsToTry
   * @private
   * @return {Array} {connection, url}
   */

  _prepareMobileStrategies(urlsToTry) {
    let strategies = this._prepareStandardStrategies(urlsToTry);
    this.debug("modifying standard strategy for to work best on mobile");

    let nativeStrategies  = emberArray(strategies).filter(s => (s.connectionKey === 'NativeAudio'));
    let otherStrategies   = emberArray(strategies).reject(s => (s.connectionKey === 'NativeAudio'));
    let orderedStrategies = nativeStrategies.concat(otherStrategies);

    return orderedStrategies;
  },

  /**
   * Given a list of urls, prepare the strategy that we think will succeed best
   *
   * Breadth first: we try each url on each compatible connection in order
   * [{connection: NativeAudio, url: url1},
   *  {connection: HLS, url: url1},
   *  {connection: Other, url: url1},
   *  {connection: NativeAudio, url: url2},
   *  {connection: HLS, url: url2},
   *  {connection: Other, url: url2}]

   * @method _prepareStandardStrategies
   * @param {Array} urlsToTry
   * @private
   * @return {Array} {connection, url}
   */

  _prepareStandardStrategies(urlsToTry, options) {
    return this._prepareStrategies(urlsToTry, this.availableConnections(), options);
  },

  /**
   * Given a list of urls and a list of connections, assemble array of
   * strategy objects to be tried in order. Each strategy object
   * should contain a connection, a connectionName, a url, and in some cases
   * a sharedAudioAccess

   * @method _prepareStrategies
   * @param {Array} urlsToTry
   * @private
   * @return {Array} {connection, url}
   */

  _prepareStrategies(urlsToTry, connectionNames) {
    connectionNames = makeArray(connectionNames);
    let strategies = [];
    let connectionOptions = this.get('options.emberHifi.connections') || [];
    connectionOptions = emberArray(connectionOptions);

    urlsToTry.forEach(url => {
      let connectionSuccesses = [];
      connectionNames.forEach(name => {
        let connection = this.get(`_connections.${name}`);
        let config = connectionOptions.findBy('name', name);
        if (connection.canPlay(url)) {
          connectionSuccesses.push(name);
          strategies.push({
            connectionName:  connection.toString(),
            connectionKey:   name,
            connection:      connection,
            url:             url.url || url,
            options:         config ? config.options : null
          });
        }
      });
      this.debug(`Compatible connections for ${url}: ${connectionSuccesses.join(", ")}`);
    });
    return strategies;
  },

  /**
   * Creates an empty audio element and plays it to unlock audio on a mobile (iOS)
   * device at the beggining of a play event.
   *
   * @method _createAndUnlockAudio
   * @private
   * @return {element} an audio element
   */

   _createAndUnlockAudio() {
     // Audio will play automatically if is Mobile device to get around
     // autoplaying restrictions. If not, it won't autoplay because
     // IE desktop browsers can't deal with that and will suddenly
     // play the loading audio before it's ready

     return SharedAudioAccess.unlock(this.get('isMobileDevice'));
  },

  /**
   * Attempts to play the sound after a load, which in certain cases can fail on mobile
   * @method _attemptToPlaySoundOnMobile
   * @param {Sound} sound
   * @private
   */

  _attemptToPlaySound(sound, options) {
    if (this.get('isMobileDevice')) {
      let touchPlay = ()=> {
        this.debug(`triggering sound play from document touch`);
        sound.play();
      };

      document.addEventListener('touchstart', touchPlay, { passive: true });

      let blockCheck = later(() => {
        this.debug(`Looks like the mobile browser blocked an autoplay trying to play sound with url: ${sound.get('url')}`);
      }, 2000);

      sound.one('audio-played', () => {
        document.removeEventListener('touchstart', touchPlay);
        cancel(blockCheck);
      });
    }
    sound.play(options);
  }
});