diff --git a/.gitignore b/.gitignore
index 21adc7af..9229541f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,5 @@ coverage.xml
dist/
docs/_build/
mopidy.log*
+node_modules/
nosetests.xml
diff --git a/js/README.rst b/js/README.rst
new file mode 100644
index 00000000..a68dd9a0
--- /dev/null
+++ b/js/README.rst
@@ -0,0 +1,75 @@
+*********
+Mopidy.js
+*********
+
+This is the source for the JavaScript library that is installed as a part of
+Mopidy's HTTP frontend. The library makes Mopidy's core API available from the
+browser, using JSON-RPC messages over a WebSocket to communicate with Mopidy.
+
+
+Getting it
+==========
+
+Regular and minified versions of Mopidy.js, ready for use, is installed
+together with Mopidy. When the HTTP frontend is running, the files are
+available at:
+
+- http://localhost:6680/mopidy/mopidy.js
+- http://localhost:6680/mopidy/mopidy.min.js
+
+You may need to adjust hostname and port for your local setup.
+
+In the source repo, you can find the files at:
+
+- ``mopidy/frontends/http/data/mopidy.js``
+- ``mopidy/frontends/http/data/mopidy.min.js``
+
+
+Building from source
+====================
+
+1. Install `Node.js `_ and npm. There is a PPA if you're
+ running Ubuntu::
+
+ sudo apt-get install python-software-properties
+ sudo add-apt-repository ppa:chris-lea/node.js
+ sudo apt-get update
+ sudo apt-get install nodejs npm
+
+2. Assuming you install from PPA, setup your ``NODE_PATH`` environment variable
+ to include ``/usr/lib/node_modules``. Add the following to your
+ ``~/.bashrc`` or equivalent::
+
+ export NODE_PATH=/usr/lib/node_modules:$NODE_PATH
+
+3. Install `Buster.js `_ and `Grunt
+ `_ globally (or locally, and make sure you get their
+ binaries on your ``PATH``)::
+
+ sudo npm -g install buster grunt
+
+4. Install the grunt-buster Grunt plugin locally, when in the ``js/`` dir::
+
+ cd js/
+ npm install grunt-buster
+
+5. Install `PhantomJS `_ so that we can run the tests
+ without a browser::
+
+ sudo apt-get install phantomjs
+
+ It is packaged in Ubuntu since 12.04, but I haven't tested with versions
+ older than 1.6 which is the one packaged in Ubuntu 12.10.
+
+6. Run Grunt to lint, test, concatenate, and minify the source::
+
+ grunt
+
+ The files in ``../mopidy/frontends/http/data/`` should now be up to date.
+
+
+Development tips
+================
+
+If you're coding on the JavaScript library, you should know about ``grunt
+watch``. It lints and tests the code every time you save a file.
diff --git a/js/buster.js b/js/buster.js
new file mode 100644
index 00000000..f789885a
--- /dev/null
+++ b/js/buster.js
@@ -0,0 +1,9 @@
+var config = module.exports;
+
+config["tests"] = {
+ environment: "browser",
+ libs: ["lib/**/*.js"],
+ sources: ["src/**/*.js"],
+ testHelpers: ["test/**/*-helper.js"],
+ tests: ["test/**/*-test.js"]
+};
diff --git a/js/grunt.js b/js/grunt.js
new file mode 100644
index 00000000..7be4d882
--- /dev/null
+++ b/js/grunt.js
@@ -0,0 +1,65 @@
+/*global module:false*/
+module.exports = function (grunt) {
+
+ grunt.initConfig({
+ meta: {
+ banner: "/*! Mopidy.js - built " +
+ "<%= grunt.template.today('yyyy-mm-dd') %>\n" +
+ " * http://www.mopidy.com/\n" +
+ " * Copyright (c) <%= grunt.template.today('yyyy') %> " +
+ "Stein Magnus Jodal and contributors\n" +
+ " * Licensed under the Apache License, Version 2.0 */"
+ },
+ dirs: {
+ dest: "../mopidy/frontends/http/data"
+ },
+ lint: {
+ files: ["grunt.js", "src/**/*.js", "test/**/*-test.js"]
+ },
+ buster: {
+ test: {
+ config: "buster.js"
+ }
+ },
+ concat: {
+ dist: {
+ src: ["", "lib/**/*.js", "src/mopidy.js"],
+ dest: "<%= dirs.dest %>/mopidy.js"
+ }
+ },
+ min: {
+ dist: {
+ src: ["", ""],
+ dest: "<%= dirs.dest %>/mopidy.min.js"
+ }
+ },
+ watch: {
+ files: "",
+ tasks: "lint buster concat min"
+ },
+ jshint: {
+ options: {
+ curly: true,
+ eqeqeq: true,
+ immed: true,
+ indent: 4,
+ latedef: true,
+ newcap: true,
+ noarg: true,
+ sub: true,
+ quotmark: "double",
+ undef: true,
+ unused: true,
+ eqnull: true,
+ browser: true,
+ devel: true
+ },
+ globals: {}
+ },
+ uglify: {}
+ });
+
+ grunt.registerTask("default", "lint buster concat min");
+
+ grunt.loadNpmTasks("grunt-buster");
+};
diff --git a/js/lib/bane-0.4.0.js b/js/lib/bane-0.4.0.js
new file mode 100644
index 00000000..a1da6efa
--- /dev/null
+++ b/js/lib/bane-0.4.0.js
@@ -0,0 +1,171 @@
+/**
+ * BANE - Browser globals, AMD and Node Events
+ *
+ * https://github.com/busterjs/bane
+ *
+ * @version 0.4.0
+ */
+
+((typeof define === "function" && define.amd && function (m) { define(m); }) ||
+ (typeof module === "object" && function (m) { module.exports = m(); }) ||
+ function (m) { this.bane = m(); }
+)(function () {
+ "use strict";
+ var slice = Array.prototype.slice;
+
+ function handleError(event, error, errbacks) {
+ var i, l = errbacks.length;
+ if (l > 0) {
+ for (i = 0; i < l; ++i) { errbacks[i](event, error); }
+ return;
+ }
+ setTimeout(function () {
+ error.message = event + " listener threw error: " + error.message;
+ throw error;
+ }, 0);
+ }
+
+ function assertFunction(fn) {
+ if (typeof fn !== "function") {
+ throw new TypeError("Listener is not function");
+ }
+ return fn;
+ }
+
+ function supervisors(object) {
+ if (!object.supervisors) { object.supervisors = []; }
+ return object.supervisors;
+ }
+
+ function listeners(object, event) {
+ if (!object.listeners) { object.listeners = {}; }
+ if (event && !object.listeners[event]) { object.listeners[event] = []; }
+ return event ? object.listeners[event] : object.listeners;
+ }
+
+ function errbacks(object) {
+ if (!object.errbacks) { object.errbacks = []; }
+ return object.errbacks;
+ }
+
+ /**
+ * @signature var emitter = bane.createEmitter([object]);
+ *
+ * Create a new event emitter. If an object is passed, it will be modified
+ * by adding the event emitter methods (see below).
+ */
+ function createEventEmitter(object) {
+ object = object || {};
+
+ function notifyListener(event, listener, args) {
+ try {
+ listener.listener.apply(listener.thisp || object, args);
+ } catch (e) {
+ handleError(event, e, errbacks(object));
+ }
+ }
+
+ object.on = function (event, listener, thisp) {
+ if (typeof event === "function") {
+ return supervisors(this).push({
+ listener: event,
+ thisp: listener
+ });
+ }
+ listeners(this, event).push({
+ listener: assertFunction(listener),
+ thisp: thisp
+ });
+ };
+
+ object.off = function (event, listener) {
+ var fns, events, i, l;
+ if (!event) {
+ fns = supervisors(this);
+ fns.splice(0, fns.length);
+
+ events = listeners(this);
+ for (i in events) {
+ if (events.hasOwnProperty(i)) {
+ fns = listeners(this, i);
+ fns.splice(0, fns.length);
+ }
+ }
+
+ fns = errbacks(this);
+ fns.splice(0, fns.length);
+
+ return;
+ }
+ if (typeof event === "function") {
+ fns = supervisors(this);
+ listener = event;
+ } else {
+ fns = listeners(this, event);
+ }
+ if (!listener) {
+ fns.splice(0, fns.length);
+ return;
+ }
+ for (i = 0, l = fns.length; i < l; ++i) {
+ if (fns[i].listener === listener) {
+ fns.splice(i, 1);
+ return;
+ }
+ }
+ };
+
+ object.once = function (event, listener, thisp) {
+ var wrapper = function () {
+ object.off(event, wrapper);
+ listener.apply(this, arguments);
+ };
+
+ object.on(event, wrapper, thisp);
+ };
+
+ object.bind = function (object, events) {
+ var prop, i, l;
+ if (!events) {
+ for (prop in object) {
+ if (typeof object[prop] === "function") {
+ this.on(prop, object[prop], object);
+ }
+ }
+ } else {
+ for (i = 0, l = events.length; i < l; ++i) {
+ if (typeof object[events[i]] === "function") {
+ this.on(events[i], object[events[i]], object);
+ } else {
+ throw new Error("No such method " + events[i]);
+ }
+ }
+ }
+ return object;
+ };
+
+ object.emit = function (event) {
+ var toNotify = supervisors(this);
+ var args = slice.call(arguments), i, l;
+
+ for (i = 0, l = toNotify.length; i < l; ++i) {
+ notifyListener(event, toNotify[i], args);
+ }
+
+ toNotify = listeners(this, event).slice()
+ args = slice.call(arguments, 1);
+ for (i = 0, l = toNotify.length; i < l; ++i) {
+ notifyListener(event, toNotify[i], args);
+ }
+ };
+
+ object.errback = function (listener) {
+ if (!this.errbacks) { this.errbacks = []; }
+ this.errbacks.push(assertFunction(listener));
+ };
+
+ return object;
+ }
+
+ return { createEventEmitter: createEventEmitter };
+});
diff --git a/js/lib/when-1.6.1.js b/js/lib/when-1.6.1.js
new file mode 100644
index 00000000..e9a3bfc3
--- /dev/null
+++ b/js/lib/when-1.6.1.js
@@ -0,0 +1,731 @@
+/** @license MIT License (c) copyright B Cavalier & J Hann */
+
+/**
+ * A lightweight CommonJS Promises/A and when() implementation
+ * when is part of the cujo.js family of libraries (http://cujojs.com/)
+ *
+ * Licensed under the MIT License at:
+ * http://www.opensource.org/licenses/mit-license.php
+ *
+ * @version 1.6.1
+ */
+
+(function(define) { 'use strict';
+define(['module'], function () {
+ var reduceArray, slice, undef;
+
+ //
+ // Public API
+ //
+
+ when.defer = defer; // Create a deferred
+ when.resolve = resolve; // Create a resolved promise
+ when.reject = reject; // Create a rejected promise
+
+ when.join = join; // Join 2 or more promises
+
+ when.all = all; // Resolve a list of promises
+ when.some = some; // Resolve a sub-set of promises
+ when.any = any; // Resolve one promise in a list
+
+ when.map = map; // Array.map() for promises
+ when.reduce = reduce; // Array.reduce() for promises
+
+ when.chain = chain; // Make a promise trigger another resolver
+
+ when.isPromise = isPromise; // Determine if a thing is a promise
+
+ /**
+ * Register an observer for a promise or immediate value.
+ * @function
+ * @name when
+ * @namespace
+ *
+ * @param promiseOrValue {*}
+ * @param {Function} [callback] callback to be called when promiseOrValue is
+ * successfully fulfilled. If promiseOrValue is an immediate value, callback
+ * will be invoked immediately.
+ * @param {Function} [errback] callback to be called when promiseOrValue is
+ * rejected.
+ * @param {Function} [progressHandler] callback to be called when progress updates
+ * are issued for promiseOrValue.
+ * @returns {Promise} a new {@link Promise} that will complete with the return
+ * value of callback or errback or the completion value of promiseOrValue if
+ * callback and/or errback is not supplied.
+ */
+ function when(promiseOrValue, callback, errback, progressHandler) {
+ // Get a trusted promise for the input promiseOrValue, and then
+ // register promise handlers
+ return resolve(promiseOrValue).then(callback, errback, progressHandler);
+ }
+
+ /**
+ * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if
+ * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise}
+ * whose value is promiseOrValue if promiseOrValue is an immediate value.
+ * @memberOf when
+ *
+ * @param promiseOrValue {*}
+ * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise}
+ * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise}
+ * whose resolution value is:
+ * * the resolution value of promiseOrValue if it's a foreign promise, or
+ * * promiseOrValue if it's a value
+ */
+ function resolve(promiseOrValue) {
+ var promise, deferred;
+
+ if(promiseOrValue instanceof Promise) {
+ // It's a when.js promise, so we trust it
+ promise = promiseOrValue;
+
+ } else {
+ // It's not a when.js promise. See if it's a foreign promise or a value.
+
+ // Some promises, particularly Q promises, provide a valueOf method that
+ // attempts to synchronously return the fulfilled value of the promise, or
+ // returns the unresolved promise itself. Attempting to break a fulfillment
+ // value out of a promise appears to be necessary to break cycles between
+ // Q and When attempting to coerce each-other's promises in an infinite loop.
+ // For promises that do not implement "valueOf", the Object#valueOf is harmless.
+ // See: https://github.com/kriskowal/q/issues/106
+ // IMPORTANT: Must check for a promise here, since valueOf breaks other things
+ // like Date.
+ if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') {
+ promiseOrValue = promiseOrValue.valueOf();
+ }
+
+ if(isPromise(promiseOrValue)) {
+ // It looks like a thenable, but we don't know where it came from,
+ // so we don't trust its implementation entirely. Introduce a trusted
+ // middleman when.js promise
+ deferred = defer();
+
+ // IMPORTANT: This is the only place when.js should ever call .then() on
+ // an untrusted promise.
+ promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress);
+ promise = deferred.promise;
+
+ } else {
+ // It's a value, not a promise. Create a resolved promise for it.
+ promise = fulfilled(promiseOrValue);
+ }
+ }
+
+ return promise;
+ }
+
+ /**
+ * Returns a rejected promise for the supplied promiseOrValue. If
+ * promiseOrValue is a value, it will be the rejection value of the
+ * returned promise. If promiseOrValue is a promise, its
+ * completion value will be the rejected value of the returned promise
+ * @memberOf when
+ *
+ * @param promiseOrValue {*} the rejected value of the returned {@link Promise}
+ * @return {Promise} rejected {@link Promise}
+ */
+ function reject(promiseOrValue) {
+ return when(promiseOrValue, function(value) {
+ return rejected(value);
+ });
+ }
+
+ /**
+ * Trusted Promise constructor. A Promise created from this constructor is
+ * a trusted when.js promise. Any other duck-typed promise is considered
+ * untrusted.
+ * @constructor
+ * @name Promise
+ */
+ function Promise(then) {
+ this.then = then;
+ }
+
+ Promise.prototype = {
+ /**
+ * Register a callback that will be called when a promise is
+ * resolved or rejected. Optionally also register a progress handler.
+ * Shortcut for .then(alwaysback, alwaysback, progback)
+ * @memberOf Promise
+ * @param alwaysback {Function}
+ * @param progback {Function}
+ * @return {Promise}
+ */
+ always: function(alwaysback, progback) {
+ return this.then(alwaysback, alwaysback, progback);
+ },
+
+ /**
+ * Register a rejection handler. Shortcut for .then(null, errback)
+ * @memberOf Promise
+ * @param errback {Function}
+ * @return {Promise}
+ */
+ otherwise: function(errback) {
+ return this.then(undef, errback);
+ }
+ };
+
+ /**
+ * Create an already-resolved promise for the supplied value
+ * @private
+ *
+ * @param value anything
+ * @return {Promise}
+ */
+ function fulfilled(value) {
+ var p = new Promise(function(callback) {
+ try {
+ return resolve(callback ? callback(value) : value);
+ } catch(e) {
+ return rejected(e);
+ }
+ });
+
+ return p;
+ }
+
+ /**
+ * Create an already-rejected {@link Promise} with the supplied
+ * rejection reason.
+ * @private
+ *
+ * @param reason rejection reason
+ * @return {Promise}
+ */
+ function rejected(reason) {
+ var p = new Promise(function(callback, errback) {
+ try {
+ return errback ? resolve(errback(reason)) : rejected(reason);
+ } catch(e) {
+ return rejected(e);
+ }
+ });
+
+ return p;
+ }
+
+ /**
+ * Creates a new, Deferred with fully isolated resolver and promise parts,
+ * either or both of which may be given out safely to consumers.
+ * The Deferred itself has the full API: resolve, reject, progress, and
+ * then. The resolver has resolve, reject, and progress. The promise
+ * only has then.
+ * @memberOf when
+ * @function
+ *
+ * @return {Deferred}
+ */
+ function defer() {
+ var deferred, promise, handlers, progressHandlers,
+ _then, _progress, _resolve;
+
+ /**
+ * The promise for the new deferred
+ * @type {Promise}
+ */
+ promise = new Promise(then);
+
+ /**
+ * The full Deferred object, with {@link Promise} and {@link Resolver} parts
+ * @class Deferred
+ * @name Deferred
+ */
+ deferred = {
+ then: then,
+ resolve: promiseResolve,
+ reject: promiseReject,
+ // TODO: Consider renaming progress() to notify()
+ progress: promiseProgress,
+
+ promise: promise,
+
+ resolver: {
+ resolve: promiseResolve,
+ reject: promiseReject,
+ progress: promiseProgress
+ }
+ };
+
+ handlers = [];
+ progressHandlers = [];
+
+ /**
+ * Pre-resolution then() that adds the supplied callback, errback, and progback
+ * functions to the registered listeners
+ * @private
+ *
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @throws {Error} if any argument is not null, undefined, or a Function
+ */
+ _then = function(callback, errback, progback) {
+ var deferred, progressHandler;
+
+ deferred = defer();
+ progressHandler = progback
+ ? function(update) {
+ try {
+ // Allow progress handler to transform progress event
+ deferred.progress(progback(update));
+ } catch(e) {
+ // Use caught value as progress
+ deferred.progress(e);
+ }
+ }
+ : deferred.progress;
+
+ handlers.push(function(promise) {
+ promise.then(callback, errback)
+ .then(deferred.resolve, deferred.reject, progressHandler);
+ });
+
+ progressHandlers.push(progressHandler);
+
+ return deferred.promise;
+ };
+
+ /**
+ * Issue a progress event, notifying all progress listeners
+ * @private
+ * @param update {*} progress event payload to pass to all listeners
+ */
+ _progress = function(update) {
+ processQueue(progressHandlers, update);
+ return update;
+ };
+
+ /**
+ * Transition from pre-resolution state to post-resolution state, notifying
+ * all listeners of the resolution or rejection
+ * @private
+ * @param completed {Promise} the completed value of this deferred
+ */
+ _resolve = function(completed) {
+ completed = resolve(completed);
+
+ // Replace _then with one that directly notifies with the result.
+ _then = completed.then;
+ // Replace _resolve so that this Deferred can only be completed once
+ _resolve = resolve;
+ // Make _progress a noop, to disallow progress for the resolved promise.
+ _progress = noop;
+
+ // Notify handlers
+ processQueue(handlers, completed);
+
+ // Free progressHandlers array since we'll never issue progress events
+ progressHandlers = handlers = undef;
+
+ return completed;
+ };
+
+ return deferred;
+
+ /**
+ * Wrapper to allow _then to be replaced safely
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @return {Promise} new Promise
+ * @throws {Error} if any argument is not null, undefined, or a Function
+ */
+ function then(callback, errback, progback) {
+ return _then(callback, errback, progback);
+ }
+
+ /**
+ * Wrapper to allow _resolve to be replaced
+ */
+ function promiseResolve(val) {
+ return _resolve(val);
+ }
+
+ /**
+ * Wrapper to allow _resolve to be replaced
+ */
+ function promiseReject(err) {
+ return _resolve(rejected(err));
+ }
+
+ /**
+ * Wrapper to allow _progress to be replaced
+ * @param {*} update progress update
+ */
+ function promiseProgress(update) {
+ return _progress(update);
+ }
+ }
+
+ /**
+ * Determines if promiseOrValue is a promise or not. Uses the feature
+ * test from http://wiki.commonjs.org/wiki/Promises/A to determine if
+ * promiseOrValue is a promise.
+ *
+ * @param {*} promiseOrValue anything
+ * @returns {Boolean} true if promiseOrValue is a {@link Promise}
+ */
+ function isPromise(promiseOrValue) {
+ return promiseOrValue && typeof promiseOrValue.then === 'function';
+ }
+
+ /**
+ * Initiates a competitive race, returning a promise that will resolve when
+ * howMany of the supplied promisesOrValues have resolved, or will reject when
+ * it becomes impossible for howMany to resolve, for example, when
+ * (promisesOrValues.length - howMany) + 1 input promises reject.
+ * @memberOf when
+ *
+ * @param promisesOrValues {Array} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param howMany {Number} number of promisesOrValues to resolve
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @returns {Promise} promise that will resolve to an array of howMany values that
+ * resolved first, or will reject with an array of (promisesOrValues.length - howMany) + 1
+ * rejection reasons.
+ */
+ function some(promisesOrValues, howMany, callback, errback, progback) {
+
+ checkCallbacks(2, arguments);
+
+ return when(promisesOrValues, function(promisesOrValues) {
+
+ var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i;
+
+ len = promisesOrValues.length >>> 0;
+
+ toResolve = Math.max(0, Math.min(howMany, len));
+ values = [];
+
+ toReject = (len - toResolve) + 1;
+ reasons = [];
+
+ deferred = defer();
+
+ // No items in the input, resolve immediately
+ if (!toResolve) {
+ deferred.resolve(values);
+
+ } else {
+ progress = deferred.progress;
+
+ rejectOne = function(reason) {
+ reasons.push(reason);
+ if(!--toReject) {
+ fulfillOne = rejectOne = noop;
+ deferred.reject(reasons);
+ }
+ };
+
+ fulfillOne = function(val) {
+ // This orders the values based on promise resolution order
+ // Another strategy would be to use the original position of
+ // the corresponding promise.
+ values.push(val);
+
+ if (!--toResolve) {
+ fulfillOne = rejectOne = noop;
+ deferred.resolve(values);
+ }
+ };
+
+ for(i = 0; i < len; ++i) {
+ if(i in promisesOrValues) {
+ when(promisesOrValues[i], fulfiller, rejecter, progress);
+ }
+ }
+ }
+
+ return deferred.then(callback, errback, progback);
+
+ function rejecter(reason) {
+ rejectOne(reason);
+ }
+
+ function fulfiller(val) {
+ fulfillOne(val);
+ }
+
+ });
+ }
+
+ /**
+ * Initiates a competitive race, returning a promise that will resolve when
+ * any one of the supplied promisesOrValues has resolved or will reject when
+ * *all* promisesOrValues have rejected.
+ * @memberOf when
+ *
+ * @param promisesOrValues {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @returns {Promise} promise that will resolve to the value that resolved first, or
+ * will reject with an array of all rejected inputs.
+ */
+ function any(promisesOrValues, callback, errback, progback) {
+
+ function unwrapSingleResult(val) {
+ return callback ? callback(val[0]) : val[0];
+ }
+
+ return some(promisesOrValues, 1, unwrapSingleResult, errback, progback);
+ }
+
+ /**
+ * Return a promise that will resolve only once all the supplied promisesOrValues
+ * have resolved. The resolution value of the returned promise will be an array
+ * containing the resolution values of each of the promisesOrValues.
+ * @memberOf when
+ *
+ * @param promisesOrValues {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param [callback] {Function}
+ * @param [errback] {Function}
+ * @param [progressHandler] {Function}
+ * @returns {Promise}
+ */
+ function all(promisesOrValues, callback, errback, progressHandler) {
+ checkCallbacks(1, arguments);
+ return map(promisesOrValues, identity).then(callback, errback, progressHandler);
+ }
+
+ /**
+ * Joins multiple promises into a single returned promise.
+ * @memberOf when
+ * @param {Promise|*} [...promises] two or more promises to join
+ * @return {Promise} a promise that will fulfill when *all* the input promises
+ * have fulfilled, or will reject when *any one* of the input promises rejects.
+ */
+ function join(/* ...promises */) {
+ return map(arguments, identity);
+ }
+
+ /**
+ * Traditional map function, similar to `Array.prototype.map()`, but allows
+ * input to contain {@link Promise}s and/or values, and mapFunc may return
+ * either a value or a {@link Promise}
+ *
+ * @memberOf when
+ *
+ * @param promise {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param mapFunc {Function} mapping function mapFunc(value) which may return
+ * either a {@link Promise} or value
+ * @returns {Promise} a {@link Promise} that will resolve to an array containing
+ * the mapped output values.
+ */
+ function map(promise, mapFunc) {
+ return when(promise, function(array) {
+ var results, len, toResolve, resolve, reject, i, d;
+
+ // Since we know the resulting length, we can preallocate the results
+ // array to avoid array expansions.
+ toResolve = len = array.length >>> 0;
+ results = [];
+ d = defer();
+
+ if(!toResolve) {
+ d.resolve(results);
+ } else {
+
+ reject = d.reject;
+ resolve = function resolveOne(item, i) {
+ when(item, mapFunc).then(function(mapped) {
+ results[i] = mapped;
+
+ if(!--toResolve) {
+ d.resolve(results);
+ }
+ }, reject);
+ };
+
+ // Since mapFunc may be async, get all invocations of it into flight
+ for(i = 0; i < len; i++) {
+ if(i in array) {
+ resolve(array[i], i);
+ } else {
+ --toResolve;
+ }
+ }
+
+ }
+
+ return d.promise;
+
+ });
+ }
+
+ /**
+ * Traditional reduce function, similar to `Array.prototype.reduce()`, but
+ * input may contain {@link Promise}s and/or values, and reduceFunc
+ * may return either a value or a {@link Promise}, *and* initialValue may
+ * be a {@link Promise} for the starting value.
+ * @memberOf when
+ *
+ * @param promise {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values. May also be a {@link Promise} for
+ * an array.
+ * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total),
+ * where total is the total number of items being reduced, and will be the same
+ * in each call to reduceFunc.
+ * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value
+ * @returns {Promise} that will resolve to the final reduced value
+ */
+ function reduce(promise, reduceFunc /*, initialValue */) {
+ var args = slice.call(arguments, 1);
+
+ return when(promise, function(array) {
+ var total;
+
+ total = array.length;
+
+ // Wrap the supplied reduceFunc with one that handles promises and then
+ // delegates to the supplied.
+ args[0] = function (current, val, i) {
+ return when(current, function (c) {
+ return when(val, function (value) {
+ return reduceFunc(c, value, i, total);
+ });
+ });
+ };
+
+ return reduceArray.apply(array, args);
+ });
+ }
+
+ /**
+ * Ensure that resolution of promiseOrValue will complete resolver with the completion
+ * value of promiseOrValue, or instead with resolveValue if it is provided.
+ * @memberOf when
+ *
+ * @param promiseOrValue
+ * @param resolver {Resolver}
+ * @param [resolveValue] anything
+ * @returns {Promise}
+ */
+ function chain(promiseOrValue, resolver, resolveValue) {
+ var useResolveValue = arguments.length > 2;
+
+ return when(promiseOrValue,
+ function(val) {
+ return resolver.resolve(useResolveValue ? resolveValue : val);
+ },
+ resolver.reject,
+ resolver.progress
+ );
+ }
+
+ //
+ // Utility functions
+ //
+
+ function processQueue(queue, value) {
+ var handler, i = 0;
+
+ while (handler = queue[i++]) {
+ handler(value);
+ }
+ }
+
+ /**
+ * Helper that checks arrayOfCallbacks to ensure that each element is either
+ * a function, or null or undefined.
+ * @private
+ *
+ * @param arrayOfCallbacks {Array} array to check
+ * @throws {Error} if any element of arrayOfCallbacks is something other than
+ * a Functions, null, or undefined.
+ */
+ function checkCallbacks(start, arrayOfCallbacks) {
+ var arg, i = arrayOfCallbacks.length;
+
+ while(i > start) {
+ arg = arrayOfCallbacks[--i];
+
+ if (arg != null && typeof arg != 'function') {
+ throw new Error('arg '+i+' must be a function');
+ }
+ }
+ }
+
+ /**
+ * No-Op function used in method replacement
+ * @private
+ */
+ function noop() {}
+
+ slice = [].slice;
+
+ // ES5 reduce implementation if native not available
+ // See: http://es5.github.com/#x15.4.4.21 as there are many
+ // specifics and edge cases.
+ reduceArray = [].reduce ||
+ function(reduceFunc /*, initialValue */) {
+ /*jshint maxcomplexity: 7*/
+
+ // ES5 dictates that reduce.length === 1
+
+ // This implementation deviates from ES5 spec in the following ways:
+ // 1. It does not check if reduceFunc is a Callable
+
+ var arr, args, reduced, len, i;
+
+ i = 0;
+ // This generates a jshint warning, despite being valid
+ // "Missing 'new' prefix when invoking a constructor."
+ // See https://github.com/jshint/jshint/issues/392
+ arr = Object(this);
+ len = arr.length >>> 0;
+ args = arguments;
+
+ // If no initialValue, use first item of array (we know length !== 0 here)
+ // and adjust i to start at second item
+ if(args.length <= 1) {
+ // Skip to the first real element in the array
+ for(;;) {
+ if(i in arr) {
+ reduced = arr[i++];
+ break;
+ }
+
+ // If we reached the end of the array without finding any real
+ // elements, it's a TypeError
+ if(++i >= len) {
+ throw new TypeError();
+ }
+ }
+ } else {
+ // If initialValue provided, use it
+ reduced = args[1];
+ }
+
+ // Do the actual reduce
+ for(;i < len; ++i) {
+ // Skip holes
+ if(i in arr) {
+ reduced = reduceFunc(reduced, arr[i], i, arr);
+ }
+ }
+
+ return reduced;
+ };
+
+ function identity(x) {
+ return x;
+ }
+
+ return when;
+});
+})(typeof define == 'function' && define.amd
+ ? define
+ : function (deps, factory) { typeof exports === 'object'
+ ? (module.exports = factory())
+ : (this.when = factory());
+ }
+ // Boilerplate for AMD, Node, and browser global
+);
diff --git a/js/src/mopidy.js b/js/src/mopidy.js
new file mode 100644
index 00000000..66c17b79
--- /dev/null
+++ b/js/src/mopidy.js
@@ -0,0 +1,278 @@
+/*global bane:false, when:false*/
+
+function Mopidy(settings) {
+ this._settings = this._configure(settings || {});
+ this._console = this._getConsole();
+
+ this._backoffDelay = this._settings.backoffDelayMin;
+ this._pendingRequests = {};
+ this._webSocket = null;
+
+ bane.createEventEmitter(this);
+ this._delegateEvents();
+
+ if (this._settings.autoConnect) {
+ this._connect();
+ }
+}
+
+Mopidy.prototype._configure = function (settings) {
+ settings.webSocketUrl = settings.webSocketUrl ||
+ "ws://" + document.location.host + "/mopidy/ws/";
+
+ if (settings.autoConnect !== false) {
+ settings.autoConnect = true;
+ }
+
+ settings.backoffDelayMin = settings.backoffDelayMin || 1000;
+ settings.backoffDelayMax = settings.backoffDelayMax || 64000;
+
+ return settings;
+};
+
+Mopidy.prototype._getConsole = function () {
+ var console = window.console || {};
+
+ console.log = console.log || function () {};
+ console.warn = console.warn || function () {};
+ console.error = console.error || function () {};
+
+ return console;
+};
+
+Mopidy.prototype._delegateEvents = function () {
+ // Remove existing event handlers
+ this.off("websocket:close");
+ this.off("websocket:error");
+ this.off("websocket:incomingMessage");
+ this.off("websocket:open");
+ this.off("state:offline");
+
+ // Register basic set of event handlers
+ this.on("websocket:close", this._cleanup);
+ this.on("websocket:error", this._handleWebSocketError);
+ this.on("websocket:incomingMessage", this._handleMessage);
+ this.on("websocket:open", this._resetBackoffDelay);
+ this.on("websocket:open", this._getApiSpec);
+ this.on("state:offline", this._reconnect);
+};
+
+Mopidy.prototype._connect = function () {
+ if (this._webSocket) {
+ if (this._webSocket.readyState === WebSocket.OPEN) {
+ return;
+ } else {
+ this._webSocket.close();
+ }
+ }
+
+ this._webSocket = this._settings.webSocket ||
+ new WebSocket(this._settings.webSocketUrl);
+
+ this._webSocket.onclose = function (close) {
+ this.emit("websocket:close", close);
+ }.bind(this);
+
+ this._webSocket.onerror = function (error) {
+ this.emit("websocket:error", error);
+ }.bind(this);
+
+ this._webSocket.onopen = function () {
+ this.emit("websocket:open");
+ }.bind(this);
+
+ this._webSocket.onmessage = function (message) {
+ this.emit("websocket:incomingMessage", message);
+ }.bind(this);
+};
+
+Mopidy.prototype._cleanup = function (closeEvent) {
+ Object.keys(this._pendingRequests).forEach(function (requestId) {
+ var resolver = this._pendingRequests[requestId];
+ delete this._pendingRequests[requestId];
+ resolver.reject({
+ message: "WebSocket closed",
+ closeEvent: closeEvent
+ });
+ }.bind(this));
+
+ this.emit("state:offline");
+};
+
+Mopidy.prototype._reconnect = function () {
+ this.emit("reconnectionPending", {
+ timeToAttempt: this._backoffDelay
+ });
+
+ setTimeout(function () {
+ this.emit("reconnecting");
+ this._connect();
+ }.bind(this), this._backoffDelay);
+
+ this._backoffDelay = this._backoffDelay * 2;
+ if (this._backoffDelay > this._settings.backoffDelayMax) {
+ this._backoffDelay = this._settings.backoffDelayMax;
+ }
+};
+
+Mopidy.prototype._resetBackoffDelay = function () {
+ this._backoffDelay = this._settings.backoffDelayMin;
+};
+
+Mopidy.prototype.close = function () {
+ this.off("state:offline", this._reconnect);
+ this._webSocket.close();
+};
+
+Mopidy.prototype._handleWebSocketError = function (error) {
+ this._console.warn("WebSocket error:", error.stack || error);
+};
+
+Mopidy.prototype._send = function (message) {
+ var deferred = when.defer();
+
+ switch (this._webSocket.readyState) {
+ case WebSocket.CONNECTING:
+ deferred.resolver.reject({
+ message: "WebSocket is still connecting"
+ });
+ break;
+ case WebSocket.CLOSING:
+ deferred.resolver.reject({
+ message: "WebSocket is closing"
+ });
+ break;
+ case WebSocket.CLOSED:
+ deferred.resolver.reject({
+ message: "WebSocket is closed"
+ });
+ break;
+ default:
+ message.jsonrpc = "2.0";
+ message.id = this._nextRequestId();
+ this._pendingRequests[message.id] = deferred.resolver;
+ this._webSocket.send(JSON.stringify(message));
+ this.emit("websocket:outgoingMessage", message);
+ }
+
+ return deferred.promise;
+};
+
+Mopidy.prototype._nextRequestId = (function () {
+ var lastUsed = -1;
+ return function () {
+ lastUsed += 1;
+ return lastUsed;
+ };
+}());
+
+Mopidy.prototype._handleMessage = function (message) {
+ try {
+ var data = JSON.parse(message.data);
+ if (data.hasOwnProperty("id")) {
+ this._handleResponse(data);
+ } else if (data.hasOwnProperty("event")) {
+ this._handleEvent(data);
+ } else {
+ this._console.warn(
+ "Unknown message type received. Message was: " +
+ message.data);
+ }
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ this._console.warn(
+ "WebSocket message parsing failed. Message was: " +
+ message.data);
+ } else {
+ throw error;
+ }
+ }
+};
+
+Mopidy.prototype._handleResponse = function (responseMessage) {
+ if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) {
+ this._console.warn(
+ "Unexpected response received. Message was:", responseMessage);
+ return;
+ }
+
+ var resolver = this._pendingRequests[responseMessage.id];
+ delete this._pendingRequests[responseMessage.id];
+
+ if (responseMessage.hasOwnProperty("result")) {
+ resolver.resolve(responseMessage.result);
+ } else if (responseMessage.hasOwnProperty("error")) {
+ resolver.reject(responseMessage.error);
+ this._console.warn("Server returned error:", responseMessage.error);
+ } else {
+ resolver.reject({
+ message: "Response without 'result' or 'error' received",
+ data: {response: responseMessage}
+ });
+ this._console.warn(
+ "Response without 'result' or 'error' received. Message was:",
+ responseMessage);
+ }
+};
+
+Mopidy.prototype._handleEvent = function (eventMessage) {
+ var type = eventMessage.event;
+ var data = eventMessage;
+ delete data.event;
+
+ this.emit("event:" + this._snakeToCamel(type), data);
+};
+
+Mopidy.prototype._getApiSpec = function () {
+ this._send({method: "core.describe"})
+ .then(this._createApi.bind(this), this._handleWebSocketError)
+ .then(null, this._handleWebSocketError);
+};
+
+Mopidy.prototype._createApi = function (methods) {
+ var caller = function (method) {
+ return function () {
+ var params = Array.prototype.slice.call(arguments);
+ return this._send({
+ method: method,
+ params: params
+ });
+ }.bind(this);
+ }.bind(this);
+
+ var getPath = function (fullName) {
+ var path = fullName.split(".");
+ if (path.length >= 1 && path[0] === "core") {
+ path = path.slice(1);
+ }
+ return path;
+ };
+
+ var createObjects = function (objPath) {
+ var parentObj = this;
+ objPath.forEach(function (objName) {
+ objName = this._snakeToCamel(objName);
+ parentObj[objName] = parentObj[objName] || {};
+ parentObj = parentObj[objName];
+ }.bind(this));
+ return parentObj;
+ }.bind(this);
+
+ var createMethod = function (fullMethodName) {
+ var methodPath = getPath(fullMethodName);
+ var methodName = this._snakeToCamel(methodPath.slice(-1)[0]);
+ var object = createObjects(methodPath.slice(0, -1));
+ object[methodName] = caller(fullMethodName);
+ object[methodName].description = methods[fullMethodName].description;
+ object[methodName].params = methods[fullMethodName].params;
+ }.bind(this);
+
+ Object.keys(methods).forEach(createMethod);
+ this.emit("state:online");
+};
+
+Mopidy.prototype._snakeToCamel = function (name) {
+ return name.replace(/(_[a-z])/g, function (match) {
+ return match.toUpperCase().replace("_", "");
+ });
+};
diff --git a/js/test/bind-helper.js b/js/test/bind-helper.js
new file mode 100644
index 00000000..a5a3e0f4
--- /dev/null
+++ b/js/test/bind-helper.js
@@ -0,0 +1,29 @@
+/*
+ * PhantomJS 1.6 does not support Function.prototype.bind, so we polyfill it.
+ *
+ * Implementation from:
+ * https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Function/bind
+ */
+if (!Function.prototype.bind) {
+ Function.prototype.bind = function (oThis) {
+ if (typeof this !== "function") {
+ // closest thing possible to the ECMAScript 5 internal IsCallable function
+ throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
+ }
+
+ var aArgs = Array.prototype.slice.call(arguments, 1),
+ fToBind = this,
+ fNOP = function () {},
+ fBound = function () {
+ return fToBind.apply(this instanceof fNOP && oThis
+ ? this
+ : oThis,
+ aArgs.concat(Array.prototype.slice.call(arguments)));
+ };
+
+ fNOP.prototype = this.prototype;
+ fBound.prototype = new fNOP();
+
+ return fBound;
+ };
+}
diff --git a/js/test/mopidy-test.js b/js/test/mopidy-test.js
new file mode 100644
index 00000000..fd1f73c6
--- /dev/null
+++ b/js/test/mopidy-test.js
@@ -0,0 +1,669 @@
+/*global buster:false, assert:false, refute:false, when:false, Mopidy:false*/
+
+buster.testCase("Mopidy", {
+ setUp: function () {
+ // Sinon.JS doesn't manage to stub PhantomJS' WebSocket implementation,
+ // so we replace it with a dummy temporarily.
+ var fakeWebSocket = function () {
+ return {
+ send: function () {},
+ close: function () {}
+ };
+ };
+ fakeWebSocket.CONNECTING = 0;
+ fakeWebSocket.OPEN = 1;
+ fakeWebSocket.CLOSING = 2;
+ fakeWebSocket.CLOSED = 3;
+ this.realWebSocket = WebSocket;
+ window.WebSocket = fakeWebSocket;
+
+ this.webSocketConstructorStub = this.stub(window, "WebSocket");
+
+ this.webSocket = {
+ close: this.stub(),
+ send: this.stub()
+ };
+ this.mopidy = new Mopidy({webSocket: this.webSocket});
+ },
+
+ tearDown: function () {
+ window.WebSocket = this.realWebSocket;
+ },
+
+ "constructor": {
+ "connects when autoConnect is true": function () {
+ new Mopidy({autoConnect: true});
+
+ assert.calledOnceWith(this.webSocketConstructorStub,
+ "ws://" + document.location.host + "/mopidy/ws/");
+ },
+
+ "does not connect when autoConnect is false": function () {
+ new Mopidy({autoConnect: false});
+
+ refute.called(this.webSocketConstructorStub);
+ },
+
+ "does not connect when passed a WebSocket": function () {
+ new Mopidy({webSocket: {}});
+
+ refute.called(this.webSocketConstructorStub);
+ }
+ },
+
+ "._connect": {
+ "does nothing when the WebSocket is open": function () {
+ this.webSocket.readyState = WebSocket.OPEN;
+ var mopidy = new Mopidy({webSocket: this.webSocket});
+
+ mopidy._connect();
+
+ refute.called(this.webSocket.close);
+ refute.called(this.webSocketConstructorStub);
+ }
+ },
+
+ "WebSocket events": {
+ "emits 'websocket:close' when connection is closed": function () {
+ var spy = this.spy();
+ this.mopidy.off("websocket:close");
+ this.mopidy.on("websocket:close", spy);
+
+ var closeEvent = {};
+ this.webSocket.onclose(closeEvent);
+
+ assert.calledOnceWith(spy, closeEvent);
+ },
+
+ "emits 'websocket:error' when errors occurs": function () {
+ var spy = this.spy();
+ this.mopidy.off("websocket:error");
+ this.mopidy.on("websocket:error", spy);
+
+ var errorEvent = {};
+ this.webSocket.onerror(errorEvent);
+
+ assert.calledOnceWith(spy, errorEvent);
+ },
+
+ "emits 'websocket:incomingMessage' when a message arrives": function () {
+ var spy = this.spy();
+ this.mopidy.off("websocket:incomingMessage");
+ this.mopidy.on("websocket:incomingMessage", spy);
+
+ var messageEvent = {data: "this is a message"};
+ this.webSocket.onmessage(messageEvent);
+
+ assert.calledOnceWith(spy, messageEvent);
+ },
+
+ "emits 'websocket:open' when connection is opened": function () {
+ var spy = this.spy();
+ this.mopidy.off("websocket:open");
+ this.mopidy.on("websocket:open", spy);
+
+ this.webSocket.onopen();
+
+ assert.calledOnceWith(spy);
+ }
+ },
+
+ "._cleanup": {
+ setUp: function () {
+ this.mopidy.off("state:offline");
+ },
+
+ "is called on 'websocket:close' event": function () {
+ var closeEvent = {};
+ var stub = this.stub(this.mopidy, "_cleanup");
+ this.mopidy._delegateEvents();
+
+ this.mopidy.emit("websocket:close", closeEvent);
+
+ assert.calledOnceWith(stub, closeEvent);
+ },
+
+ "rejects all pending requests": function (done) {
+ var closeEvent = {};
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
+
+ var promise1 = this.mopidy._send({method: "foo"});
+ var promise2 = this.mopidy._send({method: "bar"});
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 2);
+
+ this.mopidy._cleanup(closeEvent);
+
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
+ when.join(promise1, promise2).then(done(function () {
+ assert(false, "Promises should be rejected");
+ }), done(function (error) {
+ assert.equals(error.message, "WebSocket closed");
+ assert.same(error.closeEvent, closeEvent);
+ }));
+ },
+
+ "emits 'state:offline' event when done": function () {
+ var spy = this.spy();
+ this.mopidy.on("state:offline", spy);
+
+ this.mopidy._cleanup({});
+
+ assert.calledOnceWith(spy);
+ }
+ },
+
+ "._reconnect": {
+ "is called when the state changes to offline": function () {
+ var stub = this.stub(this.mopidy, "_reconnect");
+ this.mopidy._delegateEvents();
+
+ this.mopidy.emit("state:offline");
+
+ assert.calledOnceWith(stub);
+ },
+
+ "tries to connect after an increasing backoff delay": function () {
+ var clock = this.useFakeTimers();
+ var connectStub = this.stub(this.mopidy, "_connect");
+ var pendingSpy = this.spy();
+ this.mopidy.on("reconnectionPending", pendingSpy);
+ var reconnectingSpy = this.spy();
+ this.mopidy.on("reconnecting", reconnectingSpy);
+
+ refute.called(connectStub);
+
+ this.mopidy._reconnect();
+ assert.calledOnceWith(pendingSpy, {timeToAttempt: 1000});
+ clock.tick(0);
+ refute.called(connectStub);
+ clock.tick(1000);
+ assert.calledOnceWith(reconnectingSpy);
+ assert.calledOnce(connectStub);
+
+ pendingSpy.reset();
+ reconnectingSpy.reset();
+ this.mopidy._reconnect();
+ assert.calledOnceWith(pendingSpy, {timeToAttempt: 2000});
+ assert.calledOnce(connectStub);
+ clock.tick(0);
+ assert.calledOnce(connectStub);
+ clock.tick(1000);
+ assert.calledOnce(connectStub);
+ clock.tick(1000);
+ assert.calledOnceWith(reconnectingSpy);
+ assert.calledTwice(connectStub);
+
+ pendingSpy.reset();
+ reconnectingSpy.reset();
+ this.mopidy._reconnect();
+ assert.calledOnceWith(pendingSpy, {timeToAttempt: 4000});
+ assert.calledTwice(connectStub);
+ clock.tick(0);
+ assert.calledTwice(connectStub);
+ clock.tick(2000);
+ assert.calledTwice(connectStub);
+ clock.tick(2000);
+ assert.calledOnceWith(reconnectingSpy);
+ assert.calledThrice(connectStub);
+ },
+
+ "tries to connect at least about once per minute": function () {
+ var clock = this.useFakeTimers();
+ var connectStub = this.stub(this.mopidy, "_connect");
+ var pendingSpy = this.spy();
+ this.mopidy.on("reconnectionPending", pendingSpy);
+ this.mopidy._backoffDelay = this.mopidy._settings.backoffDelayMax;
+
+ refute.called(connectStub);
+
+ this.mopidy._reconnect();
+ assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
+ clock.tick(0);
+ refute.called(connectStub);
+ clock.tick(64000);
+ assert.calledOnce(connectStub);
+
+ pendingSpy.reset();
+ this.mopidy._reconnect();
+ assert.calledOnceWith(pendingSpy, {timeToAttempt: 64000});
+ assert.calledOnce(connectStub);
+ clock.tick(0);
+ assert.calledOnce(connectStub);
+ clock.tick(64000);
+ assert.calledTwice(connectStub);
+ }
+ },
+
+ "._resetBackoffDelay": {
+ "is called on 'websocket:open' event": function () {
+ var stub = this.stub(this.mopidy, "_resetBackoffDelay");
+ this.mopidy._delegateEvents();
+
+ this.mopidy.emit("websocket:open");
+
+ assert.calledOnceWith(stub);
+ },
+
+ "resets the backoff delay to the minimum value": function () {
+ this.mopidy._backoffDelay = this.mopidy._backoffDelayMax;
+
+ this.mopidy._resetBackoffDelay();
+
+ assert.equals(this.mopidy._backoffDelay,
+ this.mopidy._settings.backoffDelayMin);
+ }
+ },
+
+ "close": {
+ "unregisters reconnection hooks": function () {
+ this.stub(this.mopidy, "off");
+
+ this.mopidy.close();
+
+ assert.calledOnceWith(
+ this.mopidy.off, "state:offline", this.mopidy._reconnect);
+ },
+
+ "closes the WebSocket": function () {
+ this.mopidy.close();
+
+ assert.calledOnceWith(this.mopidy._webSocket.close);
+ }
+ },
+
+ "._handleWebSocketError": {
+ "is called on 'websocket:error' event": function () {
+ var error = {};
+ var stub = this.stub(this.mopidy, "_handleWebSocketError");
+ this.mopidy._delegateEvents();
+
+ this.mopidy.emit("websocket:error", error);
+
+ assert.calledOnceWith(stub, error);
+ },
+
+ "without stack logs the error to the console": function () {
+ var stub = this.stub(this.mopidy._console, "warn");
+ var error = {};
+
+ this.mopidy._handleWebSocketError(error);
+
+ assert.calledOnceWith(stub, "WebSocket error:", error);
+ },
+
+ "with stack logs the error to the console": function () {
+ var stub = this.stub(this.mopidy._console, "warn");
+ var error = {stack: "foo"};
+
+ this.mopidy._handleWebSocketError(error);
+
+ assert.calledOnceWith(stub, "WebSocket error:", error.stack);
+ }
+ },
+
+ "._send": {
+ "adds JSON-RPC fields to the message": function () {
+ this.stub(this.mopidy, "_nextRequestId").returns(1);
+ var stub = this.stub(JSON, "stringify");
+
+ this.mopidy._send({method: "foo"});
+
+ assert.calledOnceWith(stub, {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "foo"
+ });
+ },
+
+ "adds a resolver to the pending requests queue": function () {
+ this.stub(this.mopidy, "_nextRequestId").returns(1);
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
+
+ this.mopidy._send({method: "foo"});
+
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
+ assert.isFunction(this.mopidy._pendingRequests[1].resolve);
+ },
+
+ "sends message on the WebSocket": function () {
+ refute.called(this.mopidy._webSocket.send);
+
+ this.mopidy._send({method: "foo"});
+
+ assert.calledOnce(this.mopidy._webSocket.send);
+ },
+
+ "emits a 'websocket:outgoingMessage' event": function () {
+ var spy = this.spy();
+ this.mopidy.on("websocket:outgoingMessage", spy);
+ this.stub(this.mopidy, "_nextRequestId").returns(1);
+
+ this.mopidy._send({method: "foo"});
+
+ assert.calledOnceWith(spy, {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "foo"
+ });
+ },
+
+ "immediately rejects request if CONNECTING": function (done) {
+ this.mopidy._webSocket.readyState = WebSocket.CONNECTING;
+
+ var promise = this.mopidy._send({method: "foo"});
+
+ refute.called(this.mopidy._webSocket.send);
+ promise.then(done(function () {
+ assert(false);
+ }), done(function (error) {
+ assert.equals(
+ error.message, "WebSocket is still connecting");
+ }));
+ },
+
+ "immediately rejects request if CLOSING": function (done) {
+ this.mopidy._webSocket.readyState = WebSocket.CLOSING;
+
+ var promise = this.mopidy._send({method: "foo"});
+
+ refute.called(this.mopidy._webSocket.send);
+ promise.then(done(function () {
+ assert(false);
+ }), done(function (error) {
+ assert.equals(
+ error.message, "WebSocket is closing");
+ }));
+ },
+
+ "immediately rejects request if CLOSED": function (done) {
+ this.mopidy._webSocket.readyState = WebSocket.CLOSED;
+
+ var promise = this.mopidy._send({method: "foo"});
+
+ refute.called(this.mopidy._webSocket.send);
+ promise.then(done(function () {
+ assert(false);
+ }), done(function (error) {
+ assert.equals(
+ error.message, "WebSocket is closed");
+ }));
+ }
+ },
+
+ "._nextRequestId": {
+ "returns an ever increasing ID": function () {
+ var base = this.mopidy._nextRequestId();
+ assert.equals(this.mopidy._nextRequestId(), base + 1);
+ assert.equals(this.mopidy._nextRequestId(), base + 2);
+ assert.equals(this.mopidy._nextRequestId(), base + 3);
+ }
+ },
+
+ "._handleMessage": {
+ "is called on 'websocket:incomingMessage' event": function () {
+ var messageEvent = {};
+ var stub = this.stub(this.mopidy, "_handleMessage");
+ this.mopidy._delegateEvents();
+
+ this.mopidy.emit("websocket:incomingMessage", messageEvent);
+
+ assert.calledOnceWith(stub, messageEvent);
+ },
+
+ "passes JSON-RPC responses on to _handleResponse": function () {
+ var stub = this.stub(this.mopidy, "_handleResponse");
+ var message = {
+ jsonrpc: "2.0",
+ id: 1,
+ result: null
+ };
+ var messageEvent = {data: JSON.stringify(message)};
+
+ this.mopidy._handleMessage(messageEvent);
+
+ assert.calledOnceWith(stub, message);
+ },
+
+ "passes events on to _handleEvent": function () {
+ var stub = this.stub(this.mopidy, "_handleEvent");
+ var message = {
+ event: "track_playback_started",
+ track: {}
+ };
+ var messageEvent = {data: JSON.stringify(message)};
+
+ this.mopidy._handleMessage(messageEvent);
+
+ assert.calledOnceWith(stub, message);
+ },
+
+ "logs unknown messages": function () {
+ var stub = this.stub(this.mopidy._console, "warn");
+ var messageEvent = {data: JSON.stringify({foo: "bar"})};
+
+ this.mopidy._handleMessage(messageEvent);
+
+ assert.calledOnceWith(stub,
+ "Unknown message type received. Message was: " +
+ messageEvent.data);
+ },
+
+ "logs JSON parsing errors": function () {
+ var stub = this.stub(this.mopidy._console, "warn");
+ var messageEvent = {data: "foobarbaz"};
+
+ this.mopidy._handleMessage(messageEvent);
+
+ assert.calledOnceWith(stub,
+ "WebSocket message parsing failed. Message was: " +
+ messageEvent.data);
+ }
+ },
+
+ "._handleResponse": {
+ "logs unexpected responses": function () {
+ var stub = this.stub(this.mopidy._console, "warn");
+ var responseMessage = {
+ jsonrpc: "2.0",
+ id: 1337,
+ result: null
+ };
+
+ this.mopidy._handleResponse(responseMessage);
+
+ assert.calledOnceWith(stub,
+ "Unexpected response received. Message was:", responseMessage);
+ },
+
+ "removes the matching request from the pending queue": function () {
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
+ this.mopidy._send({method: "bar"});
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 1);
+
+ this.mopidy._handleResponse({
+ jsonrpc: "2.0",
+ id: Object.keys(this.mopidy._pendingRequests)[0],
+ result: "baz"
+ });
+
+ assert.equals(Object.keys(this.mopidy._pendingRequests).length, 0);
+ },
+
+ "resolves requests which get results back": function (done) {
+ var promise = this.mopidy._send({method: "bar"});
+ var responseResult = {};
+ var responseMessage = {
+ jsonrpc: "2.0",
+ id: Object.keys(this.mopidy._pendingRequests)[0],
+ result: responseResult
+ };
+
+ this.mopidy._handleResponse(responseMessage);
+ promise.then(done(function (result) {
+ assert.equals(result, responseResult);
+ }), done(function () {
+ assert(false);
+ }));
+ },
+
+ "rejects and logs requests which get errors back": function (done) {
+ var stub = this.stub(this.mopidy._console, "warn");
+ var promise = this.mopidy._send({method: "bar"});
+ var responseError = {message: "Error", data: {}};
+ var responseMessage = {
+ jsonrpc: "2.0",
+ id: Object.keys(this.mopidy._pendingRequests)[0],
+ error: responseError
+ };
+
+ this.mopidy._handleResponse(responseMessage);
+
+ assert.calledOnceWith(stub,
+ "Server returned error:", responseError);
+ promise.then(done(function () {
+ assert(false);
+ }), done(function (error) {
+ assert.equals(error, responseError);
+ }));
+ },
+
+ "rejects and logs responses without result or error": function (done) {
+ var stub = this.stub(this.mopidy._console, "warn");
+ var promise = this.mopidy._send({method: "bar"});
+ var responseMessage = {
+ jsonrpc: "2.0",
+ id: Object.keys(this.mopidy._pendingRequests)[0]
+ };
+
+ this.mopidy._handleResponse(responseMessage);
+
+ assert.calledOnceWith(stub,
+ "Response without 'result' or 'error' received. Message was:",
+ responseMessage);
+ promise.then(done(function () {
+ assert(false);
+ }), done(function (error) {
+ assert.equals(
+ error.message,
+ "Response without 'result' or 'error' received");
+ assert.equals(error.data.response, responseMessage);
+ }));
+ }
+ },
+
+ "._handleEvent": {
+ "emits server side even on Mopidy object": function () {
+ var spy = this.spy();
+ this.mopidy.on(spy);
+ var track = {};
+ var message = {
+ event: "track_playback_started",
+ track: track
+ };
+
+ this.mopidy._handleEvent(message);
+
+ assert.calledOnceWith(spy,
+ "event:trackPlaybackStarted", {track: track});
+ }
+ },
+
+ "._getApiSpec": {
+ "is called on 'websocket:open' event": function () {
+ var stub = this.stub(this.mopidy, "_getApiSpec");
+ this.mopidy._delegateEvents();
+
+ this.mopidy.emit("websocket:open");
+
+ assert.calledOnceWith(stub);
+ },
+
+ "gets Api description from server and calls _createApi": function () {
+ var methods = {};
+ var sendStub = this.stub(this.mopidy, "_send");
+ sendStub.returns(when.resolve(methods));
+ var _createApiStub = this.stub(this.mopidy, "_createApi");
+
+ this.mopidy._getApiSpec();
+
+ assert.calledOnceWith(sendStub, {method: "core.describe"});
+ assert.calledOnceWith(_createApiStub, methods);
+ }
+ },
+
+ "._createApi": {
+ "can create an API with methods on the root object": function () {
+ refute.defined(this.mopidy.hello);
+ refute.defined(this.mopidy.hi);
+
+ this.mopidy._createApi({
+ hello: {
+ description: "Says hello",
+ params: []
+ },
+ hi: {
+ description: "Says hi",
+ params: []
+ }
+ });
+
+ assert.isFunction(this.mopidy.hello);
+ assert.equals(this.mopidy.hello.description, "Says hello");
+ assert.equals(this.mopidy.hello.params, []);
+ assert.isFunction(this.mopidy.hi);
+ assert.equals(this.mopidy.hi.description, "Says hi");
+ assert.equals(this.mopidy.hi.params, []);
+ },
+
+ "can create an API with methods on a sub-object": function () {
+ refute.defined(this.mopidy.hello);
+
+ this.mopidy._createApi({
+ "hello.world": {
+ description: "Says hello to the world",
+ params: []
+ }
+ });
+
+ assert.defined(this.mopidy.hello);
+ assert.isFunction(this.mopidy.hello.world);
+ },
+
+ "strips off 'core' from method paths": function () {
+ refute.defined(this.mopidy.hello);
+
+ this.mopidy._createApi({
+ "core.hello.world": {
+ description: "Says hello to the world",
+ params: []
+ }
+ });
+
+ assert.defined(this.mopidy.hello);
+ assert.isFunction(this.mopidy.hello.world);
+ },
+
+ "converts snake_case to camelCase": function () {
+ refute.defined(this.mopidy.mightyGreetings);
+
+ this.mopidy._createApi({
+ "mighty_greetings.hello_world": {
+ description: "Says hello to the world",
+ params: []
+ }
+ });
+
+ assert.defined(this.mopidy.mightyGreetings);
+ assert.isFunction(this.mopidy.mightyGreetings.helloWorld);
+ },
+
+ "triggers 'state:online' event when API is ready for use": function () {
+ var spy = this.spy();
+ this.mopidy.on("state:online", spy);
+
+ this.mopidy._createApi({});
+
+ assert.calledOnceWith(spy);
+ }
+ }
+});
diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py
index d98734b2..d81d4791 100644
--- a/mopidy/frontends/http/__init__.py
+++ b/mopidy/frontends/http/__init__.py
@@ -115,17 +115,335 @@ Example JSON-RPC request::
Example JSON-RPC response::
- {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", ...}}
+ {"jsonrpc": "2.0", "id": 1, "result": {"__model__": "Track", "...": "..."}}
The JSON-RPC method ``core.describe`` returns a data structure describing all
available methods. If you're unsure how the core API maps to JSON-RPC, having a
look at the ``core.describe`` response can be helpful.
-JavaScript wrapper
-==================
-A JavaScript library wrapping the JSON-RPC over WebSocket API is under
-development. Details on it will appear here when it's released.
+Mopidy.js JavaScript library
+============================
+
+We've made a JavaScript library, Mopidy.js, which wraps the WebSocket and gets
+you quickly started with working on your client instead of figuring out how to
+communicate with Mopidy.
+
+
+Getting the library
+-------------------
+
+Regular and minified versions of Mopidy.js, ready for use, is installed
+together with Mopidy. When the HTTP frontend is running, the files are
+available at:
+
+- http://localhost:6680/mopidy/mopidy.js
+- http://localhost:6680/mopidy/mopidy.min.js
+
+You may need to adjust hostname and port for your local setup.
+
+Thus, if you use Mopidy to host your web client, like described above, you can
+load the latest version of Mopidy.js by adding the following script tag to your
+HTML file:
+
+.. code-block:: html
+
+
+
+If you don't use Mopidy to host your web client, you can find the JS files in
+the Git repo at:
+
+- ``mopidy/frontends/http/data/mopidy.js``
+- ``mopidy/frontends/http/data/mopidy.min.js``
+
+If you want to work on the Mopidy.js library itself, you'll find a complete
+development setup in the ``js/`` dir in our repo. The instructions in
+``js/README.rst`` will guide you on your way.
+
+
+Creating an instance
+--------------------
+
+Once you got Mopidy.js loaded, you need to create an instance of the wrapper:
+
+.. code-block:: js
+
+ var mopidy = new Mopidy();
+
+When you instantiate ``Mopidy()`` without arguments, it will connect to
+the WebSocket at ``/mopidy/ws/`` on the current host. Thus, if you don't host
+your web client using Mopidy's web server, you'll need to pass the URL to the
+WebSocket end point:
+
+.. code-block:: js
+
+ var mopidy = new Mopidy({
+ webSocketUrl: "ws://localhost:6680/mopidy/ws/"
+ });
+
+
+Hooking up to events
+--------------------
+
+Once you have a Mopidy.js object, you can hook up to the events it emits. To
+explore your possibilities, it can be useful to subscribe to all events and log
+them:
+
+.. code-block:: js
+
+ mopidy.on(console.log);
+
+Several types of events are emitted:
+
+- You can get notified about when the Mopidy.js object is connected to the
+ server and ready for method calls, when it's offline, and when it's trying to
+ reconnect to the server by looking at the events ``state:online``,
+ ``state:offline``, ``reconnectionPending``, and ``reconnecting``.
+
+- You can get events sent from the Mopidy server by looking at the events with
+ the name prefix ``event:``, like ``event:trackPlaybackStarted``.
+
+- You can introspect what happens internally on the WebSocket by looking at the
+ events emitted with the name prefix ``websocket:``.
+
+Mopidy.js uses the event emitter library `BANE
+`_, so you should refer to BANE's
+short API documentation to see how you can hook up your listeners to the
+different events.
+
+
+Calling core API methods
+------------------------
+
+Once your Mopidy.js object has connected to the Mopidy server and emits the
+``state:online`` event, it is ready to accept core API method calls:
+
+.. code-block:: js
+
+ mopidy.on("state:online", function () [
+ mopidy.playback.next();
+ });
+
+Any calls you make before the ``state:online`` event is emitted will fail. If
+you've hooked up an errback (more on that a bit later) to the promise returned
+from the call, the errback will be called with an error message.
+
+All methods in Mopidy's :ref:`core-api` is available via Mopidy.js. The core
+API attributes is *not* available, but that shouldn't be a problem as we've
+added (undocumented) getters and setters for all of them, so you can access the
+attributes as well from JavaScript.
+
+Both the WebSocket API and the JavaScript API are based on introspection of the
+core Python API. Thus, they will always be up to date and immediately reflect
+any changes we do to the core API.
+
+The best way to explore the JavaScript API, is probably by opening your
+browser's console, and using its tab completion to navigate the API. You'll
+find the Mopidy core API exposed under ``mopidy.playback``,
+``mopidy.tracklist``, ``mopidy.playlists``, and ``mopidy.library``.
+
+All methods in the JavaScript API have an associated data structure describing
+the Python params it expects, and most methods also have the Python API
+documentation available. This is available right there in the browser console,
+by looking at the method's ``description`` and ``params`` attributes:
+
+.. code-block:: js
+
+ console.log(mopidy.playback.next.params);
+ console.log(mopidy.playback.next.description);
+
+JSON-RPC 2.0 limits method parameters to be sent *either* by-position or
+by-name. Combinations of both, like we're used to from Python, isn't supported
+by JSON-RPC 2.0. To further limit this, Mopidy.js currently only supports
+passing parameters by-position.
+
+Obviously, you'll want to get a return value from many of your method calls.
+Since everything is happening across the WebSocket and maybe even across the
+network, you'll get the results asynchronously. Instead of having to pass
+callbacks and errbacks to every method you call, the methods return "promise"
+objects, which you can use to pipe the future result as input to another
+method, or to hook up callback and errback functions.
+
+.. code-block:: js
+
+ var track = mopidy.playback.getCurrentTrack();
+ // => ``track`` isn't a track, but a "promise" object
+
+Instead, typical usage will look like this:
+
+.. code-block:: js
+
+ var printCurrentTrack = function (track) {
+ if (track) {
+ console.log("Currently playing:", track.name, "by",
+ track.artists[0].name, "from", track.album.name);
+ } else {
+ console.log("No current track");
+ }
+ };
+
+ mopidy.playback.getCurrentTrack().then(printCurrentTrack, console.error);
+
+The first function passed to ``then()``, ``printCurrentTrack``, is the callback
+that will be called if the method call succeeds. The second function,
+``console.error``, is the errback that will be called if anything goes wrong.
+If you don't hook up an errback, debugging will be hard as errors will silently
+go missing.
+
+For debugging, you may be interested in errors from function without
+interesting return values as well. In that case, you can pass ``null`` as the
+callback:
+
+.. code-block:: js
+
+ mopidy.playback.next().then(null, console.error);
+
+The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A
+`_ standard. We use the
+implementation known as `when.js `_. Please
+refer to when.js' documentation or the standard for further details on how to
+work with promise objects.
+
+
+Cleaning up
+-----------
+
+If you for some reason want to clean up after Mopidy.js before the web page is
+closed or navigated away from, you can close the WebSocket, unregister all
+event listeners, and delete the object like this:
+
+.. code-block:: js
+
+ // Close the WebSocket without reconnecting. Letting the object be garbage
+ // collected will have the same effect, so this isn't striclty necessary.
+ mopidy.close();
+
+ // Unregister all event listeners. If you don't do this, you may have
+ // lingering references to the object causing the garbage collector to not
+ // clean up after it.
+ mopidy.off();
+
+ // Delete your reference to the object, so it can be garbage collected.
+ mopidy = null;
+
+
+Example to get started with
+---------------------------
+
+1. Create an empty directory for your web client.
+
+2. Change the setting :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` to point
+ to your new directory.
+
+3. Make sure that you've included
+ ``mopidy.frontends.http.HttpFrontend`` in
+ :attr:`mopidy.settings.FRONTENDS`.
+
+4. Start/restart Mopidy.
+
+5. Create a file in the directory named ``index.html`` containing e.g. "Hello,
+ world!".
+
+6. Visit http://localhost:6680/ to confirm that you can view your new HTML file
+ there.
+
+7. Include Mopidy.js in your web page:
+
+ .. code-block:: html
+
+
+
+8. Add one of the following Mopidy.js examples of how to queue and start
+ playback of your first playlist either to your web page or a JavaScript file
+ that you include in your web page.
+
+ "Imperative" style:
+
+ .. code-block:: js
+
+ var trackDesc = function (track) {
+ return track.name + " by " + track.artists[0].name +
+ " from " + track.album.name;
+ };
+
+ var queueAndPlayFirstPlaylist = function () {
+ mopidy.playlists.getPlaylists().then(function (playlists) {
+ var playlist = playlists[0];
+ console.log("Loading playlist:", playlist.name);
+ mopidy.tracklist.add(playlist.tracks).then(function (tlTracks) {
+ mopidy.playback.play(tlTracks[0]).then(function () {
+ mopidy.playback.getCurrentTrack().then(function (track) {
+ console.log("Now playing:", trackDesc(track));
+ }, console.error);
+ }, console.error);
+ }, console.error);
+ }, console.error);
+ };
+
+ var mopidy = new Mopidy(); // Connect to server
+ mopidy.on(console.log); // Log all events
+ mopidy.on("state:online", queueAndPlayFirstPlaylist);
+
+ Approximately the same behavior in a more functional style, using chaining
+ of promisies.
+
+ .. code-block:: js
+
+ var getFirst = function (list) {
+ return list[0];
+ };
+
+ var extractTracks = function (playlist) {
+ return playlist.tracks;
+ };
+
+ var printTypeAndName = function (model) {
+ console.log(model.__model__ + ": " + model.name);
+ // By returning the playlist, this function can be inserted
+ // anywhere a model with a name is piped in the chain.
+ return model;
+ };
+
+ var trackDesc = function (track) {
+ return track.name + " by " + track.artists[0].name +
+ " from " + track.album.name;
+ };
+
+ var printNowPlaying = function () {
+ // By returning any arguments we get, the function can be inserted
+ // anywhere in the chain.
+ var args = arguments;
+ return mopidy.playback.getCurrentTrack().then(function (track) {
+ console.log("Now playing:", trackDesc(track));
+ return args;
+ });
+ };
+
+ var queueAndPlayFirstPlaylist = function () {
+ mopidy.playlists.getPlaylists()
+ // => list of Playlists
+ .then(getFirst, console.error)
+ // => Playlist
+ .then(printTypeAndName, console.error)
+ // => Playlist
+ .then(extractTracks, console.error)
+ // => list of Tracks
+ .then(mopidy.tracklist.add, console.error)
+ // => list of TlTracks
+ .then(getFirst, console.error)
+ // => TlTrack
+ .then(mopidy.playback.play, console.error)
+ // => null
+ .then(printNowPlaying, console.error);
+ };
+
+ var mopidy = new Mopidy(); // Connect to server
+ mopidy.on(console.log); // Log all events
+ mopidy.on("state:online", queueAndPlayFirstPlaylist);
+
+9. The web page should now queue and play your first playlist every time your
+ load it. See the browser's console for output from the function, any errors,
+ and a all events that are emitted.
"""
# flake8: noqa
diff --git a/mopidy/frontends/http/data/mopidy.js b/mopidy/frontends/http/data/mopidy.js
new file mode 100644
index 00000000..6249ef7f
--- /dev/null
+++ b/mopidy/frontends/http/data/mopidy.js
@@ -0,0 +1,1187 @@
+/*! Mopidy.js - built 2012-12-04
+ * http://www.mopidy.com/
+ * Copyright (c) 2012 Stein Magnus Jodal and contributors
+ * Licensed under the Apache License, Version 2.0 */
+
+/**
+ * BANE - Browser globals, AMD and Node Events
+ *
+ * https://github.com/busterjs/bane
+ *
+ * @version 0.4.0
+ */
+
+((typeof define === "function" && define.amd && function (m) { define(m); }) ||
+ (typeof module === "object" && function (m) { module.exports = m(); }) ||
+ function (m) { this.bane = m(); }
+)(function () {
+ "use strict";
+ var slice = Array.prototype.slice;
+
+ function handleError(event, error, errbacks) {
+ var i, l = errbacks.length;
+ if (l > 0) {
+ for (i = 0; i < l; ++i) { errbacks[i](event, error); }
+ return;
+ }
+ setTimeout(function () {
+ error.message = event + " listener threw error: " + error.message;
+ throw error;
+ }, 0);
+ }
+
+ function assertFunction(fn) {
+ if (typeof fn !== "function") {
+ throw new TypeError("Listener is not function");
+ }
+ return fn;
+ }
+
+ function supervisors(object) {
+ if (!object.supervisors) { object.supervisors = []; }
+ return object.supervisors;
+ }
+
+ function listeners(object, event) {
+ if (!object.listeners) { object.listeners = {}; }
+ if (event && !object.listeners[event]) { object.listeners[event] = []; }
+ return event ? object.listeners[event] : object.listeners;
+ }
+
+ function errbacks(object) {
+ if (!object.errbacks) { object.errbacks = []; }
+ return object.errbacks;
+ }
+
+ /**
+ * @signature var emitter = bane.createEmitter([object]);
+ *
+ * Create a new event emitter. If an object is passed, it will be modified
+ * by adding the event emitter methods (see below).
+ */
+ function createEventEmitter(object) {
+ object = object || {};
+
+ function notifyListener(event, listener, args) {
+ try {
+ listener.listener.apply(listener.thisp || object, args);
+ } catch (e) {
+ handleError(event, e, errbacks(object));
+ }
+ }
+
+ object.on = function (event, listener, thisp) {
+ if (typeof event === "function") {
+ return supervisors(this).push({
+ listener: event,
+ thisp: listener
+ });
+ }
+ listeners(this, event).push({
+ listener: assertFunction(listener),
+ thisp: thisp
+ });
+ };
+
+ object.off = function (event, listener) {
+ var fns, events, i, l;
+ if (!event) {
+ fns = supervisors(this);
+ fns.splice(0, fns.length);
+
+ events = listeners(this);
+ for (i in events) {
+ if (events.hasOwnProperty(i)) {
+ fns = listeners(this, i);
+ fns.splice(0, fns.length);
+ }
+ }
+
+ fns = errbacks(this);
+ fns.splice(0, fns.length);
+
+ return;
+ }
+ if (typeof event === "function") {
+ fns = supervisors(this);
+ listener = event;
+ } else {
+ fns = listeners(this, event);
+ }
+ if (!listener) {
+ fns.splice(0, fns.length);
+ return;
+ }
+ for (i = 0, l = fns.length; i < l; ++i) {
+ if (fns[i].listener === listener) {
+ fns.splice(i, 1);
+ return;
+ }
+ }
+ };
+
+ object.once = function (event, listener, thisp) {
+ var wrapper = function () {
+ object.off(event, wrapper);
+ listener.apply(this, arguments);
+ };
+
+ object.on(event, wrapper, thisp);
+ };
+
+ object.bind = function (object, events) {
+ var prop, i, l;
+ if (!events) {
+ for (prop in object) {
+ if (typeof object[prop] === "function") {
+ this.on(prop, object[prop], object);
+ }
+ }
+ } else {
+ for (i = 0, l = events.length; i < l; ++i) {
+ if (typeof object[events[i]] === "function") {
+ this.on(events[i], object[events[i]], object);
+ } else {
+ throw new Error("No such method " + events[i]);
+ }
+ }
+ }
+ return object;
+ };
+
+ object.emit = function (event) {
+ var toNotify = supervisors(this);
+ var args = slice.call(arguments), i, l;
+
+ for (i = 0, l = toNotify.length; i < l; ++i) {
+ notifyListener(event, toNotify[i], args);
+ }
+
+ toNotify = listeners(this, event).slice()
+ args = slice.call(arguments, 1);
+ for (i = 0, l = toNotify.length; i < l; ++i) {
+ notifyListener(event, toNotify[i], args);
+ }
+ };
+
+ object.errback = function (listener) {
+ if (!this.errbacks) { this.errbacks = []; }
+ this.errbacks.push(assertFunction(listener));
+ };
+
+ return object;
+ }
+
+ return { createEventEmitter: createEventEmitter };
+});
+
+/** @license MIT License (c) copyright B Cavalier & J Hann */
+
+/**
+ * A lightweight CommonJS Promises/A and when() implementation
+ * when is part of the cujo.js family of libraries (http://cujojs.com/)
+ *
+ * Licensed under the MIT License at:
+ * http://www.opensource.org/licenses/mit-license.php
+ *
+ * @version 1.6.1
+ */
+
+(function(define) { 'use strict';
+define(['module'], function () {
+ var reduceArray, slice, undef;
+
+ //
+ // Public API
+ //
+
+ when.defer = defer; // Create a deferred
+ when.resolve = resolve; // Create a resolved promise
+ when.reject = reject; // Create a rejected promise
+
+ when.join = join; // Join 2 or more promises
+
+ when.all = all; // Resolve a list of promises
+ when.some = some; // Resolve a sub-set of promises
+ when.any = any; // Resolve one promise in a list
+
+ when.map = map; // Array.map() for promises
+ when.reduce = reduce; // Array.reduce() for promises
+
+ when.chain = chain; // Make a promise trigger another resolver
+
+ when.isPromise = isPromise; // Determine if a thing is a promise
+
+ /**
+ * Register an observer for a promise or immediate value.
+ * @function
+ * @name when
+ * @namespace
+ *
+ * @param promiseOrValue {*}
+ * @param {Function} [callback] callback to be called when promiseOrValue is
+ * successfully fulfilled. If promiseOrValue is an immediate value, callback
+ * will be invoked immediately.
+ * @param {Function} [errback] callback to be called when promiseOrValue is
+ * rejected.
+ * @param {Function} [progressHandler] callback to be called when progress updates
+ * are issued for promiseOrValue.
+ * @returns {Promise} a new {@link Promise} that will complete with the return
+ * value of callback or errback or the completion value of promiseOrValue if
+ * callback and/or errback is not supplied.
+ */
+ function when(promiseOrValue, callback, errback, progressHandler) {
+ // Get a trusted promise for the input promiseOrValue, and then
+ // register promise handlers
+ return resolve(promiseOrValue).then(callback, errback, progressHandler);
+ }
+
+ /**
+ * Returns promiseOrValue if promiseOrValue is a {@link Promise}, a new Promise if
+ * promiseOrValue is a foreign promise, or a new, already-fulfilled {@link Promise}
+ * whose value is promiseOrValue if promiseOrValue is an immediate value.
+ * @memberOf when
+ *
+ * @param promiseOrValue {*}
+ * @returns Guaranteed to return a trusted Promise. If promiseOrValue is a when.js {@link Promise}
+ * returns promiseOrValue, otherwise, returns a new, already-resolved, when.js {@link Promise}
+ * whose resolution value is:
+ * * the resolution value of promiseOrValue if it's a foreign promise, or
+ * * promiseOrValue if it's a value
+ */
+ function resolve(promiseOrValue) {
+ var promise, deferred;
+
+ if(promiseOrValue instanceof Promise) {
+ // It's a when.js promise, so we trust it
+ promise = promiseOrValue;
+
+ } else {
+ // It's not a when.js promise. See if it's a foreign promise or a value.
+
+ // Some promises, particularly Q promises, provide a valueOf method that
+ // attempts to synchronously return the fulfilled value of the promise, or
+ // returns the unresolved promise itself. Attempting to break a fulfillment
+ // value out of a promise appears to be necessary to break cycles between
+ // Q and When attempting to coerce each-other's promises in an infinite loop.
+ // For promises that do not implement "valueOf", the Object#valueOf is harmless.
+ // See: https://github.com/kriskowal/q/issues/106
+ // IMPORTANT: Must check for a promise here, since valueOf breaks other things
+ // like Date.
+ if (isPromise(promiseOrValue) && typeof promiseOrValue.valueOf === 'function') {
+ promiseOrValue = promiseOrValue.valueOf();
+ }
+
+ if(isPromise(promiseOrValue)) {
+ // It looks like a thenable, but we don't know where it came from,
+ // so we don't trust its implementation entirely. Introduce a trusted
+ // middleman when.js promise
+ deferred = defer();
+
+ // IMPORTANT: This is the only place when.js should ever call .then() on
+ // an untrusted promise.
+ promiseOrValue.then(deferred.resolve, deferred.reject, deferred.progress);
+ promise = deferred.promise;
+
+ } else {
+ // It's a value, not a promise. Create a resolved promise for it.
+ promise = fulfilled(promiseOrValue);
+ }
+ }
+
+ return promise;
+ }
+
+ /**
+ * Returns a rejected promise for the supplied promiseOrValue. If
+ * promiseOrValue is a value, it will be the rejection value of the
+ * returned promise. If promiseOrValue is a promise, its
+ * completion value will be the rejected value of the returned promise
+ * @memberOf when
+ *
+ * @param promiseOrValue {*} the rejected value of the returned {@link Promise}
+ * @return {Promise} rejected {@link Promise}
+ */
+ function reject(promiseOrValue) {
+ return when(promiseOrValue, function(value) {
+ return rejected(value);
+ });
+ }
+
+ /**
+ * Trusted Promise constructor. A Promise created from this constructor is
+ * a trusted when.js promise. Any other duck-typed promise is considered
+ * untrusted.
+ * @constructor
+ * @name Promise
+ */
+ function Promise(then) {
+ this.then = then;
+ }
+
+ Promise.prototype = {
+ /**
+ * Register a callback that will be called when a promise is
+ * resolved or rejected. Optionally also register a progress handler.
+ * Shortcut for .then(alwaysback, alwaysback, progback)
+ * @memberOf Promise
+ * @param alwaysback {Function}
+ * @param progback {Function}
+ * @return {Promise}
+ */
+ always: function(alwaysback, progback) {
+ return this.then(alwaysback, alwaysback, progback);
+ },
+
+ /**
+ * Register a rejection handler. Shortcut for .then(null, errback)
+ * @memberOf Promise
+ * @param errback {Function}
+ * @return {Promise}
+ */
+ otherwise: function(errback) {
+ return this.then(undef, errback);
+ }
+ };
+
+ /**
+ * Create an already-resolved promise for the supplied value
+ * @private
+ *
+ * @param value anything
+ * @return {Promise}
+ */
+ function fulfilled(value) {
+ var p = new Promise(function(callback) {
+ try {
+ return resolve(callback ? callback(value) : value);
+ } catch(e) {
+ return rejected(e);
+ }
+ });
+
+ return p;
+ }
+
+ /**
+ * Create an already-rejected {@link Promise} with the supplied
+ * rejection reason.
+ * @private
+ *
+ * @param reason rejection reason
+ * @return {Promise}
+ */
+ function rejected(reason) {
+ var p = new Promise(function(callback, errback) {
+ try {
+ return errback ? resolve(errback(reason)) : rejected(reason);
+ } catch(e) {
+ return rejected(e);
+ }
+ });
+
+ return p;
+ }
+
+ /**
+ * Creates a new, Deferred with fully isolated resolver and promise parts,
+ * either or both of which may be given out safely to consumers.
+ * The Deferred itself has the full API: resolve, reject, progress, and
+ * then. The resolver has resolve, reject, and progress. The promise
+ * only has then.
+ * @memberOf when
+ * @function
+ *
+ * @return {Deferred}
+ */
+ function defer() {
+ var deferred, promise, handlers, progressHandlers,
+ _then, _progress, _resolve;
+
+ /**
+ * The promise for the new deferred
+ * @type {Promise}
+ */
+ promise = new Promise(then);
+
+ /**
+ * The full Deferred object, with {@link Promise} and {@link Resolver} parts
+ * @class Deferred
+ * @name Deferred
+ */
+ deferred = {
+ then: then,
+ resolve: promiseResolve,
+ reject: promiseReject,
+ // TODO: Consider renaming progress() to notify()
+ progress: promiseProgress,
+
+ promise: promise,
+
+ resolver: {
+ resolve: promiseResolve,
+ reject: promiseReject,
+ progress: promiseProgress
+ }
+ };
+
+ handlers = [];
+ progressHandlers = [];
+
+ /**
+ * Pre-resolution then() that adds the supplied callback, errback, and progback
+ * functions to the registered listeners
+ * @private
+ *
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @throws {Error} if any argument is not null, undefined, or a Function
+ */
+ _then = function(callback, errback, progback) {
+ var deferred, progressHandler;
+
+ deferred = defer();
+ progressHandler = progback
+ ? function(update) {
+ try {
+ // Allow progress handler to transform progress event
+ deferred.progress(progback(update));
+ } catch(e) {
+ // Use caught value as progress
+ deferred.progress(e);
+ }
+ }
+ : deferred.progress;
+
+ handlers.push(function(promise) {
+ promise.then(callback, errback)
+ .then(deferred.resolve, deferred.reject, progressHandler);
+ });
+
+ progressHandlers.push(progressHandler);
+
+ return deferred.promise;
+ };
+
+ /**
+ * Issue a progress event, notifying all progress listeners
+ * @private
+ * @param update {*} progress event payload to pass to all listeners
+ */
+ _progress = function(update) {
+ processQueue(progressHandlers, update);
+ return update;
+ };
+
+ /**
+ * Transition from pre-resolution state to post-resolution state, notifying
+ * all listeners of the resolution or rejection
+ * @private
+ * @param completed {Promise} the completed value of this deferred
+ */
+ _resolve = function(completed) {
+ completed = resolve(completed);
+
+ // Replace _then with one that directly notifies with the result.
+ _then = completed.then;
+ // Replace _resolve so that this Deferred can only be completed once
+ _resolve = resolve;
+ // Make _progress a noop, to disallow progress for the resolved promise.
+ _progress = noop;
+
+ // Notify handlers
+ processQueue(handlers, completed);
+
+ // Free progressHandlers array since we'll never issue progress events
+ progressHandlers = handlers = undef;
+
+ return completed;
+ };
+
+ return deferred;
+
+ /**
+ * Wrapper to allow _then to be replaced safely
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @return {Promise} new Promise
+ * @throws {Error} if any argument is not null, undefined, or a Function
+ */
+ function then(callback, errback, progback) {
+ return _then(callback, errback, progback);
+ }
+
+ /**
+ * Wrapper to allow _resolve to be replaced
+ */
+ function promiseResolve(val) {
+ return _resolve(val);
+ }
+
+ /**
+ * Wrapper to allow _resolve to be replaced
+ */
+ function promiseReject(err) {
+ return _resolve(rejected(err));
+ }
+
+ /**
+ * Wrapper to allow _progress to be replaced
+ * @param {*} update progress update
+ */
+ function promiseProgress(update) {
+ return _progress(update);
+ }
+ }
+
+ /**
+ * Determines if promiseOrValue is a promise or not. Uses the feature
+ * test from http://wiki.commonjs.org/wiki/Promises/A to determine if
+ * promiseOrValue is a promise.
+ *
+ * @param {*} promiseOrValue anything
+ * @returns {Boolean} true if promiseOrValue is a {@link Promise}
+ */
+ function isPromise(promiseOrValue) {
+ return promiseOrValue && typeof promiseOrValue.then === 'function';
+ }
+
+ /**
+ * Initiates a competitive race, returning a promise that will resolve when
+ * howMany of the supplied promisesOrValues have resolved, or will reject when
+ * it becomes impossible for howMany to resolve, for example, when
+ * (promisesOrValues.length - howMany) + 1 input promises reject.
+ * @memberOf when
+ *
+ * @param promisesOrValues {Array} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param howMany {Number} number of promisesOrValues to resolve
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @returns {Promise} promise that will resolve to an array of howMany values that
+ * resolved first, or will reject with an array of (promisesOrValues.length - howMany) + 1
+ * rejection reasons.
+ */
+ function some(promisesOrValues, howMany, callback, errback, progback) {
+
+ checkCallbacks(2, arguments);
+
+ return when(promisesOrValues, function(promisesOrValues) {
+
+ var toResolve, toReject, values, reasons, deferred, fulfillOne, rejectOne, progress, len, i;
+
+ len = promisesOrValues.length >>> 0;
+
+ toResolve = Math.max(0, Math.min(howMany, len));
+ values = [];
+
+ toReject = (len - toResolve) + 1;
+ reasons = [];
+
+ deferred = defer();
+
+ // No items in the input, resolve immediately
+ if (!toResolve) {
+ deferred.resolve(values);
+
+ } else {
+ progress = deferred.progress;
+
+ rejectOne = function(reason) {
+ reasons.push(reason);
+ if(!--toReject) {
+ fulfillOne = rejectOne = noop;
+ deferred.reject(reasons);
+ }
+ };
+
+ fulfillOne = function(val) {
+ // This orders the values based on promise resolution order
+ // Another strategy would be to use the original position of
+ // the corresponding promise.
+ values.push(val);
+
+ if (!--toResolve) {
+ fulfillOne = rejectOne = noop;
+ deferred.resolve(values);
+ }
+ };
+
+ for(i = 0; i < len; ++i) {
+ if(i in promisesOrValues) {
+ when(promisesOrValues[i], fulfiller, rejecter, progress);
+ }
+ }
+ }
+
+ return deferred.then(callback, errback, progback);
+
+ function rejecter(reason) {
+ rejectOne(reason);
+ }
+
+ function fulfiller(val) {
+ fulfillOne(val);
+ }
+
+ });
+ }
+
+ /**
+ * Initiates a competitive race, returning a promise that will resolve when
+ * any one of the supplied promisesOrValues has resolved or will reject when
+ * *all* promisesOrValues have rejected.
+ * @memberOf when
+ *
+ * @param promisesOrValues {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param [callback] {Function} resolution handler
+ * @param [errback] {Function} rejection handler
+ * @param [progback] {Function} progress handler
+ * @returns {Promise} promise that will resolve to the value that resolved first, or
+ * will reject with an array of all rejected inputs.
+ */
+ function any(promisesOrValues, callback, errback, progback) {
+
+ function unwrapSingleResult(val) {
+ return callback ? callback(val[0]) : val[0];
+ }
+
+ return some(promisesOrValues, 1, unwrapSingleResult, errback, progback);
+ }
+
+ /**
+ * Return a promise that will resolve only once all the supplied promisesOrValues
+ * have resolved. The resolution value of the returned promise will be an array
+ * containing the resolution values of each of the promisesOrValues.
+ * @memberOf when
+ *
+ * @param promisesOrValues {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param [callback] {Function}
+ * @param [errback] {Function}
+ * @param [progressHandler] {Function}
+ * @returns {Promise}
+ */
+ function all(promisesOrValues, callback, errback, progressHandler) {
+ checkCallbacks(1, arguments);
+ return map(promisesOrValues, identity).then(callback, errback, progressHandler);
+ }
+
+ /**
+ * Joins multiple promises into a single returned promise.
+ * @memberOf when
+ * @param {Promise|*} [...promises] two or more promises to join
+ * @return {Promise} a promise that will fulfill when *all* the input promises
+ * have fulfilled, or will reject when *any one* of the input promises rejects.
+ */
+ function join(/* ...promises */) {
+ return map(arguments, identity);
+ }
+
+ /**
+ * Traditional map function, similar to `Array.prototype.map()`, but allows
+ * input to contain {@link Promise}s and/or values, and mapFunc may return
+ * either a value or a {@link Promise}
+ *
+ * @memberOf when
+ *
+ * @param promise {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values
+ * @param mapFunc {Function} mapping function mapFunc(value) which may return
+ * either a {@link Promise} or value
+ * @returns {Promise} a {@link Promise} that will resolve to an array containing
+ * the mapped output values.
+ */
+ function map(promise, mapFunc) {
+ return when(promise, function(array) {
+ var results, len, toResolve, resolve, reject, i, d;
+
+ // Since we know the resulting length, we can preallocate the results
+ // array to avoid array expansions.
+ toResolve = len = array.length >>> 0;
+ results = [];
+ d = defer();
+
+ if(!toResolve) {
+ d.resolve(results);
+ } else {
+
+ reject = d.reject;
+ resolve = function resolveOne(item, i) {
+ when(item, mapFunc).then(function(mapped) {
+ results[i] = mapped;
+
+ if(!--toResolve) {
+ d.resolve(results);
+ }
+ }, reject);
+ };
+
+ // Since mapFunc may be async, get all invocations of it into flight
+ for(i = 0; i < len; i++) {
+ if(i in array) {
+ resolve(array[i], i);
+ } else {
+ --toResolve;
+ }
+ }
+
+ }
+
+ return d.promise;
+
+ });
+ }
+
+ /**
+ * Traditional reduce function, similar to `Array.prototype.reduce()`, but
+ * input may contain {@link Promise}s and/or values, and reduceFunc
+ * may return either a value or a {@link Promise}, *and* initialValue may
+ * be a {@link Promise} for the starting value.
+ * @memberOf when
+ *
+ * @param promise {Array|Promise} array of anything, may contain a mix
+ * of {@link Promise}s and values. May also be a {@link Promise} for
+ * an array.
+ * @param reduceFunc {Function} reduce function reduce(currentValue, nextValue, index, total),
+ * where total is the total number of items being reduced, and will be the same
+ * in each call to reduceFunc.
+ * @param [initialValue] {*} starting value, or a {@link Promise} for the starting value
+ * @returns {Promise} that will resolve to the final reduced value
+ */
+ function reduce(promise, reduceFunc /*, initialValue */) {
+ var args = slice.call(arguments, 1);
+
+ return when(promise, function(array) {
+ var total;
+
+ total = array.length;
+
+ // Wrap the supplied reduceFunc with one that handles promises and then
+ // delegates to the supplied.
+ args[0] = function (current, val, i) {
+ return when(current, function (c) {
+ return when(val, function (value) {
+ return reduceFunc(c, value, i, total);
+ });
+ });
+ };
+
+ return reduceArray.apply(array, args);
+ });
+ }
+
+ /**
+ * Ensure that resolution of promiseOrValue will complete resolver with the completion
+ * value of promiseOrValue, or instead with resolveValue if it is provided.
+ * @memberOf when
+ *
+ * @param promiseOrValue
+ * @param resolver {Resolver}
+ * @param [resolveValue] anything
+ * @returns {Promise}
+ */
+ function chain(promiseOrValue, resolver, resolveValue) {
+ var useResolveValue = arguments.length > 2;
+
+ return when(promiseOrValue,
+ function(val) {
+ return resolver.resolve(useResolveValue ? resolveValue : val);
+ },
+ resolver.reject,
+ resolver.progress
+ );
+ }
+
+ //
+ // Utility functions
+ //
+
+ function processQueue(queue, value) {
+ var handler, i = 0;
+
+ while (handler = queue[i++]) {
+ handler(value);
+ }
+ }
+
+ /**
+ * Helper that checks arrayOfCallbacks to ensure that each element is either
+ * a function, or null or undefined.
+ * @private
+ *
+ * @param arrayOfCallbacks {Array} array to check
+ * @throws {Error} if any element of arrayOfCallbacks is something other than
+ * a Functions, null, or undefined.
+ */
+ function checkCallbacks(start, arrayOfCallbacks) {
+ var arg, i = arrayOfCallbacks.length;
+
+ while(i > start) {
+ arg = arrayOfCallbacks[--i];
+
+ if (arg != null && typeof arg != 'function') {
+ throw new Error('arg '+i+' must be a function');
+ }
+ }
+ }
+
+ /**
+ * No-Op function used in method replacement
+ * @private
+ */
+ function noop() {}
+
+ slice = [].slice;
+
+ // ES5 reduce implementation if native not available
+ // See: http://es5.github.com/#x15.4.4.21 as there are many
+ // specifics and edge cases.
+ reduceArray = [].reduce ||
+ function(reduceFunc /*, initialValue */) {
+ /*jshint maxcomplexity: 7*/
+
+ // ES5 dictates that reduce.length === 1
+
+ // This implementation deviates from ES5 spec in the following ways:
+ // 1. It does not check if reduceFunc is a Callable
+
+ var arr, args, reduced, len, i;
+
+ i = 0;
+ // This generates a jshint warning, despite being valid
+ // "Missing 'new' prefix when invoking a constructor."
+ // See https://github.com/jshint/jshint/issues/392
+ arr = Object(this);
+ len = arr.length >>> 0;
+ args = arguments;
+
+ // If no initialValue, use first item of array (we know length !== 0 here)
+ // and adjust i to start at second item
+ if(args.length <= 1) {
+ // Skip to the first real element in the array
+ for(;;) {
+ if(i in arr) {
+ reduced = arr[i++];
+ break;
+ }
+
+ // If we reached the end of the array without finding any real
+ // elements, it's a TypeError
+ if(++i >= len) {
+ throw new TypeError();
+ }
+ }
+ } else {
+ // If initialValue provided, use it
+ reduced = args[1];
+ }
+
+ // Do the actual reduce
+ for(;i < len; ++i) {
+ // Skip holes
+ if(i in arr) {
+ reduced = reduceFunc(reduced, arr[i], i, arr);
+ }
+ }
+
+ return reduced;
+ };
+
+ function identity(x) {
+ return x;
+ }
+
+ return when;
+});
+})(typeof define == 'function' && define.amd
+ ? define
+ : function (deps, factory) { typeof exports === 'object'
+ ? (module.exports = factory())
+ : (this.when = factory());
+ }
+ // Boilerplate for AMD, Node, and browser global
+);
+
+/*global bane:false, when:false*/
+
+function Mopidy(settings) {
+ this._settings = this._configure(settings || {});
+ this._console = this._getConsole();
+
+ this._backoffDelay = this._settings.backoffDelayMin;
+ this._pendingRequests = {};
+ this._webSocket = null;
+
+ bane.createEventEmitter(this);
+ this._delegateEvents();
+
+ if (this._settings.autoConnect) {
+ this._connect();
+ }
+}
+
+Mopidy.prototype._configure = function (settings) {
+ settings.webSocketUrl = settings.webSocketUrl ||
+ "ws://" + document.location.host + "/mopidy/ws/";
+
+ if (settings.autoConnect !== false) {
+ settings.autoConnect = true;
+ }
+
+ settings.backoffDelayMin = settings.backoffDelayMin || 1000;
+ settings.backoffDelayMax = settings.backoffDelayMax || 64000;
+
+ return settings;
+};
+
+Mopidy.prototype._getConsole = function () {
+ var console = window.console || {};
+
+ console.log = console.log || function () {};
+ console.warn = console.warn || function () {};
+ console.error = console.error || function () {};
+
+ return console;
+};
+
+Mopidy.prototype._delegateEvents = function () {
+ // Remove existing event handlers
+ this.off("websocket:close");
+ this.off("websocket:error");
+ this.off("websocket:incomingMessage");
+ this.off("websocket:open");
+ this.off("state:offline");
+
+ // Register basic set of event handlers
+ this.on("websocket:close", this._cleanup);
+ this.on("websocket:error", this._handleWebSocketError);
+ this.on("websocket:incomingMessage", this._handleMessage);
+ this.on("websocket:open", this._resetBackoffDelay);
+ this.on("websocket:open", this._getApiSpec);
+ this.on("state:offline", this._reconnect);
+};
+
+Mopidy.prototype._connect = function () {
+ if (this._webSocket) {
+ if (this._webSocket.readyState === WebSocket.OPEN) {
+ return;
+ } else {
+ this._webSocket.close();
+ }
+ }
+
+ this._webSocket = this._settings.webSocket ||
+ new WebSocket(this._settings.webSocketUrl);
+
+ this._webSocket.onclose = function (close) {
+ this.emit("websocket:close", close);
+ }.bind(this);
+
+ this._webSocket.onerror = function (error) {
+ this.emit("websocket:error", error);
+ }.bind(this);
+
+ this._webSocket.onopen = function () {
+ this.emit("websocket:open");
+ }.bind(this);
+
+ this._webSocket.onmessage = function (message) {
+ this.emit("websocket:incomingMessage", message);
+ }.bind(this);
+};
+
+Mopidy.prototype._cleanup = function (closeEvent) {
+ Object.keys(this._pendingRequests).forEach(function (requestId) {
+ var resolver = this._pendingRequests[requestId];
+ delete this._pendingRequests[requestId];
+ resolver.reject({
+ message: "WebSocket closed",
+ closeEvent: closeEvent
+ });
+ }.bind(this));
+
+ this.emit("state:offline");
+};
+
+Mopidy.prototype._reconnect = function () {
+ this.emit("reconnectionPending", {
+ timeToAttempt: this._backoffDelay
+ });
+
+ setTimeout(function () {
+ this.emit("reconnecting");
+ this._connect();
+ }.bind(this), this._backoffDelay);
+
+ this._backoffDelay = this._backoffDelay * 2;
+ if (this._backoffDelay > this._settings.backoffDelayMax) {
+ this._backoffDelay = this._settings.backoffDelayMax;
+ }
+};
+
+Mopidy.prototype._resetBackoffDelay = function () {
+ this._backoffDelay = this._settings.backoffDelayMin;
+};
+
+Mopidy.prototype.close = function () {
+ this.off("state:offline", this._reconnect);
+ this._webSocket.close();
+};
+
+Mopidy.prototype._handleWebSocketError = function (error) {
+ this._console.warn("WebSocket error:", error.stack || error);
+};
+
+Mopidy.prototype._send = function (message) {
+ var deferred = when.defer();
+
+ switch (this._webSocket.readyState) {
+ case WebSocket.CONNECTING:
+ deferred.resolver.reject({
+ message: "WebSocket is still connecting"
+ });
+ break;
+ case WebSocket.CLOSING:
+ deferred.resolver.reject({
+ message: "WebSocket is closing"
+ });
+ break;
+ case WebSocket.CLOSED:
+ deferred.resolver.reject({
+ message: "WebSocket is closed"
+ });
+ break;
+ default:
+ message.jsonrpc = "2.0";
+ message.id = this._nextRequestId();
+ this._pendingRequests[message.id] = deferred.resolver;
+ this._webSocket.send(JSON.stringify(message));
+ this.emit("websocket:outgoingMessage", message);
+ }
+
+ return deferred.promise;
+};
+
+Mopidy.prototype._nextRequestId = (function () {
+ var lastUsed = -1;
+ return function () {
+ lastUsed += 1;
+ return lastUsed;
+ };
+}());
+
+Mopidy.prototype._handleMessage = function (message) {
+ try {
+ var data = JSON.parse(message.data);
+ if (data.hasOwnProperty("id")) {
+ this._handleResponse(data);
+ } else if (data.hasOwnProperty("event")) {
+ this._handleEvent(data);
+ } else {
+ this._console.warn(
+ "Unknown message type received. Message was: " +
+ message.data);
+ }
+ } catch (error) {
+ if (error instanceof SyntaxError) {
+ this._console.warn(
+ "WebSocket message parsing failed. Message was: " +
+ message.data);
+ } else {
+ throw error;
+ }
+ }
+};
+
+Mopidy.prototype._handleResponse = function (responseMessage) {
+ if (!this._pendingRequests.hasOwnProperty(responseMessage.id)) {
+ this._console.warn(
+ "Unexpected response received. Message was:", responseMessage);
+ return;
+ }
+
+ var resolver = this._pendingRequests[responseMessage.id];
+ delete this._pendingRequests[responseMessage.id];
+
+ if (responseMessage.hasOwnProperty("result")) {
+ resolver.resolve(responseMessage.result);
+ } else if (responseMessage.hasOwnProperty("error")) {
+ resolver.reject(responseMessage.error);
+ this._console.warn("Server returned error:", responseMessage.error);
+ } else {
+ resolver.reject({
+ message: "Response without 'result' or 'error' received",
+ data: {response: responseMessage}
+ });
+ this._console.warn(
+ "Response without 'result' or 'error' received. Message was:",
+ responseMessage);
+ }
+};
+
+Mopidy.prototype._handleEvent = function (eventMessage) {
+ var type = eventMessage.event;
+ var data = eventMessage;
+ delete data.event;
+
+ this.emit("event:" + this._snakeToCamel(type), data);
+};
+
+Mopidy.prototype._getApiSpec = function () {
+ this._send({method: "core.describe"})
+ .then(this._createApi.bind(this), this._handleWebSocketError)
+ .then(null, this._handleWebSocketError);
+};
+
+Mopidy.prototype._createApi = function (methods) {
+ var caller = function (method) {
+ return function () {
+ var params = Array.prototype.slice.call(arguments);
+ return this._send({
+ method: method,
+ params: params
+ });
+ }.bind(this);
+ }.bind(this);
+
+ var getPath = function (fullName) {
+ var path = fullName.split(".");
+ if (path.length >= 1 && path[0] === "core") {
+ path = path.slice(1);
+ }
+ return path;
+ };
+
+ var createObjects = function (objPath) {
+ var parentObj = this;
+ objPath.forEach(function (objName) {
+ objName = this._snakeToCamel(objName);
+ parentObj[objName] = parentObj[objName] || {};
+ parentObj = parentObj[objName];
+ }.bind(this));
+ return parentObj;
+ }.bind(this);
+
+ var createMethod = function (fullMethodName) {
+ var methodPath = getPath(fullMethodName);
+ var methodName = this._snakeToCamel(methodPath.slice(-1)[0]);
+ var object = createObjects(methodPath.slice(0, -1));
+ object[methodName] = caller(fullMethodName);
+ object[methodName].description = methods[fullMethodName].description;
+ object[methodName].params = methods[fullMethodName].params;
+ }.bind(this);
+
+ Object.keys(methods).forEach(createMethod);
+ this.emit("state:online");
+};
+
+Mopidy.prototype._snakeToCamel = function (name) {
+ return name.replace(/(_[a-z])/g, function (match) {
+ return match.toUpperCase().replace("_", "");
+ });
+};
diff --git a/mopidy/frontends/http/data/mopidy.min.js b/mopidy/frontends/http/data/mopidy.min.js
new file mode 100644
index 00000000..42d34319
--- /dev/null
+++ b/mopidy/frontends/http/data/mopidy.min.js
@@ -0,0 +1,5 @@
+/*! Mopidy.js - built 2012-12-04
+ * http://www.mopidy.com/
+ * Copyright (c) 2012 Stein Magnus Jodal and contributors
+ * Licensed under the Apache License, Version 2.0 */
+function Mopidy(e){this._settings=this._configure(e||{}),this._console=this._getConsole(),this._backoffDelay=this._settings.backoffDelayMin,this._pendingRequests={},this._webSocket=null,bane.createEventEmitter(this),this._delegateEvents(),this._settings.autoConnect&&this._connect()}(typeof define=="function"&&define.amd&&function(e){define(e)}||typeof module=="object"&&function(e){module.exports=e()}||function(e){this.bane=e()})(function(){"use strict";function t(e,t,n){var r,i=n.length;if(i>0){for(r=0;r>>0,o=Math.max(0,Math.min(t,v)),a=[],u=v-o+1,l=[],c=f();if(!o)c.resolve(a);else{d=c.progress,p=function(e){l.push(e),--u||(h=p=w,c.reject(l))},h=function(e){a.push(e),--o||(h=p=w,c.resolve(a))};for(m=0;m>>0,n=[],l=f();if(!s)l.resolve(n);else{u=l.reject,o=function(i,o){r(i,t).then(function(e){n[o]=e,--s||l.resolve(n)},u)};for(a=0;a2;return r(e,function(e){return t.resolve(i?n:e)},t.reject,t.progress)}function y(e,t){var n,r=0;while(n=e[r++])n(t)}function b(e,t){var n,r=t.length;while(r>e){n=t[--r];if(n!=null&&typeof n!="function")throw new Error("arg "+r+" must be a function")}}function w(){}function E(e){return e}var e,t,n;return r.defer=f,r.resolve=i,r.reject=s,r.join=d,r.all=p,r.some=c,r.any=h,r.map=v,r.reduce=m,r.chain=g,r.isPromise=l,o.prototype={always:function(e,t){return this.then(e,e,t)},otherwise:function(e){return this.then(n,e)}},t=[].slice,e=[].reduce||function(e){var t,n,r,i,s;s=0,t=Object(this),i=t.length>>>0,n=arguments;if(n.length<=1)for(;;){if(s in t){r=t[s++];break}if(++s>=i)throw new TypeError}else r=n[1];for(;sthis._settings.backoffDelayMax&&(this._backoffDelay=this._settings.backoffDelayMax)},Mopidy.prototype._resetBackoffDelay=function(){this._backoffDelay=this._settings.backoffDelayMin},Mopidy.prototype.close=function(){this.off("state:offline",this._reconnect),this._webSocket.close()},Mopidy.prototype._handleWebSocketError=function(e){this._console.warn("WebSocket error:",e.stack||e)},Mopidy.prototype._send=function(e){var t=when.defer();switch(this._webSocket.readyState){case WebSocket.CONNECTING:t.resolver.reject({message:"WebSocket is still connecting"});break;case WebSocket.CLOSING:t.resolver.reject({message:"WebSocket is closing"});break;case WebSocket.CLOSED:t.resolver.reject({message:"WebSocket is closed"});break;default:e.jsonrpc="2.0",e.id=this._nextRequestId(),this._pendingRequests[e.id]=t.resolver,this._webSocket.send(JSON.stringify(e)),this.emit("websocket:outgoingMessage",e)}return t.promise},Mopidy.prototype._nextRequestId=function(){var e=-1;return function(){return e+=1,e}}(),Mopidy.prototype._handleMessage=function(e){try{var t=JSON.parse(e.data);t.hasOwnProperty("id")?this._handleResponse(t):t.hasOwnProperty("event")?this._handleEvent(t):this._console.warn("Unknown message type received. Message was: "+e.data)}catch(n){if(!(n instanceof SyntaxError))throw n;this._console.warn("WebSocket message parsing failed. Message was: "+e.data)}},Mopidy.prototype._handleResponse=function(e){if(!this._pendingRequests.hasOwnProperty(e.id)){this._console.warn("Unexpected response received. Message was:",e);return}var t=this._pendingRequests[e.id];delete this._pendingRequests[e.id],e.hasOwnProperty("result")?t.resolve(e.result):e.hasOwnProperty("error")?(t.reject(e.error),this._console.warn("Server returned error:",e.error)):(t.reject({message:"Response without 'result' or 'error' received",data:{response:e}}),this._console.warn("Response without 'result' or 'error' received. Message was:",e))},Mopidy.prototype._handleEvent=function(e){var t=e.event,n=e;delete n.event,this.emit("event:"+this._snakeToCamel(t),n)},Mopidy.prototype._getApiSpec=function(){this._send({method:"core.describe"}).then(this._createApi.bind(this),this._handleWebSocketError).then(null,this._handleWebSocketError)},Mopidy.prototype._createApi=function(e){var t=function(e){return function(){var t=Array.prototype.slice.call(arguments);return this._send({method:e,params:t})}.bind(this)}.bind(this),n=function(e){var t=e.split(".");return t.length>=1&&t[0]==="core"&&(t=t.slice(1)),t},r=function(e){var t=this;return e.forEach(function(e){e=this._snakeToCamel(e),t[e]=t[e]||{},t=t[e]}.bind(this)),t}.bind(this),i=function(i){var s=n(i),o=this._snakeToCamel(s.slice(-1)[0]),u=r(s.slice(0,-1));u[o]=t(i),u[o].description=e[i].description,u[o].params=e[i].params}.bind(this);Object.keys(e).forEach(i),this.emit("state:online")},Mopidy.prototype._snakeToCamel=function(e){return e.replace(/(_[a-z])/g,function(e){return e.toUpperCase().replace("_","")})};
\ No newline at end of file