addon/services/hifi.js
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);
}
});