services/sound-service.js

/**
 * AngularJS Service for playing sounds using Howler.js
 *
 * @module soundService
 * @see https://docs.angularjs.org/api/ng/type/angular.Module#service
 */
angular.module('tgaApp').service('soundService', [
  '$window',
  '$document',
  '$log',
  'localDataService',
  'utilityService',
  '$timeout',
  function soundService($window, $document, $log, localDataService, utilityService, $timeout) {
    /**
     * Stored list of sounds, with sound ID and Howler object.
     *
     * @property {string} sounds[].id - A sound ID.
     * @property {Object} sounds[].howl - A Howler sound object.
     */
    this.sounds = [];

    /**
     * Stored list of paused sound IDs.
     */
    this.pauseArray = [];

    /**
     * Find an object from {@link module:soundService.sounds}.
     *
     * @param {string} id - A sound ID.
     * @return {?Object} Sound object if found.
     */
    this.find = (id) => {
      for (let i = 0; i < this.sounds.length; i++) {
        if (this.sounds[i].id === id) {
          return this.sounds[i];
        }
      }
      return null;
    };

    /**
     * Find an object from {@link module:soundService.sounds} and execute a callback with it.
     *
     * @param {string} id - A sound ID.
     * @param {function} callback - A callback to run on the found audio object.
     * @return {?*} The return value of the callback, if sound is found.
     */
    this.findAndCall = (id, callback) => {
      const sound = this.find(id);
      if (sound) {
        return callback(sound);
      }
      return null;
    };

    /**
     * Play a sound randomly from a list.
     *
     * @param {string[]} - An array of sound IDs.
     * @param {Object} [settings] - Sound settings to use (see {@link module:soundService.play}).
     */
    this.playRandom = (idArr, settings) => {
      const id = idArr[Math.floor(Math.random() * idArr.length)];
      this.play(id, settings);
    };

    /**
     * Play a specific sound.
     *
     * @param {string} id - A sound ID.
     * @param {Object} [settings] - Sound settings to use.
     * @param {boolean} [settings.loop] - Whether to loop the sound.
     * @param {number} [settings.volume] - The sound volume (0.0-1.0).
     * @param {function} [settings.onComplete] - Callback to run on sound end.
     * @param {number} [settings.delay] - Delay before playing (milliseconds).
     */
    this.play = (id, settings) => {
      const s = settings ?? {};
      this.findAndCall(id, (sound) => {
        if (angular.isDefined(s.loop)) sound.howl.loop(s.loop);
        if (angular.isNumber(s.volume)) sound.howl.volume(s.volume);
        if (angular.isFunction(s.onComplete)) sound.howl.once('end', s.onComplete);
        if (angular.isNumber(s.delay)) {
          $timeout(() => {
            sound.howl.play();
          }, s.delay);
        } else {
          sound.howl.play();
        }
      });
    };

    /**
     * Fade a specific sound.
     *
     * @param {string} id - A sound ID.
     * @param {Object} [settings] - Sound settings to use.
     * @param {number} [settings.from] - Initial sound volume (0.0-1.0).
     * @param {number} [settings.to] - Final sound volume (0.0-1.0).
     * @param {number} [settings.duration] - Length of fade (milliseconds).
     * @param {function} [settings.onComplete] - Callback to run on fade end.
     */
    this.fade = (id, settings) => {
      const defaultSettings = {
        from: 0,
        to: 1,
        duration: 1000,
        onComplete: null,
      };
      const s = {
        ...defaultSettings,
        ...settings,
      };
      this.findAndCall(id, (sound) => {
        sound.howl.fade(s.from, s.to, s.duration);
        if (s.onComplete) {
          sound.howl.once('fade', s.onComplete);
        }
      });
    };

    /**
     * Check if a sound is playing.
     *
     * @param {string} id - A sound ID.
     * @return {?boolean}
     */
    this.isPlaying = (id) => this.findAndCall(id, (sound) => sound.howl.playing());

    /**
     * Stop all sounds.
     */
    this.stopAll = () => {
      for (let i = 0; i < this.sounds.length; i++) {
        this.sounds[i].howl.stop();
      }
    };

    /**
     * Stop a specific sound.
     *
     * @param {string} id - A sound ID.
     */
    this.stop = (id) => {
      if (this.isPlaying(id)) {
        this.findAndCall(id, (sound) => {
          sound.howl.stop();
        });
      }
    };

    /**
     * Pause a specific sound.
     *
     * @param {string} id - A sound ID.
     */
    this.pause = (id) => {
      this.findAndCall(id, (sound) => {
        if (sound.howl.playing()) {
          sound.howl.pause();
          this.pauseArray.push(sound.id);
        }
      });
    };

    /**
     * If given an id it will resume that sound if it is paused
     * else it will resume all paused sounds.
     *
     * @param {string} id - A sound ID.
     */
    this.resume = (id) => {
      if (id) {
        const idIndex = this.pauseArray.indexOf(id);
        if (idIndex !== -1) {
          this.findAndCall(this.pauseArray[idIndex], (sound) => {
            sound.howl.play();
          });
          this.pauseArray.splice(idIndex, 1);
        }
      } else {
        for (let i = 0; i < this.pauseArray.length; i++) {
          this.findAndCall(this.pauseArray[i], (sound) => {
            sound.howl.play();
          });
        }
        this.pauseArray = [];
      }
    };

    /**
     * Mute all sounds.
     *
     * @param {boolean} mute - Whether or not to mute.
     */
    this.muteAll = (mute) => {
      Howler.mute(mute);
    };

    /**
     * Mute all sounds in a list.
     *
     * @param {string[]} - An array of sound IDs.
     * @param {boolean} mute - Whether or not to mute.
     */
    this.muteMultiple = (arr, mute) => {
      for (let i = 0; i < arr.length; i++) {
        this.mute(arr[i], mute);
      }
    };

    /**
     * Mute a specific sound.
     *
     * @param {string} id - A sound ID.
     * @param {boolean} mute - Whether or not to mute.
     */
    this.mute = (id, mute) => {
      this.findAndCall(id, (sound) => {
        sound.howl.mute(mute);
      });
    };

    /**
     * Load a sound.
     *
     * @param {Object} options
     * @param {string} options.id - The sound ID.
     * @param {string} options.src - The URL to the sound.
     * @param {boolean} [options.autoplay=false] - Whether to autoplay the sound after load.
     * @param {number} [options.volume=1.0] - The sound volume (0.0-1.0).
     * @param {boolean} [options.loop=false] - Whether to loop the sound.
     * @param {boolean} [options.html5=false] - Whether to force HTML5 audio.
     * @param {boolean} [options.preload=true] - Whether to preload the sound.
     * @param {function} [options.onLoad] - Callback to run when sound is loaded.
     */
    this.load = (options) => {
      const sound = new Howl({
        src: options.src,
        autoplay: options.autoplay ? options.autoplay : false,
        volume: options.volume ? options.volume : 1.0,
        loop: options.loop ? options.loop : false,
        html5: options.html5 ? options.html5 : false,
        preload: options.preload ? options.preload : true,
      });
      if (options.onLoad) {
        sound.once('load', options.onLoad);
      }

      this.sounds.push({
        id: options.id,
        howl: sound,
      });
    };

    /**
     * Load multiple sounds from a list.
     *
     * @param {Object[]} - An array of objects to pass to {@link module:soundService.load}.
     */
    this.loadMultiple = (data) => {
      for (let i = 0; i < data.length; i++) {
        this.load(data[i]);
      }
    };

    /**
     * Unload a sound.
     *
     * @param {string} id - A sound ID.
     */
    this.unload = (id) => {
      this.findAndCall(id, (sound) => {
        sound.howl.unload();
        const i = this.sounds.findIndex((s) => s.id === sound.id);
        // eslint-disable-next-line no-param-reassign
        sound.id = null;
        this.sounds.splice(i, 1);
        this.pauseArray.splice(this.pauseArray.indexOf(id), 1);
      });
    };

    /**
     * Unload all sounds.
     */
    this.unloadAll = () => {
      // eslint-disable-next-line no-undef
      Howler.unload();
      this.sounds = [];
    };

    /**
     * Unload all sounds and clear pause data.
     */
    this.reset = () => {
      this.unloadAll();
      this.pauseArray = [];
    };

    /**
     * Set the volume for a specific sound.
     *
     * @param {string} id - A sound ID.
     * @param {number} volume - The sound volume (0.0-1.0).
     */
    this.setVolume = (id, volume) => {
      this.findAndCall(id, (sound) => {
        sound.howl.volume(volume);
      });
    };

    /**
     * Get the volume for a specific sound.
     *
     * @param {string} id - A sound ID.
     * @return {?number} The sound volume (0.0-1.0).
     */
    this.getVolume = (id) => this.findAndCall(id, (sound) => sound.howl.volume());

    /**
     * Set the rate for a specific sound, used for dynamic pitch shifting.
     *
     * @param {string} id - A sound ID.
     * @param {number} rate - The sound rate (0.5-4.0).
     */
    this.setRate = (id, rate) => {
      this.findAndCall(id, (sound) => {
        sound.howl.rate(rate);
      });
    };

    /**
     * Check load progress of all sounds.
     *
     * @return {number} The percentage of sounds that are done loading (0.0-1.0).
     */
    this.checkLoadCompletion = () => {
      if (this.sounds.length < 1) return -1;
      let loaded = 0;
      for (let i = 0; i < this.sounds.length; i++) {
        if (this.sounds[i].howl.state() === 'loaded') {
          loaded++;
        }
      }
      return loaded / this.sounds.length;
    };

    this.visibilityChange = () => {
      // Source 1: https://github.com/goldfire/howler.js/issues/1160
      // eslint-disable-next-line max-len
      // Source 2  https://stereologics.wordpress.com/2015/04/02/about-page-visibility-api-hidden-visibilitychange-visibilitystate/

      let hidden = 'hidden';
      const soundServ = this;
      function onchange(evt) {
        const v = false;
        const h = true;
        const evtMap = {
          focus: v,
          focusin: v,
          pageshow: v,
          blur: h,
          focusout: v,
          pagehide: h,
        };

        const changeEvent = evt || $window.event;

        let windowHidden = false;
        if (changeEvent.type in evtMap) {
          windowHidden = evtMap[changeEvent.type];
        } else {
          windowHidden = this[hidden];
        }

        if (soundServ) {
          if (windowHidden) {
            soundServ.muteAll(true);
            $log.log('MUTE');
          } else {
            // If the audio is set to mute by the user already do not bring it back
            const isAudio = localDataService.getCookieData('audio');
            soundServ.muteAll(false || !isAudio);
            $log.log('UN-MUTE', isAudio);
          }
        }
      }

      const doc = $document[0];
      // Standards:
      if (hidden in doc) {
        doc.addEventListener('visibilitychange', onchange, true);
      } else if ('mozHidden' in doc) {
        hidden = 'mozHidden';
        doc.addEventListener('mozvisibilitychange', onchange);
      } else if ('webkitHidden' in doc) {
        hidden = 'webkitHidden';
        doc.addEventListener('webkitvisibilitychange', onchange);
      } else if ('msHidden' in doc) {
        hidden = 'msHidden';
        doc.addEventListener('msvisibilitychange', onchange);
      } else if ('onfocusin' in doc) {
        // IE 9 and lower:
        // eslint-disable-next-line no-param-reassign, no-multi-assign
        doc.onfocusin = doc.onfocusout = onchange;
      } else {
        // All others:
        // eslint-disable-next-line no-param-reassign, no-multi-assign
        $window.onpageshow = $window.onpagehide = $window.onfocus = $window.onblur = onchange;
      }

      // extra event listeners for better behaviour
      doc.addEventListener('focus', () => {
        onchange({ type: 'focus' });
      }, false);

      doc.addEventListener('blur', () => {
        onchange({ type: 'blur' });
      }, false);

      $window.addEventListener('focus', () => {
        onchange({ type: 'focus' });
      }, false);

      $window.addEventListener('blur', () => {
        onchange({ type: 'blur' });
      }, false);

      // set the initial state (but only if browser supports the Page Visibility API)
      if (angular.isDefined(doc[hidden])) {
        onchange({
          type: doc[hidden] ? 'blur' : 'focus',
        });
      }
    };

    if (utilityService.isMobile()) {
      this.visibilityChange();
    }
  },
]);