Merge pull request #262 from jodal/feature/mopidy.js

Mopidy.js: Mopidy's core API in JavaScript
This commit is contained in:
Thomas Adamcik 2012-12-04 01:52:49 -08:00
commit 4cf1e5ffb8
12 changed files with 3543 additions and 5 deletions

1
.gitignore vendored
View File

@ -11,4 +11,5 @@ coverage.xml
dist/
docs/_build/
mopidy.log*
node_modules/
nosetests.xml

75
js/README.rst Normal file
View File

@ -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 <http://nodejs.org/>`_ 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 <http://busterjs.org/>`_ and `Grunt
<http://gruntjs.com/>`_ 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 <http://phantomjs.org/>`_ 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.

9
js/buster.js Normal file
View File

@ -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"]
};

65
js/grunt.js Normal file
View File

@ -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: ["<banner:meta.banner>", "lib/**/*.js", "src/mopidy.js"],
dest: "<%= dirs.dest %>/mopidy.js"
}
},
min: {
dist: {
src: ["<banner:meta.banner>", "<config:concat.dist.dest>"],
dest: "<%= dirs.dest %>/mopidy.min.js"
}
},
watch: {
files: "<config:lint.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");
};

171
js/lib/bane-0.4.0.js Normal file
View File

@ -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 };
});

731
js/lib/when-1.6.1.js Normal file
View File

@ -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
);

278
js/src/mopidy.js Normal file
View File

@ -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("_", "");
});
};

29
js/test/bind-helper.js Normal file
View File

@ -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;
};
}

669
js/test/mopidy-test.js Normal file
View File

@ -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);
}
}
});

View File

@ -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
<script type="text/javascript" src="/mopidy/mopidy.min.js"></script>
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
<https://github.com/busterjs/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
<http://wiki.commonjs.org/wiki/Promises/A>`_ standard. We use the
implementation known as `when.js <https://github.com/cujojs/when>`_. 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
<script type="text/javascript" src="/mopidy/mopidy.min.js"></script>
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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long