Release v2.3.0

This commit is contained in:
Nick Steel 2016-05-15 23:41:01 +01:00
commit 53ff635f6a
38 changed files with 4958 additions and 2476 deletions

1
.csslintrc Normal file
View File

@ -0,0 +1 @@
--format=compact

12
.eslintrc Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "standard",
"env": {
"jquery": true
},
"rules": {
"indent": [2, 4, {"SwitchCase": 1}],
"no-undef": 0, // TODO: Set this to '2' once Javascript has been modularised.
"no-unused-vars": 0, // TODO: Set this to '2' once Javascript has been modularised.
"camelcase": 1,
}
}

4
.gitignore vendored
View File

@ -8,12 +8,16 @@
MANIFEST
build/
cover/
.karma_coverage/
coverage.xml
dist/
docs/_build/
mopidy.log*
node_modules/
nosetests.xml
npm-debug.log
.project
*.pbxproj
*.egg-info
.cache
.eggs

View File

@ -1,13 +1,29 @@
sudo: false
sudo: required
dist: trusty
language: python
python:
- "2.7_with_system_site_packages"
addons:
apt:
sources:
- mopidy-stable
packages:
- mopidy
env:
- TOX_ENV=py27
# Need to run 'py27' and 'test' together so that we can share coverage reports.
- TOX_ENV=py27,test
- TOX_ENV=flake8
- TOX_ENV=eslint
- TOX_ENV=csslint
- TOX_ENV=tidy
before_install:
- "sudo sed -i '/127.0.1.1/d' /etc/hosts" # Workaround https://github.com/tornadoweb/tornado/issues/1573
- "sudo apt-get update -qq"
install:
- "pip install tox"
@ -16,4 +32,5 @@ script:
- "tox -e $TOX_ENV"
after_success:
- "if [ $TOX_ENV == 'py27' ]; then pip install coveralls; coveralls; fi"
# We just use 'coveralls-lcov' to convert lcov.info to JSON format.
- "if [ $TOX_ENV == 'py27,test' ]; then gem install coveralls-lcov; coveralls-lcov -v -n .karma_coverage/lcov.info > .karma_coverage/lcov.json; pip install coveralls; coveralls --merge=.karma_coverage/lcov.json; fi"

View File

@ -10,27 +10,52 @@ Mopidy-MusicBox-Webclient
:target: https://pypi.python.org/pypi/Mopidy-MusicBox-Webclient/
:alt: Number of PyPI downloads
With Mopidy MusicBox Webclient, you can play your music on your computer (`Rapsberry Pi <http://www.raspberrypi.org/>`_)
and remotely control it using your computer, tablet or phone.
.. image:: https://img.shields.io/travis/pimusicbox/mopidy-musicbox-webclient/develop.svg?style=flat
:target: https://travis-ci.org/pimusicbox/mopidy-musicbox-webclient
:alt: Travis CI build status
This is a responsive webclient especially written for Mopidy, a music server. Responsive, so it works on desktop and
mobile browsers. You can browse, search and play albums, artists, playlists, and it has cover art from Last.fm.
.. image:: https://img.shields.io/coveralls/pimusicbox/mopidy-musicbox-webclient/develop.svg?style=flat
:target: https://coveralls.io/r/pimusicbox/mopidy-musicbox-webclient?branch=develop
:alt: Test coverage
`Mopidy <http://www.mopidy.com/>`_ is a music server which can play music from Spotify, Google Music, SoundCloud, etc.
or from your hard drive.
.. image:: https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat
:target: http://standardjs.com/
:alt: JavaScript Standard Style
If you want to run Mopidy with this webclient on a Raspberry Pi, do yourself a favor and use my custom built SD-image:
`Pi MusicBox <http://www.pimusicbox.com/>`_.
Mopidy MusicBox Webclient (MMW) is a frontend extension and JavaScript-based web client especially written for
`Mopidy <http://www.mopidy.com/>`_.
.. image:: https://github.com/pimusicbox/mopidy-musicbox-webclient/raw/master/screenshots/playlists_desktop.png
Features
========
- Responsive design that works equally well on desktop and mobile browsers.
- Browse content provided by any Mopidy backend extension.
- Add one or more tracks or entire albums to the queue.
- Save the current queue to an easily accessible playlist.
- Search for tracks, albums, or artists from specific backends or all of Mopidy.
- Shows detailed track and album information during playback, with album cover retrieval from Last.fm.
- Seek tracks during playback.
- Support for all of the Mopidy playback controls (consume mode, repeat, shuffle, etc.)
- Deep integration with, and additional features for, the `Pi MusicBox <http://www.pimusicbox.com/>`_.
- Fullscreen mode.
.. image:: https://github.com/pimusicbox/mopidy-musicbox-webclient/raw/develop/screenshots/queue_desktop.png
Dependencies
============
- MMW has been tested on the major browsers (Chrome, IE, Firefox, Safari, iOS). It *may* also work on other browsers
that support websockets, cookies, and JavaScript.
- ``Mopidy`` >= 1.1.0. An extensible music server that plays music from local disk, Spotify, SoundCloud, Google
Play Music, and more.
Installation
============
Install by running::
pip install Mopidy-MusicBox-Webclient
pip install mopidy-musicbox-webclient
Alternatively, clone the repository and run ``sudo python setup.py install`` from within the project directory. e.g. ::
@ -40,11 +65,38 @@ Alternatively, clone the repository and run ``sudo python setup.py install`` fro
$ sudo python setup.py install
Configuration
=============
MMW is shipped with default settings that should work straight out of the box for most users::
[musicbox_webclient]
enabled = true
musicbox = false
websocket_host =
websocket_port =
on_track_click = PLAY_ALL
The following configuration values are available should you wish to customize your installation further:
- ``musicbox_webclient/enabled``: If the MMW extension should be enabled or not. Defaults to ``true``.
- ``musicbox_webclient/musicbox``: Set this to ``true`` if you are connecting to a Mopidy instance running on a
Pi Musicbox. Expands the MMW user interface to include features for rebooting the Pi, changing configuration values
via a web interface, etc.
- ``musicbox_webclient/websocket_host``: Optional setting that can be used to specify the target host for Mopidy websocket connections.
- ``musicbox_webclient/websocket_port``: Optional setting that can be used to specify the target port for Mopidy websocket connections.
- ``musicbox_webclient/on_track_click``: Specifies the default action that should be performed when the user clicks on
a track that is displayed in the Browse pane or as part of Search results. Valid options are: ``PLAY_NOW``,
``PLAY_NEXT``, ``ADD_THIS_BOTTOM``, ``ADD_ALL_BOTTOM``, ``PLAY_ALL`` (default), and ``DYNAMIC`` (repeats last action).
Usage
=====
Point your (modern) browser at Mopidy-MusicBox-Webclient running on your Mopidy server e.g.
http://localhost:6680/musicbox_webclient.
Enter the address of the Mopidy server that you are connecting to in your browser (e.g. http://localhost:6680/musicbox_webclient)
Project resources
@ -58,6 +110,44 @@ Project resources
Changelog
=========
v2.3.0 (UNRELEASED)
-------------------
- Enhance build workflow to include style checks and syntax validation for HTML, CSS, and Javascript.
- Now displays album and artist info when browsing tracks. (Addresses: `#99 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/99>`_).
- Now remembers which backend was searched previously, and automatically selects that backend as the default search target.
(Addresses: `#130 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/130>`_).
- Upgrade Media Progress Timer to version 3.0.0.
- Now retrieves album cover and artist images using MusicBrainzID, if available.
- New configuration parameter ``on_track_click`` can be used to customize the action that is performed when the
user clicks on a track in a list. Valid options are: ``PLAY_NOW``, ``PLAY_NEXT``, ``ADD_THIS_BOTTOM``,
``ADD_ALL_BOTTOM``, ``PLAY_ALL`` (default), and ``DYNAMIC`` (repeats last action).
(Addresses: `#133 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/133>`_).
- Optimized updating of 'now playing' icons in tracklists.
(Addresses: `#184 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/184>`_).
- Optimized rendering of large lists of tracks to make UI more responsive.
- Added 'Folder' FontAwesome icon on the Browse pane for browsing the filesystem.
- New icons for 'PLAY' and 'PLAY_ALL' actions. In general, icons with an empty background will perform an action only
on the selected track, while icons with a filled background will apply the action to all tracks in the list.
- Standardize popup dialog layout convention: Sentence fragments have no punctuation, buttons that confirm a
destructive action go on the left.
**Fixes**
- Don't create Mopidy models manually. (Fixes: `#172 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/172>`_).
- Context menu is now available for all tracks in browse pane. (Fixes: `#126 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/126>`_).
- last.fm artist image lookups should now always return the correct image for similarly named artists.
- Ensure that browsed tracks are always added to the queue using the track URI rather than the track's position in the folder.
(Fixes: `#124 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/124>`_).
- Fixed an issue where searches would be performed as soon as the user switches to the 'Search' pane,
instead of waiting for the 'Search!' button to be clicked.
- Fixed an issue where the last track in an album was not grouped properly with the rest of the results, and would have
a small divider rendered above it. (Fixes: `#196 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/196>`_).
- Replaced JavaScript confirmation prompt on 'Streams' pane with jQuery equivalent.
(Fixes: `#191 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/191>`_).
- Clearing the queue should no longer trigger an album cover image lookup.
(Fixes: `#201 <https://github.com/pimusicbox/mopidy-musicbox-webclient/issues/201>`_).
v2.2.0 (2016-03-01)
-------------------

80
karma.conf.js Normal file
View File

@ -0,0 +1,80 @@
// Karma configuration
module.exports = function (config) {
config.set({
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '',
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['browserify', 'mocha'],
// list of files / patterns to load in the browser
files: [
'mopidy_musicbox_webclient/static/vendors/**/*.js',
'mopidy_musicbox_webclient/static/js/**/*.js',
'tests/**/test_*.js'
],
// list of files to exclude
exclude: [
],
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
'tests/**/test_*.js': [ 'browserify' ],
'mopidy_musicbox_webclient/static/js/**/*.js': ['coverage']
},
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['progress', 'coverage'],
// web server port
port: 9876,
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: false,
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['PhantomJS'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: true,
// Concurrency level
// how many browser should be started simultaneous
concurrency: Infinity,
// add additional browserify configuration properties here
// such as transform and/or debug=true to generate source maps
browserify: {
debug: true,
transform: [
'babelify',
['browserify-istanbul', { instrumenter: require('isparta') }]
]
},
coverageReporter: {
// specify a common output directory
dir: '.karma_coverage/',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text' }
]
}
})
}

View File

@ -7,7 +7,7 @@ from mopidy import config, ext
__version__ = '2.2.0'
class MusicBoxExtension(ext.Extension):
class Extension(ext.Extension):
dist_name = 'Mopidy-MusicBox-Webclient'
ext_name = 'musicbox_webclient'
@ -18,10 +18,17 @@ class MusicBoxExtension(ext.Extension):
return config.read(conf_file)
def get_config_schema(self):
schema = super(MusicBoxExtension, self).get_config_schema()
schema = super(Extension, self).get_config_schema()
schema['musicbox'] = config.Boolean(optional=True)
schema['websocket_host'] = config.Hostname(optional=True)
schema['websocket_port'] = config.Port(optional=True)
schema['on_track_click'] = config.String(optional=True,
choices=['PLAY_NOW',
'PLAY_NEXT',
'ADD_THIS_BOTTOM',
'ADD_ALL_BOTTOM',
'PLAY_ALL',
'DYNAMIC'])
return schema
def setup(self, registry):

View File

@ -3,3 +3,4 @@ enabled = true
musicbox = false
websocket_host =
websocket_port =
on_track_click = PLAY_ALL

View File

@ -36,7 +36,7 @@
margin-left: 10px;
}
#playlisttracksback {
.backnav-optional {
display: none;
}
@ -74,7 +74,7 @@
width: 100%;
}
#playlisttracksback {
.backnav-optional {
display: block;
}
@ -244,8 +244,8 @@
}
.smalldivider {
font-size: 25% !important;
height: 5px !important;
font-size: 10%;
height: 2px;
background-color: #ddd !important;
}
@ -293,13 +293,22 @@
margin-left: 20px;
}
.song .moreBtn{
.song .moreBtn {
float: right;
padding: 15px 18px 12px 22px;
display: inline-block;
line-height: 100%;
}
.moreBtn i {
color: #ddd;
font-size: initial;
}
.backnav {
background-color: #ccc !important;
}
/**********************
* Now Playing area *
@ -338,6 +347,11 @@
text-decoration: none;
}
.popupArtistLi,
.popupAlbumLi {
display: none
}
.popupArtistName,
.popupTrackName,
.popupAlbumName,
@ -357,7 +371,7 @@
margin-top: 10px;
}
#controlspopupimage,
#albumCoverImg,
#coverpopupimage,
#artistpopupimage {
display: block;
@ -368,18 +382,61 @@
max-height: 90%;
}
#popupTracksLv li,
#popupQueueLv li {
border-bottom: 1px solid #aaa;
/* Override to make buttons more visible in popups.*/
#popupTracks .ui-btn-up-c,
#popupQueue .ui-btn-up-c {
background: white;
}
#popupTracksLv,
#popupQueueLv {
border: 1px solid #aaa;
/* Custom icons for popup listviews - see http://demos.jquerymobile.com/1.3.2/#CustomIcons */
.ui-icon-playAll:after,
.ui-icon-play:after,
.ui-icon-playNext:after,
.ui-icon-add:after,
.ui-icon-addAll:after,
.ui-icon-remove:after {
color: #34495e;
font-family: 'FontAwesome';
}
.ui-icon-playAll:after {
content: '\f144';
}
.ui-icon-play:after {
content: '\f01d';
}
.ui-icon-playNext:after {
content: '\f149';
}
.ui-icon-add:after {
content: '\f196';
}
.ui-icon-addAll:after {
content: '\f0fe';
}
.ui-icon-remove:after {
content: '\f00d';
}
.ui-icon-playAll,
.ui-icon-play,
.ui-icon-playNext,
.ui-icon-add,
.ui-icon-addAll,
.ui-icon-remove {
background-color: unset;
background-image: none;
font-weight: normal;
}
.popupDialog {
padding: 10px;
text-align: center;
}
/*dont hide clear buttons in text input */
@ -517,7 +574,7 @@ a {
overflow: hidden;
}
#controlspopupimage {
#albumCoverImg {
max-width: 90%;
max-height: 90%;
margin-bottom: 3px;

View File

@ -1,30 +1,14 @@
<!DOCTYPE html>
<html manifest="mb.appcache">
<head>
<title>Musicbox</title>
<title>{{ title }}</title>
<meta charset="utf-8">
<script type="text/javascript" src="vendors/jquery/jquery-1.12.0.min.js"></script>
<link rel="stylesheet" type="text/css" href="vendors/jquery_mobile_flat_ui_theme/jquery.mobile.flatui.min.css"/>
<script>
//configuration
var isMusicBox = '{{musicbox}}' == 'True'; // Remove MusicBox only content (e.g. settings, system pages)
var websocketUrl = ('{{useWebsocketUrl}}' == 'True') ? '{{websocket_url}}' : ''
var hasAlarmClock = '{{alarmclock}}' == 'True'; // Add Alarm Clock icons
$(document).bind("mobileinit", function () {
$.extend($.mobile, {
ajaxEnabled: false,
hashListeningEnabled: false
// linkBindingEnabled: false
// buttonMarkup.hoverDelay: 100,
// buttonMarkup.corners: false
});
});
/* window.addEventListener('load', function () {
new FastClick(document.body);
}, false);
*/
</script>
<script type="text/javascript" src="vendors/jquery/jquery-1.12.0.min.js"></script>
<script type="text/javascript" src="vendors/jquery_cookie/jquery.cookie.js"></script>
<script type="text/javascript" src="js/custom_scripting.js"></script>
<link rel="stylesheet" type="text/css" href="vendors/jquery_mobile_flat_ui_theme/jquery.mobile.flatui.min.css"/>
<link rel="icon" type="image/gif" href="images/icons/musicbox32.gif" />
<link rel="apple-touch-icon" href="images/icons/musicbox57.png" />
<link rel="apple-touch-icon" sizes="72x72" href="images/icons/musicbox72.png" />
@ -52,11 +36,10 @@
</head>
<body>
<body data-websocket-url="{{websocketUrl}}" data-is-musicbox="{{isMusicBox}}" data-has-alarmclock="{{hasAlarmClock}}" data-on-track-click="{{onTrackClick}}">
<div data-role="page" id="page" class="ui-responsive-panel" data-theme="c">
<div data-role="panel" id="panel" data-position="left" data-theme="a" data-display="reveal" data-position-fixed="true">
<ul class="ui-listview mainNav" data-role="listview" data-theme="a">
<li id="navhome" data-icon="false">
<a href="#home" onclick="switchContent('home' ); return false;">
@ -76,11 +59,11 @@
</li>
<li id="navbrowse" data-icon="false">
<a href="#browse" onclick="switchContent('browse' ); return false;">
<span class="navtxt"> Browse </span><i class="fa fa-folder"></i></a>
<span class="navtxt"> Browse </span><i class="fa fa-folder"></i></a>
</li>
<li id="navstream" data-icon="false">
<a href="#stream" onclick="switchContent('stream' ); return false;">
<span class="navtxt">Streams </span><i class="fa fa-rss"></i></a>
<span class="navtxt">Streams </span><i class="fa fa-rss"></i></a>
</li>
<li id="navsearch" data-icon="false">
<a href="#search" onclick="switchContent('search' ); return false;">
@ -104,7 +87,7 @@
</li>
<li data-icon="false">
<div><!-- slider for volume -->
<a href="#" onclick="doMute(); return false;"><span title="Toggle mute"><i id="mutebt" class="fa fa-volume-up"></i></span></a>
<a href="#" onclick="controls.doMute(); return false;"><span title="Toggle mute"><i id="mutebt" class="fa fa-volume-up"></i></span></a>
<label for="volumeslider" class="ui-hidden-accessible">Volume</label>
<input id="volumeslider" data-highlight="true" name="volumeslider" data-mini="true" type="range" min="0"
value="0" max="100"/>
@ -129,48 +112,28 @@
<a href="#" onclick="closePopups();"><img id="artistpopupimage" src="" alt="Album artist"/></a>
</div>
<div data-role="popup" data-transition="none" data-theme="c" id="popupBrowse">
<div data-role="collapsible-set">
<ul data-role="listview" data-icon="false" id="popupBrowseLv">
<li>
<a href="#" onclick="return playBrowsedTracks(PLAY_ALL);">Play All</a>
</li>
<li>
<a href="#" onclick="return playBrowsedTracks(PLAY_NOW);">Play <span class="popupTrackName"></span></a>
</li>
<li class="addqueue">
<a href="#" onclick="return playBrowsedTracks(PLAY_NEXT);">Play Track Next</a>
</li>
<li class="addqueue">
<a href="#" onclick="return playBrowsedTracks(ADD_THIS_BOTTOM);">Add Track to Bottom of Queue</a>
</li>
<li class="addqueue">
<a href="#" onclick="return playBrowsedTracks(ADD_ALL_BOTTOM);">Add All to Bottom of Queue</a>
</li>
</ul>
</div>
</div>
<div data-role="popup" data-transition="none" data-theme="c" id="popupTracks">
<div data-role="popup" data-transition="none" data-theme="b" id="popupTracks">
<div data-role="collapsible-set">
<ul data-role="listview" data-icon="false" id="popupTracksLv">
<li>
<a href="#" onclick="return playTrack(PLAY_NOW);">Play <span class="popupTrackName"></span></a>
<li data-icon="play">
<a href="#" onclick="return controls.playTracks(PLAY_NOW, mopidy);">Play <span class="popupTrackName"></span></a>
</li>
<li class="addqueue">
<a href="#" onclick="return playTrack(PLAY_NEXT);">Play Track Next</a>
<li data-icon="playAll" data-iconshadow="false">
<a href="#" onclick="return controls.playTracks(PLAY_ALL, mopidy);">Play All</a>
</li>
<li class="addqueue">
<a href="#" onclick="return playTrack(ADD_THIS_BOTTOM);">Add Track to Bottom of Queue</a>
<li data-icon="playNext" class="addqueue">
<a href="#" onclick="return controls.playTracks(PLAY_NEXT, mopidy);">Play Track Next</a>
</li>
<li class="addqueue">
<a href="#" onclick="return playTrack(ADD_ALL_BOTTOM);" id="liaddtobottom">Add all to Bottom of
Queue</a>
<li data-icon="add" class="addqueue">
<a href="#" onclick="return controls.playTracks(ADD_THIS_BOTTOM, mopidy);">Add Track to Bottom of Queue</a>
</li>
<li>
<li data-icon="addAll" class="addqueue">
<a href="#" onclick="return controls.playTracks(ADD_ALL_BOTTOM, mopidy);">Add All to Bottom of Queue</a>
</li>
<li class="popupAlbumLi">
<a href="#" onclick="showAlbumPopup('#popupTracks')">Show Album <span class="popupAlbumName"></span></a>
</li>
<li id="popupArtistsLi">
<li class="popupArtistsLi">
<a href="#" onclick="showArtist()" class="popupArtistHref">Show Artist <span class="popupArtistName"></span>
</a>
</li>
@ -182,19 +145,19 @@
</div>
</div>
<div data-role="popup" data-transition="none" data-theme="c" id="popupQueue">
<div data-role="popup" data-transition="none" data-theme="b" id="popupQueue">
<div data-role="collapsible-set">
<ul data-role="listview" data-icon="false" id="popupQueueLv">
<li>
<a href="#" onclick="return playTrackQueue();">Play <span class="popupTrackName"></span></a>
<li data-icon="play">
<a href="#" onclick="return controls.playQueueTrack();">Play <span class="popupTrackName"></span></a>
</li>
<li>
<a href="#" onclick="return removeTrack();">Remove from Queue</a>
<li data-icon="remove">
<a href="#" onclick="return controls.removeTrack();">Remove from Queue</a>
</li>
<li>
<li class="popupAlbumLi">
<a href="#" onclick="showAlbumPopup('#popupQueue')">Show Album <span class="popupAlbumName"></span></a>
</li>
<li id="popupArtistsLi">
<li class="popupArtistsLi">
<a href="#" onclick="showArtist()" class="popupArtistHref">Show Artist <span class="popupArtistName"></span>
</a>
</li>
@ -206,31 +169,29 @@
</div>
</div>
<div data-role="popup" data-theme="b" id="popupSave" class="popupDialog">
<form>
<p>Save current queue to a playlist.
<p>Save Current Queue to a Playlist
<input id="saveinput" placeholder="Playlist name" class="span2" data-clear-btn="true"
onkeypress="return savePressed(event.keyCode);" type="text"/>
onkeypress="return controls.savePressed(event.keyCode);" type="text"/>
<div data-role="controlgroup" data-type="horizontal" align="center">
<button class="btn" type="button" onclick="return saveQueue();">
Ok
</button>
<button class="btn" type="button" onclick="return $('#popupSave').popup('close');">
Cancel
</button>
<button class="btn" type="button" onclick="return controls.saveQueue();">
Save
</button>
</div>
</form>
</div>
<!--/save queue to playlist-->
<div data-role="popup" data-theme="b" id="popupOverwrite" class="popupDialog">
<form>
<p>Overwrite existing playlist with same name?
<p>A playlist with the same name already exists. Overwrite?
<div data-role="controlgroup" data-type="horizontal" align="center">
<button class="btn" type="button" id="overwriteConfirmBtn">
Ok
Overwrite
</button>
<button class="btn" type="button" onclick="$('#popupOverwrite').popup('close'); return $('#popupSave').popup('open');">
Cancel
@ -239,6 +200,20 @@
</form>
</div><!--/overwrite existing playlist-->
<div data-role="popup" data-theme="b" id="popupConfirmDelete" class="popupDialog">
<form>
<p>Are you sure you want to remove <span class="popupStreamName"></span>?
<div data-role="controlgroup" data-type="horizontal" align="center">
<button class="btn" type="button" onclick="return controls.deleteFavourite();">
Remove
</button>
<button class="btn" type="button" onclick="$('#popupConfirmDelete').popup('close');">
Cancel
</button>
</div>
</form>
</div><!--/confirm delete stream-->
<div data-role="header" data-tap-toggle="false" id="header" data-position="fixed" class="header-breakpoint headerbtn">
<a id="headermenubtn" href="#panel"><i class="fa fa-align-justify"></i></a>
<h1 id="contentHeadline">Musicbox</h1>
@ -307,11 +282,9 @@
</div>
<!--/homepane-->
<div id="nowPlayingpane" data-role="content" class="pane">
<img id="controlspopupimage" src="images/default_cover.png" alt="Album cover"/>
<img id="albumCoverImg" src="images/default_cover.png" alt="Album cover"/>
<div class="nowPlaying-artistInfo">
<h3 id="modalname"></h3>
@ -333,7 +306,7 @@
<h4>Playlists</h4>
</div>
<div align="right" class="ui-block-b" data-role="controlgroup" data-type="horizontal">
<button class="btn" type="button" title="Refresh playlists" onclick="return refreshPlaylists();">
<button class="btn" type="button" title="Refresh playlists" onclick="return controls.refreshPlaylists();">
<i class="fa fa-refresh"></i>
</button>
</div>
@ -343,9 +316,6 @@
<ul id="playlistslist" class="table"></ul>
</div>
<div class="ui-block-b scroll" id="playlisttracksdiv">
<div id="playlisttracksback" style="height: 30px; margin: 2px; padding-top: 2px; background-color: #aaa;">
<a style="display:block; padding: 5px;" href="#" onclick="return togglePlaylists();"><i class="fa fa-arrow-circle-left"></i> Back</a>
</div>
<div>
<ul class="table" id="playlisttracks"></ul>
</div>
@ -356,8 +326,9 @@
<div data-role="content" id="browsepane" class="pane">
<h4>Browse</h4>
<h5 id="browsepath"></h5>
<ul id="browselist" class="table"></ul>
<div class="ui-grid">
<ul id="browsetable" class="table"></ul>
</div>
</div>
<!--/browsepane-->
@ -367,11 +338,11 @@
<h4>Play Queue</h4>
</div>
<div align="right" class="ui-block-b" data-role="controlgroup" data-type="horizontal">
<button class="btn" type="button" title="Save queue to playlist" onclick="return showSavePopup();">
<i class="fa fa-floppy-o"></i>
<button class="btn" type="button" title="Save queue to playlist" onclick="return controls.showSavePopup();">
<i class="fa fa-bookmark-o"></i>
</button>
<button class="btn" type="button" title="Clear queue" onclick="return clearQueue();">
<i class="fa fa-trash-o"></i>
<button class="btn" type="button" title="Clear queue" onclick="return controls.clearQueue();">
<i class="fa fa-times"></i>
</button>
</div>
</div>
@ -385,7 +356,7 @@
<h3 id="h_albumname"></h3>
<h5 id="h_albumartist"></h5>
<div id="albumstable"></div>
<ul class="table" id="albumstable"></ul>
</div>
@ -403,14 +374,10 @@
<div class="ui-block">
<form>
<p>Search for artists, albums, or specific tracks.
<select id="selectSearchService">
<!-- data-native-menu="false">
multiple="multiple" data-native-menu="false">
<option data-placeholder="true">Choose services</option> -->
</select>
<select id="selectSearchService"></select>
<input id="searchinput" placeholder="Search term" class="span2" data-clear-btn="true"
onkeypress="return searchPressed(event.keyCode);" type="text"/>
<button class="btn" type="button" onclick="return initSearch(event.value);">
onkeypress="return library.searchPressed(event.keyCode);" type="text"/>
<button class="btn" type="button" onclick="return library.initSearch(event.value);">
Search!
</button>
</form>
@ -454,18 +421,18 @@
<div class="ui-block-a" style="padding: 5px">
<form>
<p>Play a specific stream/track and optionally save it to your favourites.
<button class="btn" type="button" onclick="return getCurrentlyPlaying();">
<button class="btn" type="button" onclick="return controls.getCurrentlyPlaying();">
Get currently playing
</button>
<input id="streamuriinput" placeholder="URI" class="span2" data-clear-btn="true"
onkeypress="return streamPressed(event.keyCode);" type="text"/>
<button class="btn" type="button" onclick="return playStreamUri();">
onkeypress="return controls.streamPressed(event.keyCode);" type="text"/>
<button class="btn" type="button" onclick="return controls.playStreamUri();">
Play
</button>
<input id="streamnameinput" placeholder="Name" class="span2" data-clear-btn="true"
onkeypress="return streamPressed(event.keyCode);" type="text"/>
<button class="btn" type="button" onclick="return addFavourite();">
onkeypress="return controls.streamPressed(event.keyCode);" type="text"/>
<button class="btn" type="button" onclick="return controls.addFavourite();">
Save
</button>
<br/>
@ -477,7 +444,6 @@
</div>
</div>
<div data-role="footer" data-tap-toggle="false" data-position="fixed" id="normalFooter">
<div class="footerControls">
<div class="songinfo" id="songinfo">
@ -488,36 +454,34 @@
</div>
</div>
<div class="playicon">
<a href="#" onclick="doPlay(); return false"><span id="btplay" title="Play"><i class="fa fa-play"></i></span></a>
<a href="#" onclick="controls.doPlay(); return false"><span id="btplay" title="Play"><i class="fa fa-play"></i></span></a>
</div>
</div>
</div>
<div data-role="footer" data-tap-toggle="false" data-position="fixed" id="nowPlayingFooter">
<div class="footerControls" style="padding-left: 10px;">
<div style="float: left;">
<a href="#" onclick="doPrevious(); return false"><span id="btprev" title="Previous"><i class="fa fa-fast-backward"></i></span></a>
<a href="#" onclick="doPlay(); return false"><span id="btplayNowPlaying" title="Play"><i class="fa fa-play"></i></span></a>
<a href="#" onclick="doNext(); return false"><span id="btnext" title="Next"><i class="fa fa-fast-forward"></i></span></a>
<a href="#" onclick="controls.doPrevious(); return false"><span id="btprev" title="Previous"><i class="fa fa-fast-backward"></i></span></a>
<a href="#" onclick="controls.doPlay(); return false"><span id="btplayNowPlaying" title="Play"><i class="fa fa-play"></i></span></a>
<a href="#" onclick="controls.doNext(); return false"><span id="btnext" title="Next"><i class="fa fa-fast-forward"></i></span></a>
</div>
<div style="float: right; margin-right: 10px;">
<a href="#" onclick="doRandom(); return false"><span id="randombt" title="Random"><i class="fa fa-random"></i></span></a>
<a href="#" onclick="doRepeat(); return false"><span id="repeatbt" title="Repeat"><i class="fa fa-repeat"></i></span></a>
<a href="#" onclick="doConsume(); return false"><span id="consumebt" title="Consume"><i class="fa fa-cutlery"></i></span></a>
<a href="#" onclick="doSingle(); return false"><span id="singlebt" title="Single"><i class="fa fa-dot-circle-o"></i></span></a>
<a href="#" onclick="doShuffle(); return false"><span id="shufflebt" title="Shuffle"><i class="fa fa-arrows-v"></i></span></a>
<a href="#" onclick="controls.doRandom(); return false"><span id="randombt" title="Random"><i class="fa fa-random"></i></span></a>
<a href="#" onclick="controls.doRepeat(); return false"><span id="repeatbt" title="Repeat"><i class="fa fa-repeat"></i></span></a>
<a href="#" onclick="controls.doConsume(); return false"><span id="consumebt" title="Consume"><i class="fa fa-cutlery"></i></span></a>
<a href="#" onclick="controls.doSingle(); return false"><span id="singlebt" title="Single"><i class="fa fa-dot-circle-o"></i></span></a>
<a href="#" onclick="controls.doShuffle(); return false"><span id="shufflebt" title="Shuffle"><i class="fa fa-arrows-v"></i></span></a>
</div>
</div>
</div>
<!-- /footer -->
</div>
<!-- /page one -->
<script type="text/javascript" src="vendors/mopidy/mopidy.min.js"></script>
<script type="text/javascript" src="vendors/jquery_cookie/jquery.cookie.js"></script>
<script type="text/javascript" src="vendors/media_progress_timer/timer.js"></script>
<script type="text/javascript" src="js/progress_timer.js"></script>
<script type="text/javascript" src="js/synced_timer.js"></script>
<script type="text/javascript" src="js/controls.js"></script>
<script type="text/javascript" src="js/library.js"></script>
<script type="text/javascript" src="js/functionsvars.js"></script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
// jQuery Mobile configuration options
// see: http://api.jquerymobile.com/1.3/global-config/
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory()
} else {
root.configureJQueryMobile = factory()
}
}(this, function () {
'use strict'
function configureJQueryMobile () {
$.extend($.mobile, {
ajaxEnabled: false,
hashListeningEnabled: false
})
}
$(document).bind('mobileinit', configureJQueryMobile)
return configureJQueryMobile
}))

View File

@ -4,79 +4,79 @@
* all kinds functions and vars
*/
var mopidy;
var mopidy
var syncedProgressTimer
//values for controls
var play = false;
var random;
var repeat;
var consume;
var single;
var mute;
var volumeChanging;
var volumeSliding = false;
// values for controls
var play = false
var random
var repeat
var consume
var single
var mute
var volumeChanging
var volumeSliding = false
var positionChanging;
var positionChanging
var initgui = true;
var popupData = {};
var songlength = 0;
var initgui = true
var popupData = {} // TODO: Refactor into one shared cache
var songlength = 0
var artistshtml = '';
var artiststext = '';
var songname = '';
var songdata = {'track': {}, 'tlid': -1};
var artistshtml = ''
var artiststext = ''
var songname = ''
var songdata = {'track': {}, 'tlid': -1}
var playlisttracksScroll;
var playlistslistScroll;
var playlisttracksScroll
var playlistslistScroll
var STREAMS_PLAYLIST_NAME = '[Radio Streams]';
var STREAMS_PLAYLIST_SCHEME = 'm3u';
var uriSchemes = {};
var STREAMS_PLAYLIST_NAME = '[Radio Streams]'
var STREAMS_PLAYLIST_SCHEME = 'm3u'
var uriSchemes = {}
//array of cached playlists (not only user-playlists, also search, artist, album-playlists)
var playlists = {};
var currentplaylist;
var customTracklists = [];
// array of cached playlists (not only user-playlists, also search, artist, album-playlists)
var playlists = {} // TODO: Refactor into one shared cache
var currentplaylist
var customTracklists = [] // TODO: Refactor into one shared cache
var browseStack = [];
var browseTracks = [];
var browseStack = []
var ua = navigator.userAgent,
isMobileSafari = /Mac/.test(ua) && /Mobile/.test(ua),
isMobileWebkit = /WebKit/.test(ua) && /Mobile/.test(ua),
isMobile = /Mobile/.test(ua),
isWebkit = /WebKit/.test(ua);
var ua = navigator.userAgent
var isMobileSafari = /Mac/.test(ua) && /Mobile/.test(ua)
var isMobileWebkit = /WebKit/.test(ua) && /Mobile/.test(ua)
var isMobile = /Mobile/.test(ua)
var isWebkit = /WebKit/.test(ua)
//constants
PROGRAM_NAME = 'MusicBox';
ARTIST_TABLE = '#artiststable';
ALBUM_TABLE = '#albumstable';
PLAYLIST_TABLE = '#playlisttracks';
CURRENT_PLAYLIST_TABLE = '#currenttable';
SEARCH_ALL_TABLE = '#allresulttable';
SEARCH_ALBUM_TABLE = '#albumresulttable';
SEARCH_ARTIST_TABLE = '#artistresulttable';
SEARCH_TRACK_TABLE = '#trackresulttable';
// constants
PROGRAM_NAME = 'MusicBox'
ARTIST_TABLE = '#artiststable'
ALBUM_TABLE = '#albumstable'
BROWSE_TABLE = '#browsetable'
PLAYLIST_TABLE = '#playlisttracks'
CURRENT_PLAYLIST_TABLE = '#currenttable'
SEARCH_ALL_TABLE = '#allresulttable'
SEARCH_ALBUM_TABLE = '#albumresulttable'
SEARCH_ARTIST_TABLE = '#artistresulttable'
SEARCH_TRACK_TABLE = '#trackresulttable'
URI_SCHEME = 'mbw';
URI_SCHEME = 'mbw'
PLAY_NOW = 0;
PLAY_NEXT = 1;
ADD_THIS_BOTTOM = 2;
ADD_ALL_BOTTOM = 3;
PLAY_ALL = 4;
PLAY_NOW_SEARCH = 5;
MAX_TABLEROWS = 50;
PLAY_NOW = 0
PLAY_NEXT = 1
ADD_THIS_BOTTOM = 2
ADD_ALL_BOTTOM = 3
PLAY_ALL = 4
DYNAMIC = 5
// the first part of Mopidy extensions which serve radio streams
var radioExtensionsList = ['somafm', 'tunein', 'dirble', 'audioaddict'];
var radioExtensionsList = ['somafm', 'tunein', 'dirble', 'audioaddict']
var uriClassList = [
['spotify', 'fa-spotify'],
['spotifytunigo', 'fa-spotify'],
['local', 'fa-file-sound-o'],
['file', 'fa-folder-o'],
['m3u', 'fa-file-sound-o'],
['podcast', 'fa-rss-square'],
['dirble', 'fa-microphone'],
@ -90,13 +90,13 @@ var uriClassList = [
['yt', 'fa-youtube'],
['audioaddict', 'fa-bullhorn'],
['subsonic', 'fa-folder-open']
];
]
var uriHumanList = [
['spotify', 'Spotify'],
['spotifytunigo', 'Spotify Browse'],
['local', 'Local Files'],
['m3u', 'Local Playlists'],
['spotifytunigo', 'Spotify browse'],
['local', 'Local files'],
['m3u', 'Local playlists'],
['podcast', 'Podcasts'],
['dirble', 'Dirble'],
['tunein', 'TuneIn'],
@ -107,441 +107,464 @@ var uriHumanList = [
['youtube', 'YouTube'],
['audioaddict', 'AudioAddict'],
['subsonic', 'Subsonic']
];
]
function scrollToTop() {
var divtop = 0;
// List of Mopidy URI schemes that should not be searched directly.
// Also blacklists 'yt' in favour of using the other 'youtube' supported scheme.
var searchBlacklist = [
'file',
'http',
'https',
'mms',
'rtmp',
'rtmps',
'rtsp',
'yt'
]
function scrollToTop () {
var divtop = 0
$('body,html').animate({
scrollTop: divtop
}, 250);
}, 250)
}
function scrollToTracklist() {
var divtop = $("#playlisttracksdiv").offset().top - 120;
function scrollToTracklist () {
var divtop = $('#playlisttracksdiv').offset().top - 120
$('body,html').animate({
scrollTop: divtop
}, 250);
}, 250)
}
//A hack to find the name of the first artist of a playlist. this is not yet returned by mopidy
//does not work wel with multiple artists of course
function getArtist(pl) {
// A hack to find the name of the first artist of a playlist. this is not yet returned by mopidy
// does not work wel with multiple artists of course
function getArtist (pl) {
for (var i = 0; i < pl.length; i++) {
for (var j = 0; j < pl[i].artists.length; j++) {
if (pl[i].artists[j].name !== '') {
return pl[i].artists[j].name;
return pl[i].artists[j].name
}
}
}
}
//A hack to find the first album of a playlist. this is not yet returned by mopidy
function getAlbum(pl) {
// A hack to find the first album of a playlist. this is not yet returned by mopidy
function getAlbum (pl) {
for (var i = 0; i < pl.length; i++) {
if (pl[i].album.name !== '') {
return pl[i].album.name;
return pl[i].album.name
}
}
}
function artistsToString(artists, max) {
var result = '';
max = max || 3;
function artistsToString (artists, max) {
var result = ''
max = max || 3
for (var i = 0; i < artists.length && i < max; i++) {
if (artists[i].name) {
if (i > 0) {
result += ', ';
result += ', '
}
result += artists[i].name;
result += artists[i].name
}
}
return result;
return result
}
/********************************************************
/** ******************************************************
* break up results and put them in album tables
*********************************************************/
function albumTracksToTable(pl, target, uri) {
var tmp = '<ul class="songsOfAlbum table" >';
var liId = '';
var targetmin = target.substr(1);
$(target).empty();
function albumTracksToTable (pl, target, uri) {
var track, previousTrack, nextTrack
var html = ''
$(target).empty()
$(target).attr('data', uri)
for (var i = 0; i < pl.length; i++) {
popupData[pl[i].uri] = pl[i];
liID = targetmin + '-' + pl[i].uri;
tmp += renderSongLi(pl[i], liID, uri);
previousTrack = track || undefined
nextTrack = i < pl.length - 1 ? pl[i + 1] : undefined
track = pl[i]
popupData[track.uri] = track
html += renderSongLi(previousTrack, track, nextTrack, uri, '', target, i, pl.length)
}
tmp += '</ul>';
$(target).html(tmp);
$(target).attr('data', uri);
$(target).append(html)
updatePlayIcons(songdata.track.uri, songdata.tlid, controls.getIconForAction())
}
function renderSongLi(song, liID, uri) {
var name;
if (!song.name || song.name === '') {
name = uri.split('/');
name = decodeURI(name[name.length - 1]);
function renderSongLi (previousTrack, track, nextTrack, uri, tlid, target, currentIndex, listLength) {
var name
var tlidParameter = ''
var onClick = ''
var html = ''
track.name = validateTrackName(track, currentIndex)
// Leave out unplayable items
if (track.name.substring(0, 12) === '[unplayable]') {
return html
}
// Streams
if (track.length === -1) {
html += '<li class="albumli"><a href="#"><h1><i class="' + getMediaClass(track.uri) + '"></i> ' + track.name + ' [Stream]</h1></a></li>'
return html
}
if (target === CURRENT_PLAYLIST_TABLE && typeof tlid === 'number' && tlid >= 0) { // Current queue: Show popup menu icon. onClick plays track.
tlidParameter = '\',\'' + tlid
onClick = 'return controls.playQueueTrack(' + tlid + ');'
} else { // All other tracklist: Show default action icon. onClick performs default action
onClick = 'return controls.playTracks(\'\', mopidy, \'' + track.uri + '\', \'' + uri + '\');'
}
html +=
'<li class="song albumli" id="' + getjQueryID(target, track.uri) + '" tlid="' + tlid + '">' +
'<a href="#" class="moreBtn" onclick="return popupTracks(event, \'' + uri + '\',\'' + track.uri + tlidParameter + '\');">' +
'<i class="fa fa-play-circle-o"></i></a>' +
'<a href="#" onclick="' + onClick + '"><h1><i class="' + getMediaClass(track.uri) + '"></i> ' + track.name + '</h1>'
if (listLength === 1 || (!hasSameAlbum(previousTrack, track) && !hasSameAlbum(track, nextTrack))) {
html += renderSongLiAlbumInfo(track)
}
html += '</a></li>'
return html
}
/* Tracklist renderer for track artist and album name. */
function renderSongLiAlbumInfo (track, target) {
var html = renderSongLiTrackArtists(track)
if (track.album && track.album.name) {
html += ' - <em>' + track.album.name + '</em></p>'
}
if (typeof target !== 'undefined' && target.length > 0) {
target = getjQueryID(target, track.uri, true)
$(target).children('a').eq(1).append(html)
}
return html
}
/* Tracklist renderer for track artist information. */
function renderSongLiTrackArtists (track) {
var html = ''
if (track.artists) {
for (var i = 0; i < track.artists.length; i++) {
html += track.artists[i].name
html += (i === track.artists.length - 1) ? '' : ' / '
// Stop after 3
if (i > 2) {
html += '...'
break
}
}
}
return html
}
/* Tracklist renderer to insert dividers between albums. */
function renderSongLiDivider (previousTrack, track, nextTrack, target) {
var html = ''
// Render differently if part of an album.
if (!hasSameAlbum(previousTrack, track) && hasSameAlbum(track, nextTrack)) {
// Large divider with album cover.
html +=
'<li class="albumdivider"><a href="#" onclick="return library.showAlbum(\'' + track.album.uri + '\');">' +
'<img id="' + getjQueryID(target + '-cover', track.uri) + '" class="artistcover" width="30" height="30"/>' +
'<h1><i class="' + getMediaClass(track.uri) + '"></i> ' + track.album.name + '</h1><p>' +
renderSongLiTrackArtists(track) + '</p></a></li>'
// Retrieve album covers
images.setAlbumImage(track.uri, getjQueryID(target + '-cover', track.uri, true), mopidy, 'small')
} else if (previousTrack && !hasSameAlbum(previousTrack, track)) {
// Small divider
html += '<li class="smalldivider"> &nbsp;</li>'
}
if (typeof target !== 'undefined' && target.length > 0) {
target = getjQueryID(target, track.uri, true)
$(target).before(html)
}
return html
}
function renderSongLiBackButton (results, target, onClick, optional) {
if (onClick && onClick.length > 0) {
if (!results || results.length === 0) {
$(target).empty()
$(target).append(
'<li class="song albumli"><a href="#" onclick="' + onClick + '"><h1><i></i>No tracks found...</h1></a></li>'
)
}
var opt = ''
if (optional) {
opt = ' backnav-optional'
}
$(target).prepend(
'<li class="backnav' + opt + '"><a href="#" onclick="' + onClick + '"><h1><i class="fa fa-arrow-circle-left"></i> Back</h1></a></li>'
)
}
}
function hasSameAlbum (track1, track2) {
// 'true' if album for each track exists and has the same name
var name1 = track1 ? (track1.album ? track1.album.name : undefined) : undefined
var name2 = track2 ? (track2.album ? track2.album.name : undefined) : undefined
return name1 && name2 && (name1 === name2)
}
function validateTrackName (track, trackNumber) {
// Create name if there is none
var name = ''
if (!track.name || track.name === '') {
name = track.uri.split('/')
name = decodeURI(name[name.length - 1]) || 'Track ' + String(trackNumber)
} else {
name = song.name;
name = track.name
}
songLi = '<li class="song albumli" id="' + liID + '">' +
'<a href="#" class="moreBtn" onclick="return popupTracks(event, \'' + uri + '\',\'' + song.uri + '\');">' +
'<i class="fa fa-ellipsis-v"></i></a>' +
'<a href="#" onclick="return playTrackByUri(\'' + song.uri + '\',\'' + uri + '\');">' +
'<h1 class="trackname">' + name + '</h1></a>' +
'</li>';
return songLi;
return name
}
function renderQueueSongLi(song, liID, uri, tlid) {
var name;
if (!song.name || song.name === '') {
name = uri.split('/');
name = decodeURI(name[name.length - 1]);
} else {
name = song.name;
function resultsToTables (results, target, uri, onClickBack, backIsOptional) {
$(target).empty()
renderSongLiBackButton(results, target, onClickBack, backIsOptional)
if (!results || results.length === 0) {
return
}
songLi = '<li class="song albumli" id="' + liID + '" tlid="' + tlid + '">' +
'<a href="#" class="moreBtn" onclick="return popupTracks(event, \'' + uri + '\',\'' + song.uri + '\',\'' + tlid + '\');">' +
'<i class="fa fa-ellipsis-v"></i></a>' +
'<a href="#" onclick="return playTrackQueueByTlid(\'' + song.uri + '\',\'' + tlid + '\');">' +
'<h1 class="trackname">' + name + '</h1></a>' +
'</li>';
return songLi;
$(target).attr('data', uri)
var track, previousTrack, nextTrack, tlid
var html = ''
// Break into albums and put in tables
for (i = 0; i < results.length; i++) {
previousTrack = track || undefined
nextTrack = i < results.length - 1 ? results[i + 1] : undefined
track = results[i]
if (track) {
if ('tlid' in track) {
// Get track information from TlTrack instance
tlid = track.tlid
track = track.track
nextTrack = nextTrack ? nextTrack.track : undefined
}
popupData[track.uri] = track
html += renderSongLiDivider(previousTrack, track, nextTrack, target)
html += renderSongLi(previousTrack, track, nextTrack, uri, tlid, target, i, results.length)
}
}
$(target).append(html)
updatePlayIcons(songdata.track.uri, songdata.tlid, controls.getIconForAction())
}
function resultsToTables(results, target, uri) {
if (!results) {
return;
}
var tlids = [];
if (target == CURRENT_PLAYLIST_TABLE) {
for (i = 0; i < results.length; i++) {
tlids[i] = results[i].tlid;
results[i] = results[i].track;
}
}
var newalbum = [];
var newtlids = [];
//keep a list of track URIs for retrieving of covers
var coversList = [];
var nextname = '';
var count = 0;
$(target).html('');
//break into albums and put in tables
var html = '';
var tableid, j, artistname, alburi, name, iconClass;
var targetmin = target.substr(1);
$(target).attr('data', uri);
var length = 0 || results.length;
for (i = 0; i < length; i++) {
//create album if none extists
if (!results[i].album) {
results[i].album = {"__model__": "Album"};
}
//create album uri if there is none
if (!results[i].album.uri) {
results[i].album.uri = 'x';
}
if (!results[i].album.name) {
results[i].album.name = '';
}
//create name if there is no one
if (!results[i].name || results[i].name === '') {
name = results[i].uri.split('/');
results[i].name = decodeURI(name[name.length - 1]) || 'Track ' + String(i);
}
//leave out unplayable items
if (results[i].name.substring(0, 12) == '[unplayable]') continue;
newalbum.push(results[i]);
newtlids.push(tlids[i]);
nextname = '';
if ((i < length - 1) && results[i + 1].album && results[i + 1].album.name) {
nextname = results[i + 1].album.name;
}
if (results[i].length == -1) {
html += '<li class="albumli"><a href="#"><h1><i class="' + iconClass + '"></i> ' + results[i].name + ' [Stream]</h1></a></li>';
newalbum = [];
newtlids = [];
nextname = '';
} else {
if ((results[i].album.name != nextname) || (nextname === '')) {
tableid = 'art' + i;
//render differently if only one track in the album
if (newalbum.length == 1) {
if (i !== 0) {
html += '<li class="smalldivider"> &nbsp;</li>';
}
iconClass = getMediaClass(newalbum[0].uri);
var liID = targetmin + '-' + newalbum[0].uri;
if (target == CURRENT_PLAYLIST_TABLE) {
html += '<li class="song albumli" id="' + liID + '" tlid="' + newtlids[0] + '">' +
'<a href="#" class="moreBtn" onclick="return popupTracks(event, \'' + uri + '\',\'' + newalbum[0].uri + '\',\'' + newtlids[0] + '\');">' +
'<i class="fa fa-ellipsis-v"></i></a>' +
'<a href="#" onclick="return playTrackQueueByTlid(\'' + newalbum[0].uri + '\',\'' + newtlids[0] + '\');">' +
'<h1><i class="' + iconClass + '"></i> ' + newalbum[0].name + "</h1><p>";
} else {
html += '<li class="song albumli" id="' + liID + '">' +
'<a href="#" class="moreBtn" onclick="return popupTracks(event, \'' + uri + '\',\'' + newalbum[0].uri + '\');">' +
'<i class="fa fa-ellipsis-v"></i></a>' +
'<a href="#" onclick="return playTrackByUri(\'' + newalbum[0].uri + '\',\'' + uri + '\');">' +
'<h1><i class="' + iconClass + '"></i> ' + newalbum[0].name + "</h1><p>";
}
if (newalbum[0].artists) {
for (j = 0; j < newalbum[0].artists.length; j++) {
html += newalbum[0].artists[j].name;
html += (j == newalbum[0].artists.length - 1) ? '' : ' / ';
//stop after 3
if (j > 2) {
html += '...';
break;
}
}
}
if (newalbum[0].album.name !== '') {
html += ' / ';
}
html += '<em>' + newalbum[0].album.name + '</em></p>';
html += '</a></li>';
popupData[newalbum[0].uri] = newalbum[0];
newalbum = [];
newtlids = [];
} else { //newalbum length
if (results[i].album.uri && results[i].album.name) {
iconClass = getMediaClass(newalbum[0].uri);
html += '<li class="albumdivider">';
html += '<a href="#" onclick="return showAlbum(\'' + results[i].album.uri + '\');"><img id="' +
targetmin + '-cover-' + i + '" class="artistcover" width="30" height="30" /><h1><i class="' + iconClass + '"></i> ' + results[i].album.name + '</h1><p>';
}
if (results[i].album.artists) {
for (j = 0; j < results[i].album.artists.length; j++) {
html += results[i].album.artists[j].name;
html += (j == results[i].album.artists.length - 1) ? '' : ' / ';
//stop after 3
if (j > 2) {
child += '...';
break;
}
}
}
html += '</p></a></li>';
for (j = 0; j < newalbum.length; j++) {
popupData[newalbum[j].uri] = newalbum[j];
//hERE!
var liID = targetmin + '-' + newalbum[j].uri;
if (target == CURRENT_PLAYLIST_TABLE) {
html += renderQueueSongLi(newalbum[j], liID, uri, newtlids[j]);
} else {
html += renderSongLi(newalbum[j], liID, uri);
}
}
newalbum = [];
newtlids = [];
if (results[i].album) {
coversList.push([results[i].uri, i]);
}
} //newalbum length
} //albums name
}
}
tableid = "#" + tableid;
$(target).html(html);
$(target).attr('data', uri);
//retrieve albumcovers
for (i = 0; i < coversList.length; i++) {
getCover(coversList[i][0], target + '-cover-' + coversList[i][1], 'small');
}
}
//process updated playlist to gui
function playlisttotable(pl, target, uri) {
var tmp = '';
$(target).html('');
var targetmin = target.substr(1);
var child = '';
// process updated playlist to gui
function playlisttotable (pl, target, uri) {
var tmp = ''
$(target).html('')
var targetmin = target.substr(1)
var child = ''
for (var i = 0; i < pl.length; i++) {
if (pl[i]) {
popupData[pl[i].uri] = pl[i];
child = '<li id="' + targetmin + '-' + pl[i].uri + '"><a href="#" onclick="return popupTracks(event, \'' + uri + '\',\'' + pl[i].uri + '\');">';
child += '<h1>' + pl[i].name + "</h1>";
child += '<p>';
child += '<span style="float: right;">' + timeFromSeconds(pl[i].length / 1000) + '</span>';
popupData[pl[i].uri] = pl[i]
child = '<li id="' + targetmin + '-' + pl[i].uri + '"><a href="#" onclick="return popupTracks(event, \'' + uri + '\',\'' + pl[i].uri + '\');">'
child += '<h1>' + pl[i].name + 'h1>'
child += '<p>'
child += '<span style="float: right;">' + timeFromSeconds(pl[i].length / 1000) + '</span>'
for (var j = 0; j < pl[i].artists.length; j++) {
if (pl[i].artists[j]) {
child += pl[i].artists[j].name;
child += (j == pl[i].artists.length - 1) ? '' : ' / ';
//stop after 3
child += pl[i].artists[j].name
child += (j === pl[i].artists.length - 1) ? '' : ' / '
// stop after 3
if (j > 2) {
child += '...';
break;
child += '...'
break
}
}
}
child += ' / <em>' + pl[i].album.name + '</em></p>';
child += '</a></li>';
tmp += child;
child += ' / <em>' + pl[i].album.name + '</em></p>'
child += '</a></li>'
tmp += child
}
}
$(target).html(tmp);
$(target).attr('data', uri);
$(target).html(tmp)
$(target).attr('data', uri)
}
function getPlaylistTracks(uri) {
function getPlaylistTracks (uri) {
if (playlists[uri] && playlists[uri].tracks) {
return Mopidy.when(playlists[uri].tracks);
return Mopidy.when(playlists[uri].tracks)
} else {
showLoading(true);
return mopidy.playlists.getItems({'uri': uri}).then(function(refs) {
return processPlaylistItems({'uri': uri, 'items': refs});
}, console.error);
showLoading(true)
return mopidy.playlists.getItems({'uri': uri}).then(function (refs) {
return processPlaylistItems({'uri': uri, 'items': refs})
}, console.error)
}
}
function getUris(tracks) {
var results = [];
function getUris (tracks) {
var results = []
for (var i = 0; i < tracks.length; i++) {
results.push(tracks[i].uri);
results.push(tracks[i].uri)
}
return results;
return results
}
function getTracksFromUri(uri, full_track_data) {
var returnTracksOrUris = function(tracks) {
return (full_track_data || false) ? tracks : getUris(tracks);
};
function getTracksFromUri (uri, full_track_data) {
var returnTracksOrUris = function (tracks) {
return full_track_data ? tracks : getUris(tracks)
}
if (customTracklists[uri]) {
return returnTracksOrUris(customTracklists[uri]);
return returnTracksOrUris(customTracklists[uri])
} else if (playlists[uri] && playlists[uri].tracks) {
return returnTracksOrUris(playlists[uri].tracks);
return returnTracksOrUris(playlists[uri].tracks)
}
return [];
return []
}
//convert time to human readable format
function timeFromSeconds(length) {
var d = Number(length);
var h = Math.floor(d / 3600);
var m = Math.floor(d % 3600 / 60);
var s = Math.floor(d % 3600 % 60);
return ((h > 0 ? h + ":" : "") + (m > 0 ? (h > 0 && m < 10 ? "0" : "") + m + ":" : "0:") + (s < 10 ? "0" : "") + s);
// convert time to human readable format
function timeFromSeconds (length) {
var d = Number(length)
var h = Math.floor(d / 3600)
var m = Math.floor(d % 3600 / 60)
var s = Math.floor(d % 3600 % 60)
return ((h > 0 ? h + ':' : '') + (m > 0 ? (h > 0 && m < 10 ? '0' : '') + m + ':' : '0:') + (s < 10 ? '0' : '') + s)
}
/******* Toast ***/
function toast(message, delay, textOnly) {
textOnl = textOnly || false;
message = message || "Loading...";
delay = delay || 1000;
/** ***** Toast ***/
function toast (message, delay, textOnly) {
textOnl = textOnly || false
message = message || 'Loading...'
delay = delay || 1000
$.mobile.loading('show', {
text: message,
textVisible: true,
theme: 'a',
textonly: textOnl
});
})
if (delay > 0) {
setTimeout(function() {
$.mobile.loading('hide');
}, delay);
setTimeout(function () {
$.mobile.loading('hide')
}, delay)
}
}
/******************
/** ****************
* Modal dialogs *
******************/
function showLoading(on) {
function showLoading (on) {
if (on) {
$("body").css("cursor", "progress");
$('body').css('cursor', 'progress')
$.mobile.loading('show', {
text: 'Loading data from ' + PROGRAM_NAME + '. Please wait...',
textVisible: true,
theme: 'a'
});
})
} else {
$("body").css("cursor", "default");
$.mobile.loading('hide');
$('body').css('cursor', 'default')
$.mobile.loading('hide')
}
}
function showOffline(on) {
function showOffline (on) {
if (on) {
$.mobile.loading('show', {
text: 'Trying to reach ' + PROGRAM_NAME + '. Please wait...',
textVisible: true,
theme: 'a'
});
})
} else {
$.mobile.loading('hide');
$.mobile.loading('hide')
}
}
// from http://dzone.com/snippets/validate-url-regexp
function validUri(str) {
var regexp = /^(mms|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/;
return regexp.test(str);
function validUri (str) {
var regexp = /^(mms|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!\-\/]))?/
return regexp.test(str)
}
function validServiceUri(str) {
return validUri(str) || isServiceUri(str);
function validServiceUri (str) {
return validUri(str) || isServiceUri(str)
}
function getScheme(uri) {
return uri.split(':')[0].toLowerCase();
function getScheme (uri) {
return uri.split(':')[0].toLowerCase()
}
function isStreamUri(uri) {
var a = validUri(uri);
var b = radioExtensionsList.indexOf(getScheme(uri)) >= 0;
return a || b;
function isStreamUri (uri) {
var a = validUri(uri)
var b = radioExtensionsList.indexOf(getScheme(uri)) >= 0
return a || b
}
function getMediaClass(uri) {
var scheme = getScheme(uri);
function getMediaClass (uri) {
var scheme = getScheme(uri)
for (var i = 0; i < uriClassList.length; i++) {
if (scheme == uriClassList[i][0]) {
return "fa " + uriClassList[i][1];
if (scheme === uriClassList[i][0]) {
return 'fa ' + uriClassList[i][1]
}
}
return '';
return ''
}
function getMediaHuman(uri) {
var scheme = getScheme(uri);
function getMediaHuman (uri) {
var scheme = getScheme(uri)
for (var i = 0; i < uriHumanList.length; i++) {
if (scheme == uriHumanList[i][0]) {
return uriHumanList[i][1];
if (scheme.toLowerCase() === uriHumanList[i][0].toLowerCase()) {
return uriHumanList[i][1]
}
}
return '';
return uri
}
function isServiceUri(uri) {
var scheme = getScheme(uri);
for (var i = 0; i < uriClassList.length; i++) {
if (scheme == uriClassList[i][0]) {
return true;
function isServiceUri (uri) {
var scheme = getScheme(uri)
var i = 0
for (i = 0; i < uriClassList.length; i++) {
if (scheme === uriClassList[i][0]) {
return true
}
}
for (var i = 0; i < radioExtensionsList.length; i++) {
if (scheme == radioExtensionsList[i]) {
return true;
for (i = 0; i < radioExtensionsList.length; i++) {
if (scheme === radioExtensionsList[i]) {
return true
}
}
return false;
return false
}
function isFavouritesPlaylist(playlist) {
return (playlist.name == STREAMS_PLAYLIST_NAME &&
getScheme(playlist.uri) == STREAMS_PLAYLIST_SCHEME);
function isFavouritesPlaylist (playlist) {
return (playlist.name === STREAMS_PLAYLIST_NAME &&
getScheme(playlist.uri) === STREAMS_PLAYLIST_SCHEME)
}
function isSpotifyStarredPlaylist(playlist) {
var starredRegex = /spotify:user:.*:starred/g;
return (starredRegex.test(playlist.uri) && playlist.name == 'Starred');
function isSpotifyStarredPlaylist (playlist) {
var starredRegex = /spotify:user:.*:starred/g
return (starredRegex.test(playlist.uri) && playlist.name === 'Starred')
}
/**
* Converts a URI to a jQuery-safe identifier. jQuery identifiers need to be
* unique per page and cannot contain special characters.
*
* @param {string} identifier - Identifier string to prefix to the URI. Can
* be used to ensure that the generated ID will be unique for the page that
* it will be included on. Also accepts jQuery identifiers starting with '#'.
*
* @param {string} uri - URI to encode, usually the URI of a Mopidy track.
*
* @param {boolean} includePrefix - Will prefix the generated identifier
* with the '#' character if set to 'true', ready to be passed to $() or
* jQuery().
*
* @return {string} - a string in the format '[#]identifier-encodedURI' that
* is safe to use as a jQuery identifier.
*/
function getjQueryID (identifier, uri, includePrefix) {
if (identifier.charAt(0) === '#' && !includePrefix) {
identifier = identifier.substr(1)
} else if (identifier.charAt(0) !== '#' && includePrefix) {
identifier = '#' + identifier
}
return identifier + '-' + fixedEncodeURIComponent(uri).replace(/([;&,\.\+\*\~':"\!\^#$%@\[\]\(\)=>\|])/g, '') // eslint-disable-line no-useless-escape
}
// Strict URI encoding as per https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
function fixedEncodeURIComponent (str) {
return encodeURIComponent(str).replace(/[!'()*]/g, function (c) {
return '%' + c.charCodeAt(0).toString(16)
})
}

File diff suppressed because it is too large Load Diff

View File

@ -1,92 +1,282 @@
/**
* @author Wouter van Wijk
*/
API_KEY = 'b6d34c3af91d62ab0ae00ab1b6fa8733';
API_SECRET = '2c631802c2285d5d5d1502462fe42a2b';
var fmcache;
var lastfm;
$(window).load(function () {
// create a Cache object
fmcache = new LastFMCache();
// create a LastFM object
lastfm = new LastFM({
apiKey : API_KEY,
apiSecret : API_SECRET,
cache : fmcache
});
});
function getCover(uri, images, size) {
var defUrl = 'images/default_cover.png';
$(images).attr('src', defUrl);
if (!uri) {
return;
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory()
} else {
root.images = factory()
}
}(this, function () {
'use strict'
mopidy.library.getImages({'uris': [uri]}).then(function(imageResults) {
var uri = Object.keys(imageResults)[0];
if (imageResults[uri].length > 0) {
$(images).attr('src', imageResults[uri][0].uri);
} else {
// Also check deprecated 'album.images' in case backend does not
// implement mopidy.library.getImages yet...
getCoverFromAlbum(uri, images, size);
}
});
}
var API_KEY = 'b6d34c3af91d62ab0ae00ab1b6fa8733'
var API_SECRET = '2c631802c2285d5d5d1502462fe42a2b'
// Note that this approach has been deprecated in Mopidy
// TODO: Remove when Mopidy no longer supports getting images
// with 'album.images'.
function getCoverFromAlbum(uri, images, size) {
mopidy.library.lookup({'uris': [uri]}).then(function(resultDict) {
var uri = Object.keys(resultDict)[0];
var track = resultDict[uri][0];
if (track.album && track.album.images && (track.album.images.length > 0) ) {
$(images).attr('src', track.album.images[0]);
} else {
// Fallback to last.fm
getCoverFromLastFm(track, images, size);
}
});
}
var images = {
function getCoverFromLastFm(track, images, size) {
var defUrl = 'images/default_cover.png';
if (!(track.album || track.artist)) {
return;
}
var albumname = track.album.name || '';
var artistname = '';
if ( track.album.artists && (track.album.artists.length > 0) ) {
// First look for the artist in the album
artistname = track.album.artists[0].name;
} else if (track.artists && (track.artists.length > 0) ) {
// Fallback to using artists for specific track
artistname = track.artists[0].name;
}
DEFAULT_ALBUM_URL: 'images/default_cover.png',
DEFAULT_ARTIST_URL: 'images/user_24x32.png',
lastfm.album.getInfo( {artist: artistname, album: albumname},
{ success: function(data) {
for (var i = 0; i < data.album.image.length; i++) {
if ( data.album.image[i].size == size) {
$(images).attr('src', data.album.image[i]['#text'] || defUrl);
lastFM: new LastFM({
apiKey: API_KEY,
apiSecret: API_SECRET,
cache: new LastFMCache()
}),
/* Extract artist information from Mopidy track. */
_getArtistInfo: function (track) {
var artistName = ''
var musicBrainzID = ''
if (track && track.artists && (track.artists.length > 0)) {
// First look for the artist info in the track
artistName = track.artists[0].name
musicBrainzID = track.artists[0].musicbrainz_id
}
if ((!artistName || !musicBrainzID) && (track && track.album && track.album.artists && track.album.artists.length > 0)) {
// Fallback to using artist info contained in the track's album
artistName = artistName || track.album.artists[0].name
musicBrainzID = musicBrainzID || track.album.artists[0].musicbrainz_id
}
return {mbid: musicBrainzID, name: artistName}
},
/* Utility function for retrieving artist informaton for the given track from last.fm */
_getLastFmArtistInfo: function (track) {
var artist = images._getArtistInfo(track)
var artistPromise = $.Deferred()
if (!(track && (track.musicbrainz_id || (track.name && artist && artist.name)))) {
// Avoid expensive last.fm call if tag information is missing.
return artistPromise.reject('none', 'Not enough tag information available for track to make last.fm call.')
}
var params = {}
// Only add arguments to parameter object if values are available for them.
if (track.musicbrainz_id) {
params.mbid = track.musicbrainz_id
}
if (track.name && artist.name) {
params.track = track.name
params.artist = artist.name
}
images.lastFM.track.getInfo(params, {success: function (data) {
artistPromise.resolve(data.track.artist)
}, error: function (code, message) {
artistPromise.reject(code, message)
}})
return artistPromise
},
/* Utility function for retrieving information for the given track from last.fm. */
_getLastFmAlbumInfo: function (track) {
var artist = images._getArtistInfo(track)
var albumPromise = $.Deferred()
if (!(track && track.album && (track.album.musicbrainz_id || (track.album.name && artist && artist.name)))) {
// Avoid expensive last.fm call if tag information is missing.
return albumPromise.reject('none', 'Not enough tag information available for album to make last.fm call.')
}
var musicBrainzID = track.album.musicbrainz_id
var albumName = track.album.name
var artistName = images._getArtistInfo(track).name
var params = {}
// Only add arguments to parameter object if values are available for them.
if (musicBrainzID) {
params.mbid = musicBrainzID
}
if (artistName && albumName) {
params.artist = artistName
params.album = albumName
}
images.lastFM.album.getInfo(params, {success: function (data) {
albumPromise.resolve(data)
}, error: function (code, message) {
albumPromise.reject(code, message)
}})
return albumPromise
},
/**
* Sets an HTML image element to contain the album cover art of the relevant Mopidy track.
*
* Potential sources for the album image will be interrogated in the following order until
* a suitable image URI is found:
* 1.) mopidy.library.getImages
* 2.) mopidy.models.Track.album.images (DEPRECATED)
* 3.) last.fm using the album MusicBrainz ID
* 4.) last.fm using the album name and track artist name
* 5.) last.fm using the album name and album artist name
* 6.) a default image
*
* @param {string} uri - The URI of the Mopidy track to retrieve the album cover image for.
* @param {string} img_element - The identifier of the HTML image element that will be used
* to render the image.
* @param {object} mopidy - The Mopidy.js object that should be used to communicate with the
* Mopidy server.
* @param {string} size - (Optional) The preferred size of the image. This parameter is only
* used in the last.fm lookups if Mopidy does not provide the image
* directly. Can be one of 'small', 'medium', 'large',
* 'extralarge' (default), or 'mega'.
*/
setAlbumImage: function (uri, img_element, mopidy, size) {
// Set default immediately while we're busy retrieving actual image.
$(img_element).attr('src', images.DEFAULT_ALBUM_URL)
if (!uri) {
return
}
size = size || 'extralarge'
mopidy.library.getImages({'uris': [uri]}).then(function (imageResults) {
var uri = Object.keys(imageResults)[0]
if (imageResults[uri].length > 0) {
$(img_element).attr('src', imageResults[uri][0].uri)
} else {
// Also check deprecated 'album.images' in case backend does not
// implement mopidy.library.getImages yet...
images._setDeprecatedAlbumImage(uri, img_element, mopidy, size)
}
}
}
});
}
})
},
function getArtistImage(nwartist, image, size) {
var defUrl = 'images/user_24x32.png';
lastfm.artist.getInfo({artist: nwartist}, {success: function(data){
for (var i = 0; i < data.artist.image.length; i++) {
if ( data.artist.image[i].size == size) {
$(image).attr('src', data.artist.image[i]['#text'] || defUrl);
// Note that this approach has been deprecated in Mopidy
// TODO: Remove when Mopidy no longer supports retrieving images
// from 'album.images'.
/* Set album image using mopidy.album.images. */
_setDeprecatedAlbumImage: function (uri, img_element, mopidy, size) {
if (!uri) {
$(img_element).attr('src', images.DEFAULT_ALBUM_URL)
return
}
size = size || 'extralarge'
mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) {
var uri = Object.keys(resultDict)[0]
var track = resultDict[uri][0]
if (track && track.album && track.album.images && track.album.images.length > 0) {
$(img_element).attr('src', track.album.images[0])
} else {
// Fallback to last.fm
images._setLastFmAlbumImage(track, img_element, size)
}
})
},
/* Lookup album image on last.fm using the provided Mopidy track. */
_setLastFmAlbumImage: function (track, img_element, size) {
if (!track || !(track.album || track.artists)) {
$(img_element).attr('src', images.DEFAULT_ALBUM_URL)
return
}
size = size || 'extralarge'
images._getLastFmAlbumInfo(track).then(function (data) {
for (var i = 0; i < data.album.image.length; i++) {
if (data.album.image[i].size === size) {
$(img_element).attr('src', data.album.image[i]['#text'] || images.DEFAULT_ALBUM_URL)
break
}
}
}, function (code, message) {
$(img_element).attr('src', images.DEFAULT_ALBUM_URL)
console.error('Error getting album info from last.fm (%s: %s)', code, message)
})
},
/**
* Sets an HTML image element to contain the artist image of the relevant Mopidy track.
*
* Potential sources of the artist image will be interrogated in the following order until
* a suitable image URI is found:
* 1.) mopidy.library.getImages
* 2.) last.fm using the artist MusicBrainz ID. If no artist ID is provided, it will be
* looked up on last.fm first using the track and album details.
* 3.) a default image
*
* @param {string} artist_uri - The URI of the Mopidy artist to retrieve the image for.
* @param {string} track_uri - The URI of the Mopidy track that will be used as a fallback
* if the artist URI does not provide any image results.
* @param {string} img_element - The identifier of the HTML image element that will be used
* to render the image.
* @param {object} mopidy - The Mopidy.js object that should be used to communicate with the
* Mopidy server.
* @param {string} size - (Optional) The preferred size of the image. This parameter is only
* used in the last.fm lookups if Mopidy does not provide the image
* directly. Can be one of 'small', 'medium', 'large',
* 'extralarge' (default), or 'mega'.
*/
setArtistImage: function (artist_uri, track_uri, img_element, mopidy, size) {
// Set default immediately while we're busy retrieving actual image.
$(img_element).attr('src', images.DEFAULT_ARTIST_URL)
if (!artist_uri && !track_uri) {
return
}
size = size || 'extralarge'
if (artist_uri) {
// Use artist as starting point for retrieving image.
mopidy.library.getImages({'uris': [artist_uri]}).then(function (imageResults) {
var uri = Object.keys(imageResults)[0]
if (imageResults[uri].length > 0) {
$(img_element).attr('src', imageResults[uri][0].uri)
} else {
// Fall back to using track as starting point for retrieving image.
images._setArtistImageFromTrack(track_uri, img_element, mopidy, size)
}
})
}
},
/* Set artist image using the supplied Mopidy track URI. */
_setArtistImageFromTrack: function (uri, img_element, mopidy, size) {
mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) {
var uri = Object.keys(resultDict)[0]
var track = resultDict[uri][0]
var artist = images._getArtistInfo(track)
if (artist.mbid) {
images._setLastFmArtistImage(artist.mbid, img_element, size)
} else {
// Look up unique MusicBrainz ID for artist first using the available track information
images._getLastFmArtistInfo(track).then(function (artist) {
images._setLastFmArtistImage(artist.mbid, img_element, size)
}, function (code, message) {
$(img_element).attr('src', images.DEFAULT_ARTIST_URL)
console.error('Error retrieving artist info from last.fm. (%s: %s)', code, message)
})
}
})
},
/* Set artist image using the supplied artist MusicBrainz ID. */
_setLastFmArtistImage: function (mbid, img_element, size) {
if (!mbid) {
// Avoid expensive last.fm call if tag information is missing.
$(img_element).attr('src', images.DEFAULT_ARTIST_URL)
return
}
size = size || 'extralarge'
images.lastFM.artist.getInfo({mbid: mbid}, {success: function (data) {
for (var i = 0; i < data.artist.image.length; i++) {
if (data.artist.image[i].size === size) {
$(img_element).attr('src', data.artist.image[i]['#text'] || images.DEFAULT_ARTIST_URL)
break
}
}
}, error: function (code, message) {
$(img_element).attr('src', images.DEFAULT_ARTIST_URL)
console.error('Error retrieving artist info from last.fm. (%s: %s)', code, message)
}})
}
}});
}
}
return images
}))

View File

@ -1,344 +1,349 @@
/*********************************
* Search
*********************************/
function searchPressed(key) {
var value = $('#searchinput').val();
switchContent('search');
if (key == 13) {
initSearch();
return false;
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory()
} else {
root.library = factory()
}
return true;
}
}(this, function () {
'use strict'
//init search
function initSearch() {
var value = $('#searchinput').val();
var searchService = $('#selectSearchService').val();
var library = {
if ((value.length < 100) && (value.length > 0)) {
showLoading(true);
//hide ios/android keyboard
document.activeElement.blur();
$("input").blur();
/** *******************************
* Search
*********************************/
searchPressed: function (key) {
var value = $('#searchinput').val()
switchContent('search')
delete customTracklists[URI_SCHEME+':allresultscache'];
delete customTracklists[URI_SCHEME+':artistresultscache'];
delete customTracklists[URI_SCHEME+':albumresultscache'];
delete customTracklists[URI_SCHEME+':trackresultscache'];
$("#searchartists").hide();
$("#searchalbums").hide();
$("#searchtracks").hide();
if (key === 13) {
library.initSearch()
return false
}
return true
},
if (searchService != 'all') {
mopidy.library.search({'query': {any:[value]}, 'uris': [searchService + ':']}).then(processSearchResults, console.error);
} else {
mopidy.getUriSchemes().then(function (schemes) {
var query = {},
uris = [];
// init search
initSearch: function () {
var value = $('#searchinput').val()
var searchService = $('#selectSearchService').val()
$.cookie('searchScheme', searchService, { expires: 365 })
var regexp = $.map(schemes, function (scheme) {
return '^' + scheme + ':';
}).join('|');
if ((value.length < 100) && (value.length > 0)) {
showLoading(true)
// hide ios/android keyboard
document.activeElement.blur()
$('input').blur()
var match = value.match(regexp);
if (match) {
var scheme = match[0];
query = {uri: [value]};
uris = [scheme];
delete customTracklists[URI_SCHEME + ':trackresultscache']
$('#searchartists').hide()
$('#searchalbums').hide()
$('#searchtracks').hide()
if (searchService !== 'all') {
mopidy.library.search({'query': {any: [value]}, 'uris': [searchService + ':']}).then(library.processSearchResults, console.error)
} else {
query = {any: [value]};
}
mopidy.library.search({'query': query, 'uris': uris}).then(processSearchResults, console.error);
});
}
}
}
mopidy.getUriSchemes().then(function (schemes) {
var query = {}
var uris = []
/********************************************************
* process results of a search
*********************************************************/
var regexp = $.map(schemes, function (scheme) {
return '^' + scheme + ':'
}).join('|')
//# speed clone http://jsperf.com/cloning-an-object/2
function clone(obj) {
var target = {};
for (var i in obj) {
if (obj.hasOwnProperty(i)) {
target[i] = obj[i];
}
}
return target;
}
function processSearchResults(resultArr) {
$(SEARCH_TRACK_TABLE).empty();
$(SEARCH_ARTIST_TABLE).empty();
$(SEARCH_ALBUM_TABLE).empty();
// Merge results from different backends.
// TODO should of coures have multiple tables
var results = {'tracks': [], 'artists': [], 'albums': []};
var j, emptyResult = true;
for (var i = 0; i < resultArr.length; i++) {
if (resultArr[i].tracks) {
for (j = 0; j < resultArr[i].tracks.length; j++) {
results.tracks.push(resultArr[i].tracks[j]);
emptyResult = false;
}
}
if (resultArr[i].artists) {
for (j = 0; j < resultArr[i].artists.length; j++) {
results.artists.push(resultArr[i].artists[j]);
emptyResult = false;
}
}
if (resultArr[i].albums) {
for (j = 0; j < resultArr[i].albums.length; j++) {
results.albums.push(resultArr[i].albums[j]);
emptyResult = false;
}
}
}
customTracklists[URI_SCHEME+':trackresultscache'] = results.tracks;
if (emptyResult) {
toast('No results');
showLoading(false);
return false;
}
if (results.artists.length > 0) {
$("#searchartists").show();
}
if (results.albums.length > 0) {
$("#searchalbums").show();
}
if (results.tracks.length > 0) {
$("#searchtracks").show();
}
// Returns a string where {x} in template is replaced by tokens[x].
function theme(template, tokens) {
return template.replace(/{[^}]+}/g, function(match) {
return tokens[match.slice(1,-1)];
});
}
// 'Show more' pattern
var showMorePattern = '<li onclick="$(this).hide().siblings().show(); return false;"><a>Show {count} more</a></li>';
// Artist results
var child = '';
var pattern = '<li><a href="#" onclick="return showArtist(this.id)" id={id}><i class="{class}"></i> <strong>{name}</strong></a></li>';
var tokens;
for (var i = 0; i < results.artists.length; i++) {
tokens = {
'id': results.artists[i].uri,
'name': results.artists[i].name,
'class': getMediaClass(results.artists[i].uri)
};
// Add 'Show all' item after a certain number of hits.
if (i == 4 && results.artists.length > 5) {
child += theme(showMorePattern, {'count': results.artists.length - i});
pattern = pattern.replace('<li>', '<li class="overflow">');
}
child += theme(pattern, tokens);
}
// Inject list items, refresh listview and hide superfluous items.
$(SEARCH_ARTIST_TABLE).html(child).listview('refresh').find('.overflow').hide();
// Album results
child = '';
pattern = '<li><a href="#" onclick="return showAlbum(this.id)" id="{albumId}">';
pattern += '<h5 data-role="heading"><i class="{class}"></i> {albumName}</h5>';
pattern += '<p data-role="desc">{artistName}</p>';
pattern += '</a></li>';
for (var i = 0; i < results.albums.length; i++) {
tokens = {
'albumId': results.albums[i].uri,
'albumName': results.albums[i].name,
'artistName': '',
'albumYear': results.albums[i].date,
'class': getMediaClass(results.albums[i].uri)
};
if (results.albums[i].artists) {
for (var j = 0; j < results.albums[i].artists.length; j++) {
if (results.albums[i].artists[j].name) {
tokens.artistName += results.albums[i].artists[j].name + ' ';
var match = value.match(regexp)
if (match) {
var scheme = match[0]
query = {uri: [value]}
uris = [scheme]
} else {
query = {any: [value]}
}
mopidy.library.search({'query': query, 'uris': uris}).then(library.processSearchResults, console.error)
})
}
}
}
if (tokens.albumYear) {
tokens.artistName += '(' + tokens.albumYear + ')';
}
// Add 'Show all' item after a certain number of hits.
if (i == 4 && results.albums.length > 5) {
child += theme(showMorePattern, {'count': results.albums.length - i});
pattern = pattern.replace('<li>', '<li class="overflow">');
}
},
child += theme(pattern, tokens);
}
// Inject list items, refresh listview and hide superfluous items.
$(SEARCH_ALBUM_TABLE).html(child).listview('refresh').find('.overflow').hide();
/** ******************************************************
* process results of a search
*********************************************************/
processSearchResults: function (resultArr) {
$(SEARCH_TRACK_TABLE).empty()
$(SEARCH_ARTIST_TABLE).empty()
$(SEARCH_ALBUM_TABLE).empty()
$('#expandsearch').show();
// Merge results from different backends.
// TODO should of coures have multiple tables
var results = {'tracks': [], 'artists': [], 'albums': []}
var i, j
var emptyResult = true
// Track results
resultsToTables(results.tracks, SEARCH_TRACK_TABLE, URI_SCHEME+':trackresultscache');
showLoading(false);
}
function toggleSearch() {
$("#albumresulttable tr").removeClass('hidden');
$("#artistresulttable tr").removeClass('hidden');
}
/*********************************
* Playlists & Browse
*********************************/
function getPlaylists() {
// get playlists without tracks
mopidy.playlists.asList().then(processGetPlaylists, console.error);
}
function getBrowseDir(rootdir) {
// get directory to browse
showLoading(true);
if (!rootdir) {
browseStack.pop();
rootdir = browseStack[browseStack.length - 1];
} else {
browseStack.push(rootdir);
}
if (!rootdir) {
rootdir = null;
}
mopidy.library.browse({'uri': rootdir}).then(processBrowseDir, console.error);
}
function getCurrentPlaylist() {
mopidy.tracklist.getTlTracks().then(processCurrentPlaylist, console.error);
}
/********************************************************
* Show tracks of playlist
********************************************************/
function togglePlaylists() {
if ($(window).width() <= 960) {
$('#playlisttracksdiv').toggle();
//Hide other div
($('#playlisttracksdiv').is(":visible")) ? $('#playlistslistdiv').hide() : $('#playlistslistdiv').show();
} else {
$('#playlisttracksdiv').show();
$('#playlistslistdiv').show();
}
return true;
}
function showTracklist(uri) {
$(PLAYLIST_TABLE).empty();
togglePlaylists();
var tracks = getPlaylistTracks(uri).then(function(tracks) {
resultsToTables(tracks, PLAYLIST_TABLE, uri);
});
showLoading(false);
updatePlayIcons(uri);
$('#playlistslist li a').each(function() {
$(this).removeClass("playlistactive");
if (this.id == uri) {
$(this).addClass('playlistactive');
}
});
return false;
}
/******
* Lookups
*/
function showArtist(nwuri) {
$('#popupQueue').popup('close');
$('#popupTracks').popup('close');
$('#controlsmodal').popup('close');
$(ARTIST_TABLE).empty();
//TODO cache
$('#h_artistname').html('');
showLoading(true);
mopidy.library.lookup({'uris': [nwuri]}).then(function(resultDict) {
var resultArr = resultDict[nwuri];
resultArr.uri = nwuri;
processArtistResults(resultArr);
}, console.error);
switchContent('artists', nwuri);
scrollToTop();
return false;
}
function showAlbum(uri) {
$('#popupQueue').popup('close');
$('#popupTracks').popup('close');
$('#controlsmodal').popup('close');
$(ALBUM_TABLE).empty();
//fill from cache
var pl = getTracksFromUri(uri, true);
if (pl.length>0) {
albumTracksToTable(pl, ALBUM_TABLE, uri);
var albumname = getAlbum(pl);
var artistname = getArtist(pl);
$('#h_albumname').html(albumname);
$('#h_albumartist').html(artistname);
$('#coverpopupalbumname').html(albumname);
$('#coverpopupartist').html(artistname);
showLoading(false);
mopidy.library.lookup({'uris': [uri]}).then(function(resultDict) {
var resultArr = resultDict[uri];
resultArr.uri = uri;
processAlbumResults(resultArr);
}, console.error);
} else {
showLoading(true);
$('#h_albumname').html('');
$('#h_albumartist').html('');
mopidy.library.lookup({'uris': [uri]}).then(function(resultDict) {
var resultArr = resultDict[uri];
resultArr.uri = uri;
processAlbumResults(resultArr);
}, console.error);
}
//show page
switchContent('albums', uri);
scrollToTop();
return false;
}
function getSearchSchemes() {
mopidy.getUriSchemes().then(
function(schemesArray) {
var humanIndex;
$("#selectSearchService").children().remove().end();
$("#selectSearchService").append(new Option('All services', 'all'));
for (var i = 0; i < schemesArray.length; i++) {
for (var j = 0; j < uriHumanList.length; j++) {
if (uriHumanList[j][0] == schemesArray[i].toLowerCase() ) {
$("#selectSearchService").append(new Option(uriHumanList[j][1], schemesArray[i]));
for (i = 0; i < resultArr.length; i++) {
if (resultArr[i].tracks) {
for (j = 0; j < resultArr[i].tracks.length; j++) {
results.tracks.push(resultArr[i].tracks[j])
emptyResult = false
}
}
if (resultArr[i].artists) {
for (j = 0; j < resultArr[i].artists.length; j++) {
results.artists.push(resultArr[i].artists[j])
emptyResult = false
}
}
if (resultArr[i].albums) {
for (j = 0; j < resultArr[i].albums.length; j++) {
results.albums.push(resultArr[i].albums[j])
emptyResult = false
}
}
}
$("#selectSearchService").selectmenu( "refresh", true );
}, console.error
);
}
customTracklists[URI_SCHEME + ':trackresultscache'] = results.tracks
if (emptyResult) {
$('#searchtracks').show()
$(SEARCH_TRACK_TABLE).append(
'<li class="song albumli"><a href="#"><h1><i></i>No tracks found...</h1></a></li>'
)
toast('No results')
showLoading(false)
return false
}
if (results.artists.length > 0) {
$('#searchartists').show()
}
if (results.albums.length > 0) {
$('#searchalbums').show()
}
if (results.tracks.length > 0) {
$('#searchtracks').show()
}
// Returns a string where {x} in template is replaced by tokens[x].
function theme (template, tokens) {
return template.replace(/{[^}]+}/g, function (match) {
return tokens[match.slice(1, -1)]
})
}
// 'Show more' pattern
var showMorePattern = '<li onclick="$(this).hide().siblings().show(); return false;"><a>Show {count} more</a></li>'
// Artist results
var child = ''
var pattern = '<li><a href="#" onclick="return library.showArtist(this.id)" id={id}><i class="{class}"></i> <strong>{name}</strong></a></li>'
var tokens
for (i = 0; i < results.artists.length; i++) {
tokens = {
'id': results.artists[i].uri,
'name': results.artists[i].name,
'class': getMediaClass(results.artists[i].uri)
}
// Add 'Show all' item after a certain number of hits.
if (i === 4 && results.artists.length > 5) {
child += theme(showMorePattern, {'count': results.artists.length - i})
pattern = pattern.replace('<li>', '<li class="overflow">')
}
child += theme(pattern, tokens)
}
// Inject list items, refresh listview and hide superfluous items.
$(SEARCH_ARTIST_TABLE).html(child).listview('refresh').find('.overflow').hide()
// Album results
child = ''
pattern = '<li><a href="#" onclick="return library.showAlbum(this.id)" id="{albumId}">'
pattern += '<h5 data-role="heading"><i class="{class}"></i> {albumName}</h5>'
pattern += '<p data-role="desc">{artistName}</p>'
pattern += '</a></li>'
for (i = 0; i < results.albums.length; i++) {
tokens = {
'albumId': results.albums[i].uri,
'albumName': results.albums[i].name,
'artistName': '',
'albumYear': results.albums[i].date,
'class': getMediaClass(results.albums[i].uri)
}
if (results.albums[i].artists) {
for (j = 0; j < results.albums[i].artists.length; j++) {
if (results.albums[i].artists[j].name) {
tokens.artistName += results.albums[i].artists[j].name + ' '
}
}
}
if (tokens.albumYear) {
tokens.artistName += '(' + tokens.albumYear + ')'
}
// Add 'Show all' item after a certain number of hits.
if (i === 4 && results.albums.length > 5) {
child += theme(showMorePattern, {'count': results.albums.length - i})
pattern = pattern.replace('<li>', '<li class="overflow">')
}
child += theme(pattern, tokens)
}
// Inject list items, refresh listview and hide superfluous items.
$(SEARCH_ALBUM_TABLE).html(child).listview('refresh').find('.overflow').hide()
// Track results
resultsToTables(results.tracks, SEARCH_TRACK_TABLE, URI_SCHEME + ':trackresultscache')
showLoading(false)
},
/** *******************************
* Playlists & Browse
*********************************/
getPlaylists: function () {
// get playlists without tracks
mopidy.playlists.asList().then(processGetPlaylists, console.error)
},
getBrowseDir: function (rootdir) {
// get directory to browse
showLoading(true)
if (!rootdir) {
browseStack.pop()
rootdir = browseStack[browseStack.length - 1]
} else {
browseStack.push(rootdir)
}
if (!rootdir) {
rootdir = null
}
mopidy.library.browse({'uri': rootdir}).then(processBrowseDir, console.error)
},
getCurrentPlaylist: function () {
mopidy.tracklist.getTlTracks().then(processCurrentPlaylist, console.error)
},
/** ******************************************************
* Show tracks of playlist
********************************************************/
togglePlaylists: function () {
if ($(window).width() <= 960) {
$('#playlisttracksdiv').toggle();
// Hide other div
($('#playlisttracksdiv').is(':visible')) ? $('#playlistslistdiv').hide() : $('#playlistslistdiv').show()
} else {
$('#playlisttracksdiv').show()
$('#playlistslistdiv').show()
}
return true
},
/** **********
* Lookups
************/
showTracklist: function (uri) {
showLoading(true)
$(PLAYLIST_TABLE).empty()
library.togglePlaylists()
var tracks = getPlaylistTracks(uri).then(function (tracks) {
resultsToTables(tracks, PLAYLIST_TABLE, uri, 'return library.togglePlaylists();', true)
showLoading(false)
})
updatePlayIcons(uri, '', controls.getIconForAction())
$('#playlistslist li a').each(function () {
$(this).removeClass('playlistactive')
if (this.id === uri) {
$(this).addClass('playlistactive')
}
})
return false
},
showArtist: function (nwuri) {
$('#popupQueue').popup('close')
$('#popupTracks').popup('close')
$('#controlsmodal').popup('close')
$(ARTIST_TABLE).empty()
// TODO cache
$('#h_artistname').html('')
showLoading(true)
mopidy.library.lookup({'uris': [nwuri]}).then(function (resultDict) {
var resultArr = resultDict[nwuri]
resultArr.uri = nwuri
processArtistResults(resultArr)
}, console.error)
switchContent('artists', nwuri)
scrollToTop()
return false
},
showAlbum: function (uri) {
$('#popupQueue').popup('close')
$('#popupTracks').popup('close')
$('#controlsmodal').popup('close')
$(ALBUM_TABLE).empty()
// fill from cache
var pl = getTracksFromUri(uri, true)
if (pl.length > 0) {
albumTracksToTable(pl, ALBUM_TABLE, uri)
var albumname = getAlbum(pl)
var artistname = getArtist(pl)
$('#h_albumname').html(albumname)
$('#h_albumartist').html(artistname)
$('#coverpopupalbumname').html(albumname)
$('#coverpopupartist').html(artistname)
showLoading(false)
mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) {
var resultArr = resultDict[uri]
resultArr.uri = uri
processAlbumResults(resultArr)
}, console.error)
} else {
showLoading(true)
$('#h_albumname').html('')
$('#h_albumartist').html('')
mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) {
var resultArr = resultDict[uri]
resultArr.uri = uri
processAlbumResults(resultArr)
}, console.error)
}
// show page
switchContent('albums', uri)
scrollToTop()
return false
},
getSearchSchemes: function (searchBlacklist, mopidy) {
var backendName
var searchScheme = $.cookie('searchScheme')
if (searchScheme) {
searchScheme = searchScheme.replace(/"/g, '')
} else {
searchScheme = 'all'
}
$('#selectSearchService').empty()
$('#selectSearchService').append(new Option('All services', 'all'))
mopidy.getUriSchemes().then(function (schemesArray) {
schemesArray = schemesArray.filter(function (el) {
return searchBlacklist.indexOf(el) < 0
})
for (var i = 0; i < schemesArray.length; i++) {
backendName = getMediaHuman(schemesArray[i])
backendName = backendName.charAt(0).toUpperCase() + backendName.slice(1)
$('#selectSearchService').append(new Option(backendName, schemesArray[i]))
}
$('#selectSearchService').val(searchScheme)
$('#selectSearchService').selectmenu('refresh', true)
}, console.error)
}
}
return library
}))

View File

@ -5,249 +5,250 @@
*
*/
/********************************************************
/** ******************************************************
* process results of a (new) currently playing track
*********************************************************/
function processCurrenttrack(data) {
setSongInfo(data);
function processCurrenttrack (data) {
setSongInfo(data)
}
/********************************************************
/** ******************************************************
* process results of volume
*********************************************************/
function processVolume(data) {
setVolume(data);
function processVolume (data) {
controls.setVolume(data)
}
/********************************************************
/** ******************************************************
* process results of mute
*********************************************************/
function processMute(data) {
setMute(data);
function processMute (data) {
controls.setMute(data)
}
/********************************************************
/** ******************************************************
* process results of a repeat
*********************************************************/
function processRepeat(data) {
setRepeat(data);
function processRepeat (data) {
controls.setRepeat(data)
}
/********************************************************
/** ******************************************************
* process results of random
*********************************************************/
function processRandom(data) {
setRandom(data);
function processRandom (data) {
controls.setRandom(data)
}
/********************************************************
/** ******************************************************
* process results of consume
*********************************************************/
function processConsume(data) {
setConsume(data);
function processConsume (data) {
controls.setConsume(data)
}
/********************************************************
/** ******************************************************
* process results of single
*********************************************************/
function processSingle(data) {
setSingle(data);
function processSingle (data) {
controls.setSingle(data)
}
/********************************************************
/** ******************************************************
* process results of current position
*********************************************************/
function processCurrentposition(data) {
setPosition(parseInt(data));
function processCurrentposition (data) {
controls.setPosition(parseInt(data))
}
/********************************************************
/** ******************************************************
* process results playstate
*********************************************************/
function processPlaystate(data) {
if (data == 'playing') {
setPlayState(true);
function processPlaystate (data) {
if (data === 'playing') {
controls.setPlayState(true)
} else {
setPlayState(false);
controls.setPlayState(false)
}
}
/********************************************************
/** ******************************************************
* process results of a browse list
*********************************************************/
function processBrowseDir(resultArr) {
var backHtml = '<li style="background-color:#ccc"><a href="#" onclick="return getBrowseDir();"><h1 class="trackname"><i class="fa fa-arrow-circle-left"></i> Back</h1></a></li>';
if ( (!resultArr) || (resultArr === '') || (resultArr.length === 0) ) {
$('#browsepath').html('No tracks found...');
$('#browselist').html(backHtml);
showLoading(false);
return;
}
$('#browselist').empty();
var child = "", rooturi = "", uri = resultArr[0].uri;
//check root uri
//find last : or / (spltting the result)
//do it twice, since.
var colonindex = uri.lastIndexOf(':');
var slashindex = uri.lastIndexOf('/');
var lastindex = (colonindex > slashindex) ? colonindex : slashindex;
rooturi = uri.slice(0, lastindex);
if (resultArr[0].type == 'track' ) {
rooturi = rooturi.replace(":track:", ":directory:");
}
colonindex = rooturi.lastIndexOf(':');
slashindex = rooturi.lastIndexOf('/');
lastindex = (colonindex > slashindex) ? colonindex : slashindex;
rooturi = rooturi.slice(0, lastindex);
function processBrowseDir (resultArr) {
$(BROWSE_TABLE).empty()
if (browseStack.length > 0) {
child += backHtml;
renderSongLiBackButton(resultArr, BROWSE_TABLE, 'return library.getBrowseDir();')
}
if (!resultArr || resultArr.length === 0) {
showLoading(false)
return
}
uris = []
var ref, previousRef, nextRef
var uri = resultArr[0].uri
var length = 0 || resultArr.length
customTracklists[BROWSE_TABLE] = []
var html = ''
browseTracks = [];
for (var i = 0, index = 0; i < resultArr.length; i++) {
iconClass = getMediaClass(resultArr[i].uri);
if (resultArr[i].type == 'track') {
//console.log(resultArr[i]);
mopidy.library.lookup({'uris': [resultArr[i].uri]}).then(function (resultDict) {
var lookupUri = Object.keys(resultDict)[0];
popupData[lookupUri] = resultDict[lookupUri][0];
browseTracks.push(resultDict[lookupUri][0]);
}, console.error);
child += '<li class="song albumli" id="browselisttracks-' + resultArr[i].uri + '">' +
'<a href="#" class="moreBtn" onclick="return popupTracks(event, \'' + uri + '\', \'' + resultArr[i].uri + '\', \'' + index + '\');">' +
'<i class="fa fa-ellipsis-v"></i></a>' +
'<a href="#" class="browsetrack" onclick="return playBrowsedTracks(PLAY_ALL, ' + index + ');" id="' + resultArr[i].uri +
'"><h1 class="trackname"><i class="' + iconClass + '"></i> ' + resultArr[i].name + '</h1></a></li>';
index++;
if (resultArr[i].type === 'track') {
previousRef = ref || undefined
nextRef = i < resultArr.length - 1 ? resultArr[i + 1] : undefined
ref = resultArr[i]
// TODO: consolidate usage of various arrays for caching URIs, Refs, and Tracks
popupData[ref.uri] = ref
customTracklists[BROWSE_TABLE].push(ref)
uris.push(ref.uri)
html += renderSongLi(previousRef, ref, nextRef, BROWSE_TABLE, '', BROWSE_TABLE, index, resultArr.length)
index++
} else {
var iconClass = ''
if (browseStack.length > 0) {
iconClass="fa fa-folder-o";
iconClass = 'fa fa-folder-o'
} else {
iconClass = getMediaClass(resultArr[i].uri)
}
child += '<li><a href="#" onclick="return getBrowseDir(this.id);" id="' + resultArr[i].uri +
'""><h1 class="trackname"><i class="' + iconClass + '"></i> ' + resultArr[i].name + '</h1></a></li>';
html += '<li><a href="#" onclick="return library.getBrowseDir(this.id);" id="' + resultArr[i].uri + '">' +
'<h1><i class="' + iconClass + '"></i> ' + resultArr[i].name + '</h1></a></li>'
}
}
$('#browselist').html(child);
if (browseStack.length > 0 ) {
child = getMediaHuman(uri);
iconClass = getMediaClass(uri);
$('#browsepath').html('<i class="' + iconClass + '"></i> ' + child);
$(BROWSE_TABLE).append(html)
updatePlayIcons(songdata.track.uri, songdata.tlid, controls.getIconForAction())
if (uris.length > 0) {
mopidy.library.lookup({'uris': uris}).then(function (resultDict) {
// Break into albums and put in tables
var track, previousTrack, nextTrack, uri
$.each(resultArr, function (i, ref) {
if (ref.type === 'track') {
previousTrack = track || undefined
if (i < resultArr.length - 1 && resultDict[resultArr[i + 1].uri]) {
nextTrack = resultDict[resultArr[i + 1].uri][0]
} else {
nextTrack = undefined
}
track = resultDict[ref.uri][0]
popupData[track.uri] = track // Need full track info in popups in order to display albums and artists.
if (uris.length === 1 || (previousTrack && !hasSameAlbum(previousTrack, track) && !hasSameAlbum(track, nextTrack))) {
renderSongLiAlbumInfo(track, BROWSE_TABLE)
}
renderSongLiDivider(previousTrack, track, nextTrack, BROWSE_TABLE)
}
})
showLoading(false)
}, console.error)
} else {
$('#browsepath').html('');
showLoading(false)
}
updatePlayIcons(songdata.track.uri, songdata.tlid);
showLoading(false);
}
/********************************************************
/** ******************************************************
* process results of list of playlists of the user
*********************************************************/
function processGetPlaylists(resultArr) {
function processGetPlaylists (resultArr) {
if ((!resultArr) || (resultArr === '')) {
$('#playlistslist').empty();
return;
$('#playlistslist').empty()
return
}
var tmp = '', favourites = '', starred = '';
var tmp = ''
var favourites = ''
var starred = ''
for (var i = 0; i < resultArr.length; i++) {
var li_html = '<li><a href="#" onclick="return showTracklist(this.id);" id="' + resultArr[i].uri + '">';
if(isSpotifyStarredPlaylist(resultArr[i])) {
starred = li_html + '&#9733; Spotify Starred Tracks</a></li>' + tmp;
var li_html = '<li><a href="#" onclick="return library.showTracklist(this.id);" id="' + resultArr[i].uri + '">'
if (isSpotifyStarredPlaylist(resultArr[i])) {
starred = li_html + '&#9733; Spotify Starred Tracks</a></li>' + tmp
} else if (isFavouritesPlaylist(resultArr[i])) {
favourites = li_html + '&hearts; Musicbox Favourites</a></li>';
favourites = li_html + '&hearts; Musicbox Favourites</a></li>'
} else {
tmp = tmp + li_html + '<i class="' + getMediaClass(resultArr[i].uri) + '"></i> ' + resultArr[i].name + '</a></li>';
tmp = tmp + li_html + '<i class="' + getMediaClass(resultArr[i].uri) + '"></i> ' + resultArr[i].name + '</a></li>'
}
}
// Prepend the user's Spotify "Starred" playlist and favourites to the results. (like Spotify official client).
tmp = favourites + starred + tmp;
$('#playlistslist').html(tmp);
scrollToTracklist();
showLoading(false);
tmp = favourites + starred + tmp
$('#playlistslist').html(tmp)
scrollToTracklist()
showLoading(false)
}
/********************************************************
/** ******************************************************
* process results of a returned list of playlist track refs
*********************************************************/
function processPlaylistItems(resultDict) {
function processPlaylistItems (resultDict) {
if (resultDict.items.length === 0) {
console.log('Playlist', resultDict.uri, 'is empty');
showLoading(false);
return;
console.log('Playlist', resultDict.uri, 'is empty')
showLoading(false)
return
}
var trackUris = [];
var trackUris = []
for (i = 0; i < resultDict.items.length; i++) {
trackUris.push(resultDict.items[i].uri);
trackUris.push(resultDict.items[i].uri)
}
return mopidy.library.lookup({'uris': trackUris}).then(function(tracks) {
return mopidy.library.lookup({'uris': trackUris}).then(function (tracks) {
// Transform from dict to list and cache result
var newplaylisturi = resultDict.uri;
playlists[newplaylisturi] = {'uri':newplaylisturi, 'tracks':[]};
var newplaylisturi = resultDict.uri
playlists[newplaylisturi] = {'uri': newplaylisturi, 'tracks': []}
for (i = 0; i < trackUris.length; i++) {
playlists[newplaylisturi].tracks.push(tracks[trackUris[i]][0]);
playlists[newplaylisturi].tracks.push(tracks[trackUris[i]][0])
}
showLoading(false);
return playlists[newplaylisturi].tracks;
});
showLoading(false)
return playlists[newplaylisturi].tracks
})
}
/********************************************************
/** ******************************************************
* process results of the queue, the current playlist
*********************************************************/
function processCurrentPlaylist(resultArr) {
currentplaylist = resultArr;
resultsToTables(currentplaylist, CURRENT_PLAYLIST_TABLE);
mopidy.playback.getCurrentTlTrack().then(processCurrenttrack, console.error);
updatePlayIcons(songdata.track.uri, songdata.tlid);
function processCurrentPlaylist (resultArr) {
currentplaylist = resultArr
resultsToTables(currentplaylist, CURRENT_PLAYLIST_TABLE)
mopidy.playback.getCurrentTlTrack().then(processCurrenttrack, console.error)
updatePlayIcons(songdata.track.uri, songdata.tlid, controls.getIconForAction())
}
/********************************************************
/** ******************************************************
* process results of an artist lookup
*********************************************************/
function processArtistResults(resultArr) {
function processArtistResults (resultArr) {
if (!resultArr || (resultArr.length === 0)) {
$('#h_artistname').text('Artist not found...');
getCover('', '#artistviewimage, #artistpopupimage', 'extralarge');
showLoading(false);
return;
$('#h_artistname').text('Artist not found...')
images.setAlbumImage('', '#artistviewimage, #artistpopupimage', mopidy)
showLoading(false)
return
}
customTracklists[resultArr.uri] = resultArr;
customTracklists[resultArr.uri] = resultArr
resultsToTables(resultArr, ARTIST_TABLE, resultArr.uri);
var artistname = getArtist(resultArr);
$('#h_artistname, #artistpopupname').html(artistname);
getArtistImage(artistname, '#artistviewimage, #artistpopupimage', 'extralarge');
showLoading(false);
resultsToTables(resultArr, ARTIST_TABLE, resultArr.uri)
var artistname = getArtist(resultArr)
$('#h_artistname, #artistpopupname').html(artistname)
images.setArtistImage(resultArr.uri, resultArr[0].uri, '#artistviewimage, #artistpopupimage', mopidy)
showLoading(false)
}
/********************************************************
/** ******************************************************
* process results of an album lookup
*********************************************************/
function processAlbumResults(resultArr) {
function processAlbumResults (resultArr) {
if (!resultArr || (resultArr.length === 0)) {
$('#h_albumname').text('Album not found...');
getCover('', '#albumviewcover, #coverpopupimage', 'extralarge');
showLoading(false);
return;
$('#h_albumname').text('Album not found...')
images.setAlbumImage('', '#albumviewcover, #coverpopupimage', mopidy)
showLoading(false)
return
}
customTracklists[resultArr.uri] = resultArr;
albumTracksToTable(resultArr, ALBUM_TABLE, resultArr.uri);
var albumname = getAlbum(resultArr);
var artistname = getArtist(resultArr);
$('#h_albumname').html(albumname);
$('#h_albumartist').html(artistname);
$('#coverpopupalbumname').html(albumname);
$('#coverpopupartist').html(artistname);
getCover(resultArr[0].uri, '#albumviewcover, #coverpopupimage', 'extralarge');
showLoading(false);
customTracklists[resultArr.uri] = resultArr
albumTracksToTable(resultArr, ALBUM_TABLE, resultArr.uri)
var albumname = getAlbum(resultArr)
var artistname = getArtist(resultArr)
$('#h_albumname').html(albumname)
$('#h_albumartist').html(artistname)
$('#coverpopupalbumname').html(albumname)
$('#coverpopupartist').html(artistname)
images.setAlbumImage(resultArr[0].uri, '#albumviewcover, #coverpopupimage', mopidy)
showLoading(false)
}

View File

@ -1,176 +0,0 @@
var progressTimer;
var progressElement = document.getElementById('trackslider');
var positionNode = document.createTextNode('');
var durationNode = document.createTextNode('');
var START_BEATS = 5; // 0.5 seconds, needs to be less than 1s to prevent unwanted updates.
var RUN_BEATS = 300; // 30 seconds assuming default timer update rate of 100ms
var callbackHeartbeats = 0; // Timer will check syncs on every n-number of calls.
var targetPosition = null;
var MAX_SYNCS = 5; // Maximum number of consecutive successful syncs to perform.
var syncsLeft = MAX_SYNCS;
var synced = false;
var consecutiveSyncs = 0;
document.getElementById('songelapsed').appendChild(positionNode);
document.getElementById('songlength').appendChild(durationNode);
function timerCallback(position, duration, isRunning) {
updateTimers(position, duration, isRunning);
if (callbackHeartbeats === 0) {
callbackHeartbeats = getHeartbeat();
}
if (mopidy && position > 0) {
// Mopidy and timer are both initialized.
if (callbackHeartbeats-- == 1) {
// Get time position from Mopidy on every nth callback until
// synced.
mopidy.playback.getTimePosition().then(
function(mopidy_position) {
syncTimer(position, mopidy_position);
}
);
}
}
}
function updateTimers(position, duration, isRunning) {
var ready = !(duration == Infinity && position === 0 && !isRunning); // Timer has been properly initialized.
var streaming = (duration == Infinity && position > 0); // Playing a stream.
var ok = synced && isRunning; // Normal operation.
var syncing = !synced && isRunning; // Busy syncing.
if (!ready) {
//Make sure that default values are displayed while the timer is being initialized.
positionNode.nodeValue = '';
durationNode.nodeValue = '';
$("#trackslider").val(0).slider('refresh');
} else {
durationNode.nodeValue = format(duration || Infinity);
if (syncing) {
if (!targetPosition) {
// Waiting for Mopidy to provide a target position.
positionNode.nodeValue = '(wait)';
} else {
// Busy seeking to new target position.
positionNode.nodeValue = '(sync)';
}
} else if (synced || streaming) {
positionNode.nodeValue = format(position);
}
}
if (ok) {
// Don't update the track slider unless it is synced and running.
// (prevents awkward 'jitter' animation).
$("#trackslider").val(position).slider('refresh');
}
}
function getHeartbeat() {
if (syncsLeft > 0 && callbackHeartbeats === 0) {
// Step back exponentially while increasing heartbeat.
return Math.round(delay_exponential(5, 2, MAX_SYNCS - syncsLeft));
} else if (syncsLeft === 0 && callbackHeartbeats === 0) {
// Sync completed, keep checking using maximum number of heartbeats.
return RUN_BEATS;
} else {
return START_BEATS;
}
}
function syncTimer(current, target) {
if (target) {
var drift = Math.abs(target - current);
if (drift <= 500) {
syncsLeft--;
// Less than 500ms == in sync.
if (++consecutiveSyncs == 2) {
// Need at least two consecutive syncs to know that Mopidy
// is progressing playback and we are in sync.
synced = true;
targetPosition = null;
consecutiveSyncs = 0;
}
} else {
// Drift is too large, re-sync with Mopidy.
reset();
targetPosition = target;
progressTimer.set(targetPosition);
}
}
}
function toInt(value) {
return value.match(/^\w*\d+\w*$/) ? parseInt(value) : null;
}
function format(milliseconds) {
if (milliseconds === Infinity) {
return '(n/a)';
} else if (milliseconds === 0) {
return '0:00';
}
var seconds = Math.floor(milliseconds / 1000);
var minutes = Math.floor(seconds / 60);
seconds = seconds % 60;
seconds = seconds < 10 ? '0' + seconds : seconds;
return minutes + ':' + seconds;
}
function delay_exponential(base, growthFactor, attempts) {
/*Calculate number of beats between syncs based on exponential function.
The format is::
base * growthFactor ^ (attempts - 1)
If ``base`` is set to 'rand' then a random number between
0 and 1 will be used as the base.
Base must be greater than 0.
*/
if (base == 'rand') {
base = Math.random();
}
beats = base * (Math.pow(growthFactor, (attempts - 1)));
return beats;
}
function reset() {
synced = false;
consecutiveSyncs = 0;
syncsLeft = MAX_SYNCS;
callbackHeartbeats = START_BEATS;
targetPosition = null;
}
function setProgressTimer(pos) {
reset();
targetPosition = pos;
progressTimer.set(pos);
if (!play) {
// Set lapsed time and slider position directly as timer callback is not currently
// running.
positionNode.nodeValue = format(pos);
$("#trackslider").val(pos).slider('refresh');
}
}
function updatePosition(pos) {
positionNode.nodeValue = format(pos);
}
function startProgressTimer() {
reset();
progressTimer.start();
}
function resetProgressTimer() {
progressTimer.reset();
reset();
targetPosition = 0;
}

View File

@ -0,0 +1,207 @@
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory()
} else {
root.SyncedProgressTimer = factory()
}
}(this, function () {
'use strict'
function delay_exponential (base, growthFactor, attempts) {
/* Calculate number of beats between syncs based on exponential function.
The format is::
base * growthFactor ^ (attempts - 1)
If ``base`` is set to 'rand' then a random number between
0 and 1 will be used as the base.
Base must be greater than 0.
*/
if (base === 'rand') {
base = Math.random()
}
// console.log(base + ' * (Math.pow(' + growthFactor + ', (' + attempts + ' - 1)) = ' + base * (Math.pow(growthFactor, (attempts - 1))))
return base * (Math.pow(growthFactor, (attempts - 1)))
}
function SyncedProgressTimer (maxAttempts, mopidy) {
if (!(this instanceof SyncedProgressTimer)) {
return new SyncedProgressTimer(maxAttempts, mopidy)
}
this.positionNode = document.createTextNode('')
this.durationNode = document.createTextNode('')
$('#songelapsed').empty().append(this.positionNode)
$('#songlength').empty().append(this.durationNode)
this._progressTimer = new ProgressTimer({
// Make sure that the timer object's context is available.
callback: $.proxy(this.timerCallback, this)
})
this._maxAttempts = maxAttempts
this._mopidy = mopidy
this._isConnected = false
this._mopidy.on('state:online', $.proxy(function () { this._isConnected = true }), this)
this._mopidy.on('state:offline', $.proxy(function () { this._isConnected = false }), this)
this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
this._isSyncScheduled = false
this._scheduleID = null
this._syncAttemptsRemaining = this._maxAttempts
this._previousSyncPosition = null
this._duration = null
}
SyncedProgressTimer.SYNC_STATE = {
NOT_SYNCED: 0,
SYNCING: 1,
SYNCED: 2
}
SyncedProgressTimer.format = function (milliseconds) {
if (milliseconds === Infinity) {
return ''
} else if (milliseconds === 0) {
return '0:00'
}
var seconds = Math.floor(milliseconds / 1000)
var minutes = Math.floor(seconds / 60)
seconds = seconds % 60
seconds = seconds < 10 ? '0' + seconds : seconds
return minutes + ':' + seconds
}
SyncedProgressTimer.prototype.timerCallback = function (position, duration) {
this._update(position, duration)
if (this._isSyncScheduled && this._isConnected) {
this._doSync(position, duration)
}
}
SyncedProgressTimer.prototype._update = function (position, duration) {
switch (this.syncState) {
case SyncedProgressTimer.SYNC_STATE.NOT_SYNCED:
// Waiting for Mopidy to provide a target position.
this.positionNode.nodeValue = '(wait)'
break
case SyncedProgressTimer.SYNC_STATE.SYNCING:
// Busy seeking to new target position.
this.positionNode.nodeValue = '(sync)'
break
case SyncedProgressTimer.SYNC_STATE.SYNCED:
this._previousSyncPosition = position
this.positionNode.nodeValue = SyncedProgressTimer.format(position)
$('#trackslider').val(position).slider('refresh')
break
}
}
SyncedProgressTimer.prototype._scheduleSync = function (milliseconds) {
// Use an anonymous callback to set a boolean value, which should be faster to
// check in the timeout callback than doing another function call.
clearTimeout(this._scheduleID)
this._isSyncScheduled = false
this._scheduleID = setTimeout($.proxy(function () { this._isSyncScheduled = true }, this), milliseconds)
}
SyncedProgressTimer.prototype._doSync = function (position, duration) {
var ready = !(duration === Infinity && position === 0) // Timer has been properly initialized.
if (!ready) {
// Don't try to sync if progress timer has not been initialized yet.
return
}
var _this = this
_this._mopidy.playback.getTimePosition().then(function (targetPosition) {
if (_this.syncState === SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) {
_this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
}
if (Math.abs(targetPosition - position) <= 500) {
// Less than 500ms == in sync.
_this._syncAttemptsRemaining = Math.max(_this._syncAttemptsRemaining - 1, 0)
if (_this._syncAttemptsRemaining < _this._maxAttempts - 1 && _this._previousSyncPosition !== targetPosition) {
// Need at least two consecutive syncs to know that Mopidy
// is progressing playback and we are in sync.
_this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED
}
_this._previousSyncPosition = targetPosition
// Step back exponentially while increasing number of callbacks.
_this._scheduleSync(delay_exponential(0.25, 2, _this._maxAttempts - _this._syncAttemptsRemaining) * 1000)
} else {
// Drift is too large, re-sync with Mopidy.
_this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
_this._syncAttemptsRemaining = _this._maxAttempts
_this._previousSyncPosition = null
_this._scheduleSync(1000)
_this._progressTimer.set(targetPosition)
}
})
}
SyncedProgressTimer.prototype.set = function (position, duration) {
if (arguments.length === 0) {
throw new Error('"SyncedProgressTimer.set" requires the "position" argument.')
}
this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
this._syncAttemptsRemaining = this._maxAttempts
// Workaround for https://github.com/adamcik/media-progress-timer/issues/3
// This causes the timer to die unexpectedly if the position exceeds
// the duration slightly.
if (this._duration && this._duration < position) {
position = this._duration - 1
}
if (arguments.length === 1) {
this._progressTimer.set(position)
} else {
this._duration = duration
this._progressTimer.set(position, duration)
this.durationNode.nodeValue = SyncedProgressTimer.format(duration)
}
this.updatePosition(position, duration)
$('#trackslider').val(position).slider('refresh')
return this
}
SyncedProgressTimer.prototype.start = function () {
this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
this._scheduleSync(0)
this._progressTimer.start()
return this
}
SyncedProgressTimer.prototype.stop = function () {
this._progressTimer.stop()
clearTimeout(this._scheduleID)
this._isSyncScheduled = false
if (this.syncState !== SyncedProgressTimer.SYNC_STATE.SYNCED && this._previousSyncPosition) {
// Timer was busy trying to sync when it was stopped, fallback to displaying the last synced position on screen.
this.positionNode.nodeValue = SyncedProgressTimer.format(this._previousSyncPosition)
}
return this
}
SyncedProgressTimer.prototype.reset = function () {
this.stop()
this.set(0, Infinity)
return this
}
SyncedProgressTimer.prototype.updatePosition = function (position) {
if (!(this._duration === Infinity && position === 0)) {
this.positionNode.nodeValue = SyncedProgressTimer.format(position)
} else {
this.positionNode.nodeValue = ''
}
}
return SyncedProgressTimer
}))

View File

@ -1,6 +1,6 @@
CACHE MANIFEST
# 2016-02-29:v1
# 2016-05-13:v1
NETWORK:
*
@ -19,14 +19,14 @@ images/icons/play_alt_12x12.png
images/icons/play_alt_16x16.png
images/loader.gif
images/user_24x32.png
index.html
js/controls.js
js/custom_scripting.js
js/functionsvars.js
js/gui.js
js/images.js
js/library.js
js/process_ws.js
js/progress_timer.js
js/synced_timer.js
mb.appcache
system.html
vendors/font_awesome/css/font-awesome.css

View File

@ -23,8 +23,8 @@
<div data-role="content" data-theme="b">
<h3>System</h3>
<a href="#" onclick="haltSystem(); return false;" data-role="button" data-rel="dialog" data-transition="slidedown" data-theme="b">Shutdown</a>
<a href="#" onclick="rebootSystem(); return false;" data-role="button" data-rel="dialog" data-transition="slidedown" data-theme="b">Reboot</a>
<a href="#" onclick="controls.haltSystem(); return false;" data-role="button" data-rel="dialog" data-transition="slidedown" data-theme="b">Shutdown</a>
<a href="#" onclick="controls.rebootSystem(); return false;" data-role="button" data-rel="dialog" data-transition="slidedown" data-theme="b">Reboot</a>
<a href="index.html" data-role="button" data-rel="back" data-theme="a">Cancel</a>
</div>
</div>

View File

@ -1,128 +1,167 @@
/*! timer.js v2.0.2
/*! timer.js v3.0.0
* https://github.com/adamcik/media-progress-timer
* Copyright (c) 2015 Thomas Adamcik
* Copyright (c) 2015-2016 Thomas Adamcik
* Licensed under the Apache License, Version 2.0 */
(function() {
'use strict';
var now = typeof window.performance !== 'undefined' &&
typeof window.performance.now !== 'undefined' &&
window.performance.now.bind(window.performance) || Date.now ||
function() { return new Date().getTime(); };
function ProgressTimer(options) {
if (!(this instanceof ProgressTimer)) {
return new ProgressTimer(options);
} else if (typeof options === 'function') {
options = {'callback': options};
} else if (typeof options !== 'object') {
throw 'ProgressTimer must be called with a callback or options.';
} else if (typeof options.callback !== 'function') {
throw 'ProgressTimer needs a callback to operate.';
}
this._running = false;
this._updateRate = Math.max(options.updateRate || 100, 10);
this._callback = options.callback;
this._fallback = typeof window.requestAnimationFrame === 'undefined' ||
options.disableRequestAnimationFrame|| false;
if (!this._fallback) {
this._callUpdate = this._scheduleAnimationFrame;
this._scheduleUpdate = this._scheduleAnimationFrame;
}
this._boundCallUpdate = this._callUpdate.bind(this);
this._boundUpdate = this._update.bind(this);
this.reset();
}
ProgressTimer.prototype.set = function(position, duration) {
if (arguments.length === 0) {
throw 'set requires at least a position argument.';
} else if (arguments.length === 1) {
duration = this._state.duration;
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
duration = Math.floor(
Math.max(duration === null ? Infinity : duration || 0, 0));
root.ProgressTimer = factory();
}
position = Math.floor(Math.min(Math.max(position || 0, 0), duration));
}(this, function () {
'use strict';
this._state = {
initialTimestamp: null,
initialPosition: position,
previousPosition: position,
duration: duration
// Helper function to provide a reference time in milliseconds.
var now = /* Sinon does not currently support faking `window.performance`
(see https://github.com/sinonjs/sinon/issues/803).
Changing this to only rely on `new Date().getTime()
in the interim in order to allow testing of the
progress timer from MMW.
typeof window.performance !== 'undefined' &&
typeof window.performance.now !== 'undefined' &&
window.performance.now.bind(window.performance) || Date.now ||*/
function() { return new Date().getTime(); };
// Helper to warn library users about deprecated features etc.
function warn(msg) {
window.setTimeout(function() { throw msg; }, 0);
}
// Creates a new timer object, works with both 'new ProgressTimer(options)'
// and just 'ProgressTimer(options). Optionally the timer can also be
// called with only the callback instead of options.
function ProgressTimer(options) {
if (!(this instanceof ProgressTimer)) {
return new ProgressTimer(options);
} else if (typeof options === 'function') {
options = {'callback': options};
} else if (typeof options !== 'object') {
throw '"ProgressTimer" must be called with a callback or options.';
} else if (typeof options['callback'] !== 'function') {
throw '"ProgressTimer" needs a callback to operate.';
}
this._userCallback = options['callback'];
this._updateId = null;
this._state = null; // Gets initialized by the set() call.
var frameDuration = 1000 / (options['fallbackTargetFrameRate'] || 30);
// TODO: Remove this legacy code path at some point.
if (options['updateRate'] && !options['fallbackTargetFrameRate']) {
warn('"ProgressTimer" no longer supports the updateRate option.');
frameDuration = Math.max(options['updateRate'], 1000 / 60);
}
var useFallback = (
typeof window.requestAnimationFrame === 'undefined' ||
typeof window.cancelAnimationFrame === 'undefined' ||
options['disableRequestAnimationFrame'] || false);
// Make sure this works in _update.
var update = this._update.bind(this);
if (useFallback) {
this._schedule = function(timestamp) {
var timeout = Math.max(timestamp + frameDuration - now(), 0);
return window.setTimeout(update, Math.floor(timeout));
};
this._cancel = window.clearTimeout.bind(window);
} else {
this._schedule = window.requestAnimationFrame.bind(window, update);
this._cancel = window.cancelAnimationFrame.bind(window);
}
this.reset(); // Reuse reset code to ensure we start in the same state.
}
// If called with one argument the previous duration is preserved. Note
// that the position can be changed while the timer is running.
ProgressTimer.prototype.set = function(position, duration) {
if (arguments.length === 0) {
throw '"ProgressTimer.set" requires the "position" arugment.';
} else if (arguments.length === 1) {
// Fallback to previous duration, whatever that was.
duration = this._state.duration;
} else {
// Round down and make sure zero and null are treated as inf.
duration = Math.floor(Math.max(
duration === null ? Infinity : duration || Infinity, 0));
}
// Make sure '0 <= position <= duration' always holds.
position = Math.floor(Math.min(Math.max(position || 0, 0), duration));
this._state = {
initialTimestamp: null,
initialPosition: position,
position: position,
duration: duration
};
// Update right away if we don't have anything running.
if (this._updateId === null) {
// TODO: Consider wrapping this in a try/catch?
this._userCallback(position, duration);
}
return this;
};
this._callback(position, duration, this._running);
return this;
};
ProgressTimer.prototype.start = function() {
this._running = true;
this._callUpdate();
return this;
};
ProgressTimer.prototype.stop = function() {
this._running = false;
var state = this._state;
return this.set(state.previousPosition, state.duration);
};
ProgressTimer.prototype.reset = function() {
this._running = false;
return this.set(0, Infinity);
};
ProgressTimer.prototype._callUpdate = function() {
this._update(now());
};
ProgressTimer.prototype._scheduleUpdate = function(timestamp) {
var adjustedTimeout = timestamp + this._updateRate - now();
setTimeout(this._boundCallUpdate, adjustedTimeout);
};
ProgressTimer.prototype._scheduleAnimationFrame = function() {
window.requestAnimationFrame(this._boundUpdate);
};
ProgressTimer.prototype._update = function(timestamp) {
if (!this._running) {
return;
}
var state = this._state;
state.initialTimestamp = state.initialTimestamp || timestamp;
var position = state.initialPosition + timestamp - state.initialTimestamp;
var duration = state.duration;
if (position < duration || duration === null) {
var delta = position - state.previousPosition;
if (delta >= this._updateRate || this._fallback) {
this._callback(Math.floor(position), duration, this._running);
state.previousPosition = position;
// Start the timer if it is not already running.
ProgressTimer.prototype.start = function() {
if (this._updateId === null) {
this._updateId = this._schedule(0);
}
} else {
return this;
};
// Cancel the timer if it us currently tracking progress.
ProgressTimer.prototype.stop = function() {
if (this._updateId !== null) {
this._cancel(this._updateId);
// Ensure we correctly reset the initial position and timestamp.
this.set(this._state.position, this._state.duration);
this._updateId = null; // Last step to avoid callback in set()
}
return this;
};
// Marks the timer as stopped, sets position to zero and duration to inf.
ProgressTimer.prototype.reset = function() {
return this.stop().set(0, Infinity);
};
// Calls the user callback with the current position/duration and then
// schedules the next update run via _schedule if we haven't finished.
ProgressTimer.prototype._update = function(timestamp) {
var state = this._state; // We refer a lot to state, this is shorter.
// Make sure setTimeout has a timestamp and store first reference time.
timestamp = timestamp || now();
state.initialTimestamp = state.initialTimestamp || timestamp;
// Recalculate position according to start location and reference.
state.position = (
state.initialPosition + timestamp - state.initialTimestamp);
// Ensure callback gets an integer and that 'position <= duration'.
var userPosisition = Math.min(
Math.floor(state.position), state.duration);
// TODO: Consider wrapping this in a try/catch?
this._userCallback(userPosisition, state.duration);
// Workaround for https://github.com/adamcik/media-progress-timer/issues/3
// This causes the timer to die unexpectedly if the position goes
// over the duration slightly.
// this._running = false;
this._callback(duration, duration, this._running);
}
this._scheduleUpdate(timestamp);
};
// Mopidy <= 1.1.2 does not always return the correct track position as
// track changes are being done, which can cause the timer to die unexpectedly.
//if (state.position < state.duration) {
this._updateId = this._schedule(timestamp); // Schedule update.
//} else {
// this._updateId = null; // Unset since we didn't reschedule.
//}
};
if(typeof module !== 'undefined') {
module.exports = ProgressTimer;
} else {
window.ProgressTimer = ProgressTimer;
}
}());
return ProgressTimer;
}));

View File

@ -1,10 +1,13 @@
from __future__ import unicode_literals
from __future__ import absolute_import, division, print_function, unicode_literals
import json
import logging
import string
import tornado.web
from . import MusicBoxExtension
import mopidy_musicbox_webclient.webclient as mmw
logger = logging.getLogger(__name__)
@ -21,40 +24,32 @@ class StaticHandler(tornado.web.StaticFileHandler):
@classmethod
def get_version(cls, settings, path):
return MusicBoxExtension.version
return mmw.Extension.version
class IndexHandler(tornado.web.RequestHandler):
def initialize(self, config, path):
ext_config = config[MusicBoxExtension.ext_name]
host, port = ext_config['websocket_host'], ext_config['websocket_port']
ws_url = ''
if host or port:
if not host:
host = self.request.host.partition(':')[0]
logger.warning('Musicbox websocket_host not specified, '
'using %s', host)
elif not port:
port = config['http']['port']
logger.warning('Musicbox websocket_port not specified, '
'using %s', port)
protocol = 'ws'
if self.request.protocol == 'https':
protocol = 'wss'
ws_url = "%s://%s:%d/mopidy/ws" % (protocol, host, port)
webclient = mmw.Webclient(config)
self.__dict = {
'version': MusicBoxExtension.version,
'musicbox': ext_config.get('musicbox', False),
'useWebsocketUrl': ws_url != '',
'websocket_url': ws_url,
'alarmclock': config.get('alarmclock', {}).get('enabled', False),
'isMusicBox': json.dumps(webclient.is_music_box()),
'websocketUrl': webclient.get_websocket_url(self.request),
'hasAlarmClock': json.dumps(webclient.has_alarm_clock()),
'onTrackClick': webclient.get_default_click_action()
}
self.__path = path
self.__title = string.Template('MusicBox on $hostname')
def get(self, path):
return self.render('index.html', **self.__dict)
return self.render(path, title=self.get_title(), **self.__dict)
def get_title(self):
hostname, sep, port = self.request.host.rpartition(':')
if not sep or not port.isdigit():
hostname, port = self.request.host, '80'
return self.__title.safe_substitute(hostname=hostname, port=port)
def get_template_path(self):
return self.__path

View File

@ -0,0 +1,49 @@
from __future__ import absolute_import, division, print_function, unicode_literals
import logging
from mopidy_musicbox_webclient import Extension
logger = logging.getLogger(__name__)
class Webclient(object):
def __init__(self, config):
self.config = config
@property
def ext_config(self):
return self.config.get(Extension.ext_name, {})
@classmethod
def get_version(cls):
return Extension.version
def get_websocket_url(self, request):
host, port = self.ext_config['websocket_host'], self.ext_config['websocket_port']
ws_url = ''
if host or port:
if not host:
host = request.host.partition(':')[0]
logger.warning('Musicbox websocket_host not specified, '
'using %s', host)
elif not port:
port = self.config['http']['port']
logger.warning('Musicbox websocket_port not specified, '
'using %s', port)
protocol = 'ws'
if request.protocol == 'https':
protocol = 'wss'
ws_url = "%s://%s:%d/mopidy/ws" % (protocol, host, port)
return ws_url
def has_alarm_clock(self):
return self.ext_config.get('alarmclock', {}).get('enabled', False)
def is_music_box(self):
return self.ext_config.get('musicbox', False)
def get_default_click_action(self):
return self.ext_config.get('on_track_click', 'PLAY_ALL')

53
package.json Normal file
View File

@ -0,0 +1,53 @@
{
"name": "Mopidy-MusicBox-Webclient",
"version": "2.1.1",
"description": "Mopidy MusicBox web extension",
"main": "gui.js",
"directories": {
"test": "tests"
},
"scripts": {
"test": "karma start karma.conf.js",
"eslint": "eslint mopidy_musicbox_webclient/static/js/**/**.js tests/**/test_*.js",
"csslint": "csslint mopidy_musicbox_webclient/static/css/**.css",
"tidy": "node tidy.js"
},
"repository": {
"type": "git",
"url": "git+https://github.com/pimusicbox/mopidy-musicbox-webclient.git"
},
"author": "Wouter van Wijk",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/pimusicbox/mopidy-musicbox-webclient/issues"
},
"devDependencies": {
"babelify": "^7.2.0",
"browserify": "^13.0.0",
"browserify-istanbul": "^2.0.0",
"chai": "^3.5.0",
"chai-as-promised": "^5.2.0",
"chai-jquery": "^2.0.0",
"chai-string": "^1.2.0",
"coveralls": "^2.11.8",
"csslint": "^0.10.0",
"eslint": "^2.3.0",
"eslint-config-standard": "^5.1.0",
"eslint-plugin-promise": "^1.1.0",
"eslint-plugin-standard": "^1.3.2",
"install": "^0.5.6",
"isparta": "^4.0.0",
"karma": "^0.13.22",
"karma-browserify": "^5.0.2",
"karma-cli": "^0.1.2",
"karma-coverage": "^0.5.5",
"karma-mocha": "^0.2.2",
"karma-phantomjs-launcher": "^1.0.0",
"mocha": "^2.4.5",
"phantomjs-prebuilt": "^2.1.5",
"sinon": "^1.17.3",
"tidy-html5": "latest",
"watchify": "^3.7.0"
},
"homepage": "https://github.com/pimusicbox/mopidy-musicbox-webclient#readme"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -29,7 +29,7 @@ setup(
],
entry_points={
'mopidy.ext': [
'musicbox_webclient = mopidy_musicbox_webclient:MusicBoxExtension',
'musicbox_webclient = mopidy_musicbox_webclient:Extension',
],
},
classifiers=[

108
tests/js/dummy_tracklist.js Normal file
View File

@ -0,0 +1,108 @@
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory)
} else if (typeof module === 'object' && module.exports) {
module.exports = factory()
} else {
root.DummyTracklist = factory()
}
}(this, function () {
'use strict'
/* A dummy tracklist with partial support for mocking mopidy.core.TracklistController.
*
* Returns resolved promises to simulate functionality of Mopidy.js.
*/
function DummyTracklist () {
if (!(this instanceof DummyTracklist)) {
return new DummyTracklist()
}
this._tlTracks = []
this._nextTlid = 1
return this
}
/* Add tracks to the tracklist. params.uris should contain an array of strings for the URIs to be added. */
DummyTracklist.prototype.add = function (params) {
if (!params || !params.uris) {
throw new Error('No tracks provided to add.')
}
if (params.tracks || params.uri) {
throw new Error('DummyTracklist.add does not support deprecated "tracks" and "uri" parameters.')
}
// Add tracks to end of tracklist if no position is provided
params.at_position = params.at_position || this._tlTracks.length
var tlTrack
var tlTracks = []
for (var i = 0; i < params.uris.length; i++) {
tlTrack = {
tlid: this._nextTlid++,
track: {
uri: params.uris[i]
}
}
tlTracks.push(tlTrack)
this._tlTracks.splice(params.at_position + i, 0, tlTrack)
}
return $.when(tlTracks)
}
/* Clears the tracklist */
DummyTracklist.prototype.clear = function () {
this._tlTracks = []
}
/**
* Retuns a list containing tlTracks that contain the provided
* criteria.uri or has ID criteria.tlid.
*
*/
DummyTracklist.prototype.filter = function (criteria) {
if (!criteria || (!criteria.uri && !criteria.tlid)) {
throw new Error('No URI or tracklist ID provided to filter on.')
}
var matches = []
if (criteria.uri) { // Look for matching URIs
for (var i = 0; i < criteria.uri.length; i++) {
for (var j = 0; j < this._tlTracks.length; j++) {
if (this._tlTracks[j].track.uri === criteria.uri[i]) {
matches.push(this._tlTracks[j])
}
}
}
}
if (criteria.tlid) { // Look for matching tracklist IDs
for (i = 0; i < criteria.tlid.length; i++) {
for (j = 0; j < this._tlTracks.length; j++) {
if (this._tlTracks[j].tlid === criteria.tlid[i]) {
matches.push(this._tlTracks[j])
}
}
}
}
return $.when(matches)
}
/* Retuns index of the currently 'playing' track. */
DummyTracklist.prototype.index = function (params) {
if (!params) {
if (this._tlTracks.length > 1) {
// Always just assume that the second track is playing
return $.when(1)
} else {
return $.when(0)
}
}
for (var i = 0; i < this._tlTracks.length; i++) {
if (this._tlTracks[i].tlid === params.tlid || (params.tl_track && params.tl_track.tlid === this._tlTracks[i].tlid)) {
return $.when(i)
}
}
return $.when(0)
}
return DummyTracklist
}))

265
tests/js/test_controls.js Normal file
View File

@ -0,0 +1,265 @@
var chai = require('chai')
var expect = chai.expect
var assert = chai.assert
chai.use(require('chai-string'))
chai.use(require('chai-jquery'))
var sinon = require('sinon')
var controls = require('../../mopidy_musicbox_webclient/static/js/controls.js')
var DummyTracklist = require('./dummy_tracklist.js')
describe('controls', function () {
var mopidy
var div_element
var QUEUE_TRACKS = [ // Simulate an existing queue with three tracks loaded.
{uri: 'track:tlTrackMock1'},
{uri: 'track:tlTrackMock2'},
{uri: 'track:tlTrackMock3'}
]
var NEW_TRACKS = [ // Simulate the user browsing to a folder with three tracks inside it.
{uri: 'track:trackMock1'},
{uri: 'tunein:track:trackMock2'}, // Stream
{uri: 'track:trackMock3'}
]
var addSpy
before(function () {
$(document.body).append('<div data-role="popup" id="popupTracks"></div>')
$('#popupTracks').popup() // Initialize popup
$(document.body).data('on-track-click', 'PLAY_ALL') // Set default click action
mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'}))
var playback = {
play: sinon.stub(),
stop: sinon.stub()
}
mopidy.playback = playback
mopidy.playback.stop.returns($.when())
// Mock the Mopidy tracklist so that we have a predictable state to test against.
mopidy.tracklist = new DummyTracklist()
addSpy = sinon.spy(mopidy.tracklist, 'add')
clearSpy = sinon.spy(mopidy.tracklist, 'clear')
})
beforeEach(function () {
mopidy.tracklist.clear()
clearSpy.reset()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
})
afterEach(function () {
mopidy.playback.play.reset()
addSpy.reset()
})
after(function () {
mopidy.tracklist.add.restore()
mopidy.tracklist.clear.restore()
})
describe('#playTracks()', function () {
it('PLAY_ALL should clear tracklist first before populating with tracks', function () {
customTracklists[BROWSE_TABLE] = NEW_TRACKS
controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri, BROWSE_TABLE)
assert(clearSpy.called)
})
it('should not clear tracklist for events other than PLAY_ALL', function () {
customTracklists[BROWSE_TABLE] = NEW_TRACKS
controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri, BROWSE_TABLE)
assert(clearSpy.notCalled)
})
it('should raise exception if trackUri parameter is not provided and "track" data attribute is empty', function () {
assert.throw(function () { controls.playTracks('', mopidy) }, Error)
controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri, BROWSE_TABLE)
assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[0].tlid}))
})
it('should raise exception if playListUri parameter is not provided and "track" data attribute is empty', function () {
assert.throw(function () { controls.playTracks('', mopidy, NEW_TRACKS[0].uri) }, Error)
controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri, BROWSE_TABLE)
assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[0].tlid}))
})
it('should raise exception if unknown tracklist action is provided', function () {
var getTrackURIsForActionStub = sinon.stub(controls, '_getTrackURIsForAction') // Stub to bypass earlier exception
assert.throw(function () { controls.playTracks('99', mopidy, NEW_TRACKS[0].uri, BROWSE_TABLE) }, Error)
getTrackURIsForActionStub.restore()
})
it('should use "track" and "list" data attributes as fallback if parameters are not provided', function () {
$('#popupTracks').data('track', 'track:trackMock1') // Simulate 'track:trackMock1' being clicked.
$('#popupTracks').data('list', BROWSE_TABLE)
customTracklists[BROWSE_TABLE] = NEW_TRACKS
controls.playTracks(PLAY_ALL, mopidy)
assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[0].tlid}))
})
it('PLAY_NOW, PLAY_NEXT, and ADD_THIS_BOTTOM should only add one track to the tracklist', function () {
controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri)
assert(addSpy.calledWithMatch({at_position: 2, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NOW did not add correct track')
addSpy.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(PLAY_NEXT, mopidy, NEW_TRACKS[0].uri)
assert(addSpy.calledWithMatch({at_position: 2, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NEXT did not add correct track')
addSpy.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(ADD_THIS_BOTTOM, mopidy, NEW_TRACKS[0].uri)
assert(addSpy.calledWithMatch({uris: [NEW_TRACKS[0].uri]}), 'ADD_THIS_BOTTOM did not add correct track')
})
it('PLAY_ALL and ADD_ALL_BOTTOM should add all tracks to tracklist', function () {
controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri)
assert(addSpy.calledWithMatch({uris: getUris(NEW_TRACKS)}), 'PLAY_ALL did not add correct tracks')
addSpy.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(ADD_ALL_BOTTOM, mopidy, NEW_TRACKS[0].uri)
assert(addSpy.calledWithMatch({uris: getUris(NEW_TRACKS)}), 'ADD_ALL_BOTTOM did not add correct tracks')
})
it('PLAY_NOW and PLAY_NEXT should insert track after currently playing track', function () {
controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri)
assert(addSpy.calledWithMatch({at_position: 2, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NOW did not insert track at correct position')
addSpy.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(PLAY_NEXT, mopidy, NEW_TRACKS[0].uri)
assert(addSpy.calledWithMatch({at_position: 2, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NEXT did not insert track at correct position')
})
it('only PLAY_NOW and PLAY_ALL should trigger playback', function () {
controls.playTracks(PLAY_NOW, mopidy, 2)
assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[2].tlid}), 'PLAY_NOW did not start playback of correct track')
mopidy.playback.play.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(PLAY_NEXT, mopidy, NEW_TRACKS[0].uri)
assert.isFalse(mopidy.playback.play.called, 'PLAY_NEXT should not have triggered playback to start')
mopidy.playback.play.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(ADD_THIS_BOTTOM, mopidy, NEW_TRACKS[0].uri)
assert.isFalse(mopidy.playback.play.called, 'ADD_THIS_BOTTOM should not have triggered playback to start')
mopidy.playback.play.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[2].uri)
assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[2].tlid}), 'PLAY_ALL did not start playback of correct track')
mopidy.playback.play.reset()
mopidy.tracklist.clear()
mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)})
controls.playTracks(ADD_ALL_BOTTOM, mopidy, NEW_TRACKS[0].uri)
assert.isFalse(mopidy.playback.play.called, 'ADD_ALL_BOTTOM should not have triggered playback to start')
mopidy.playback.play.reset()
})
it('should store last action in cookie if on-track-click mode is set to "DYNAMIC"', function () {
$(document.body).data('on-track-click', 'DYNAMIC')
var cookieStub = sinon.stub($, 'cookie')
controls.playTracks(PLAY_NOW, mopidy, 2)
assert(cookieStub.calledWithMatch('onTrackClick', PLAY_NOW, {expires: 365}))
cookieStub.reset()
$(document.body).data('on-track-click', 'PLAY_NOW')
controls.playTracks(PLAY_NOW, mopidy, 2)
assert(cookieStub.notCalled)
cookieStub.restore()
})
})
describe('#getAction()', function () {
it('should use default action if none is specified', function () {
window.MOCK_DEFAULT = 99 // Define global variable to test against.
$(document.body).data('on-track-click', 'MOCK_DEFAULT')
assert.equal(controls.getAction(), 99)
})
it('should get action from cookie if action is set to "DYNAMIC"', function () {
$(document.body).data('on-track-click', 'DYNAMIC')
var cookieStub = sinon.stub($, 'cookie')
controls.getAction()
assert(cookieStub.called)
cookieStub.restore()
})
it('should default to "PLAY_ALL" if no cookie is available for "DYNAMIC"', function () {
$(document.body).data('on-track-click', 'DYNAMIC')
$.removeCookie('onTrackClick')
assert.equal(controls.getAction(), PLAY_ALL)
})
})
describe('#getIconForAction()', function () {
it('should return correct FontAwesome class for each tracklist action', function () {
assert.equal(controls.getIconForAction(PLAY_ALL), 'fa fa-play-circle')
assert.equal(controls.getIconForAction(PLAY_NOW), 'fa fa-play-circle-o')
assert.equal(controls.getIconForAction(PLAY_NEXT), 'fa fa-level-down')
assert.equal(controls.getIconForAction(ADD_THIS_BOTTOM), 'fa fa-plus-square-o')
assert.equal(controls.getIconForAction(ADD_ALL_BOTTOM), 'fa fa-plus-square')
})
it('should raise error if unknown tracklist action is provided', function () {
assert.throw(function () { controls.getIconForAction(99) }, Error)
})
it('should handle action identifier strings in addition to integers', function () {
assert.equal(controls.getIconForAction('0'), 'fa fa-play-circle-o')
})
it('should use default tracklist action if no parameter is provided', function () {
assert.equal(controls.getIconForAction(), 'fa fa-play-circle')
})
})
describe('#_getTrackURIsForAction()', function () {
it('should return just "trackUri" for PLAY_NOW, PLAY_NEXT, and ADD_THIS_BOTTOM', function () {
assert.equal(controls._getTrackURIsForAction(PLAY_NOW, 'mockUri')[0], 'mockUri')
assert.equal(controls._getTrackURIsForAction(PLAY_NEXT, 'mockUri')[0], 'mockUri')
assert.equal(controls._getTrackURIsForAction(ADD_THIS_BOTTOM, 'mockUri')[0], 'mockUri')
})
it('should get tracks from "playlistUri" for PLAY_ALL, and ADD_ALL_BOTTOM', function () {
customTracklists[BROWSE_TABLE] = NEW_TRACKS
var tracks = controls._getTrackURIsForAction(PLAY_ALL, NEW_TRACKS[0], BROWSE_TABLE)
assert.equal(tracks.length, NEW_TRACKS.length)
for (var i = 0; i < tracks.length; i++) {
assert.equal(tracks[i], NEW_TRACKS[i].uri)
}
})
it('should raise error if unknown tracklist action is provided', function () {
assert.throw(function () { controls._getTrackURIsForAction(99) }, Error)
})
it('should handle action identifier strings in addition to integers', function () {
assert.equal(controls._getTrackURIsForAction('0', 'mockUri')[0], 'mockUri')
})
})
})

View File

@ -0,0 +1,30 @@
var chai = require('chai')
var expect = chai.expect
var assert = chai.assert
chai.use(require('chai-string'))
chai.use(require('chai-jquery'))
var sinon = require('sinon')
var configureJQueryMobile = require('../../mopidy_musicbox_webclient/static/js/custom_scripting.js')
describe('jQuery Defaults', function () {
it('should disable ajax and hashListening', function () {
expect($.mobile.ajaxEnabled).to.be.true
expect($.mobile.hashListeningEnabled).to.be.true
configureJQueryMobile()
expect($.mobile.ajaxEnabled).to.be.false
expect($.mobile.hashListeningEnabled).to.be.false
})
it('should bind to "mobileinit"', function () {
var configSpy = sinon.spy(configureJQueryMobile)
$(document).bind('mobileinit', configSpy)
expect(configSpy.called).to.be.false
$(document).trigger('mobileinit')
expect(configSpy.called).to.be.true
configSpy.reset()
})
})

567
tests/js/test_images.js Normal file
View File

@ -0,0 +1,567 @@
var chai = require('chai')
var expect = chai.expect
var assert = chai.assert
chai.use(require('chai-string'))
chai.use(require('chai-jquery'))
var sinon = require('sinon')
var images = require('../../mopidy_musicbox_webclient/static/js/images.js')
describe('images', function () {
var mopidy
var img_element
before(function () {
mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'}))
img_element = $('<img id="img_mock">')
})
beforeEach(function () {
$(img_element).removeAttr('src')
})
describe('#_getArtistInfo()', function () {
it('should get artist info from track', function () {
var track = {
artists: [{name: 'trackArtistMock', musicbrainz_id: 'trackArtistIDMock'}],
album: {artists: [{name: 'albumArtistMock', musicbrainz_id: 'albumArtistIDMock'}]}
}
var artist = images._getArtistInfo(track)
assert.equal(artist.name, 'trackArtistMock')
assert.equal(artist.mbid, 'trackArtistIDMock')
})
it('should fall back to using album artist if track artist is not available', function () {
var track = {
album: {artists: [{name: 'albumArtistMock', musicbrainz_id: 'albumArtistIDMock'}]}
}
var artist = images._getArtistInfo(track)
assert.equal(artist.name, 'albumArtistMock')
assert.equal(artist.mbid, 'albumArtistIDMock')
})
it('should use any combination of artist info from tracks and albums', function () {
var track = {
artists: [{name: 'trackArtistMock'}],
album: {artists: [{name: 'albumArtistMock', musicbrainz_id: 'albumArtistIDMock'}]}
}
var artist = images._getArtistInfo(track)
assert.equal(artist.name, 'trackArtistMock')
assert.equal(artist.mbid, 'albumArtistIDMock')
})
})
describe('#_getLastFmArtistInfo', function () {
var getInfoStub
before(function () {
getInfoStub = sinon.stub(images.lastFM.track, 'getInfo')
getInfoStub.yieldsTo('success', {track: {artist: {mbid: 'mockArtistID', name: 'mockArtistName'}}})
})
afterEach(function () {
getInfoStub.reset()
})
after(function () {
getInfoStub.restore()
})
it('should retrieve artist info from last.fm using MusicBrainz ID', function () {
var track = {
musicbrainz_id: 'trackIDMock'
}
images._getLastFmArtistInfo(track).then(function (artist) {
assert.equal(artist.mbid, 'mockArtistID')
assert.equal(artist.name, 'mockArtistName')
}, function (code, message) {
assert.fail(code, '', 'Async method call did not resolve as expected')
})
assert(getInfoStub.calledWith({mbid: 'trackIDMock'}))
})
it('should retrieve artist info from last.fm using track and artist name', function () {
var track = {
name: 'trackNameMock',
artists: [{name: 'trackArtistMock'}]
}
images._getLastFmArtistInfo(track).then(function (artist) {
assert.equal(artist.mbid, 'mockArtistID')
assert.equal(artist.name, 'mockArtistName')
}, function (code, message) {
assert.fail(code, '', 'Async method call did not resolve as expected')
})
assert(getInfoStub.calledWith({track: 'trackNameMock', artist: 'trackArtistMock'}))
})
it('should raise error if neither track MusicBrainz ID OR track AND album names are available', function () {
var track = {
name: 'trackNameMock'
}
images._getLastFmArtistInfo(track).then(function (data) {
assert.fail(data, undefined, 'Async method call with just track name did not reject as expected')
}, function (code, message) {
assert.equal(code, 'none')
assert.equal(message, 'Not enough tag information available for track to make last.fm call.')
})
track = {
artists: [{name: 'trackArtistMock'}]
}
images._getLastFmArtistInfo(track).then(function (data) {
assert.fail(data, undefined, 'Async method call with just artist name did not reject as expected')
}, function (code, message) {
assert.equal(code, 'none')
assert.equal(message, 'Not enough tag information available for track to make last.fm call.')
})
})
it('should re-raise last.fm errors', function () {
var track = {
musicbrainz_id: 'trackIDMock'
}
getInfoStub.yieldsTo('error', 'code', 'message')
images._getLastFmArtistInfo(track).then(function (data) {
assert.fail(data, undefined, 'Async method call did not re-raise reject as expected')
}, function (code, message) {
assert.equal(code, 'code')
assert.equal(message, 'message')
})
})
})
describe('#_getLastFmAlbumInfo', function () {
var getInfoStub
before(function () {
getInfoStub = sinon.stub(images.lastFM.album, 'getInfo')
getInfoStub.yieldsTo('success', {album: {image: [{size: 'extralarge', '#text': 'mockURL'}]}})
})
afterEach(function () {
getInfoStub.reset()
})
after(function () {
getInfoStub.restore()
})
it('should retrieve album info from last.fm using MusicBrainz ID', function () {
var track = {
album: {musicbrainz_id: 'albumIDMock'}
}
images._getLastFmAlbumInfo(track).then(function (data) {
assert.equal(data.album.image[0].size, 'extralarge')
assert.equal(data.album.image[0]['#text'], 'mockURL')
}, function (code, message) {
assert.fail(code, '', 'Async method call did not resolve as expected')
})
assert(getInfoStub.calledWith({mbid: 'albumIDMock'}))
})
it('should retrieve album info from last.fm using album name and either the track or artist name', function () {
var track = {
album: {
name: 'albumNameMock',
artists: [{name: 'albumArtistMock'}]
},
artists: [{name: 'trackArtistMock'}]
}
images._getLastFmAlbumInfo(track).then(function (data) {
assert.equal(data.album.image[0].size, 'extralarge')
assert.equal(data.album.image[0]['#text'], 'mockURL')
}, function (code, message) {
assert.fail(code, '', 'Async method call did not resolve as expected')
})
assert(getInfoStub.calledWith({artist: 'trackArtistMock', album: 'albumNameMock'}))
})
it('should raise error if neither album MusicBrainz ID OR album AND artist names are available', function () {
var track = {
album: {}
}
images._getLastFmAlbumInfo(track).then(function (data) {
assert.fail(data, undefined, 'Async method call with just track name did not reject as expected')
}, function (code, message) {
assert.equal(code, 'none')
assert.equal(message, 'Not enough tag information available for album to make last.fm call.')
})
track = {
artists: [{name: 'trackArtistMock'}]
}
images._getLastFmAlbumInfo(track).then(function (data) {
assert.fail(data, undefined, 'Async method call with just artist name did not reject as expected')
}, function (code, message) {
assert.equal(code, 'none')
assert.equal(message, 'Not enough tag information available for album to make last.fm call.')
})
})
it('should re-raise last.fm errors', function () {
var track = {
album: {musicbrainz_id: 'albumIDMock'}
}
getInfoStub.yieldsTo('error', 'code', 'message')
images._getLastFmAlbumInfo(track).then(function (data) {
assert.fail(data, undefined, 'Async method call did not re-raise reject as expected')
}, function (code, message) {
assert.equal(code, 'code')
assert.equal(message, 'message')
})
})
})
describe('#setAlbumImage()', function () {
var getImagesResultMock
var lookupResultMock
var library
var getImagesSpy
var setDeprecatedAlbumImageSpy
before(function () {
library = {
getImages: function () { return $.when(getImagesResultMock) },
lookup: function () { return $.when(lookupResultMock) }
}
mopidy.library = library
getImagesSpy = sinon.spy(mopidy.library, 'getImages')
setDeprecatedAlbumImageSpy = sinon.spy(images, '_setDeprecatedAlbumImage')
})
afterEach(function () {
getImagesSpy.reset()
setDeprecatedAlbumImageSpy.reset()
})
after(function () {
mopidy.library.getImages.restore()
})
it('should use default image if no track URI is provided', function () {
images.setAlbumImage('', img_element, mopidy)
expect($(img_element).prop('src')).to.endWith(images.DEFAULT_ALBUM_URL)
})
it('should get image from Mopidy, if available', function () {
getImagesResultMock = {'mock:track:uri': [{uri: 'mockImageUri'}]}
images.setAlbumImage('mock:track:uri', img_element, mopidy)
assert.isTrue(getImagesSpy.calledOnce)
expect($(img_element).prop('src')).to.endWith('mockImageUri')
})
it('should fall back to retrieving image from deprecated track.album.images', function () {
getImagesResultMock = {'mock:track:uri': []}
lookupResultMock = {'mock:track:uri': [{album: {images: ['mockAlbumImageUri']}}]}
images.setAlbumImage('mock:track:uri', img_element, mopidy)
assert.isTrue(getImagesSpy.calledOnce)
assert.isTrue(setDeprecatedAlbumImageSpy.calledOnce)
})
it('should default to retrieving "extralarge" album image', function () {
getImagesResultMock = {'mock:track:uri': []}
lookupResultMock = {'mock:track:uri': [{album: {images: ['mockAlbumImageUri']}}]}
images.setAlbumImage('mock:track:uri', img_element, mopidy)
expect(setDeprecatedAlbumImageSpy.args[0]).to.include('extralarge')
})
})
describe('#_setDeprecatedAlbumImage()', function () {
var lookupResultMock
var library
before(function () {
library = {
lookup: function () { return $.when(lookupResultMock) }
}
mopidy.library = library
})
it('should use default image if no track URI is provided', function () {
images._setDeprecatedAlbumImage('', img_element, mopidy)
expect($(img_element).prop('src')).to.endWith(images.DEFAULT_ALBUM_URL)
})
it('should get image from Mopidy track.album.img_element, if available', function () {
lookupResultMock = {'mock:track:uri': [{album: {images: ['mockAlbumImageUri']}}]}
var lookupSpy = sinon.spy(mopidy.library, 'lookup')
images._setDeprecatedAlbumImage('mock:track:uri', img_element, mopidy)
assert.isTrue(lookupSpy.calledOnce)
expect($(img_element).prop('src')).to.endWith('mockAlbumImageUri')
mopidy.library.lookup.restore()
})
it('should use default image if track.album or track.artist is not available', function () {
lookupResultMock = {'mock:track:uri': [{}]}
var lookupSpy = sinon.spy(mopidy.library, 'lookup')
images._setDeprecatedAlbumImage('mock:track:uri', img_element, mopidy)
assert.isTrue(lookupSpy.calledOnce)
expect($(img_element).prop('src')).to.endWith(images.DEFAULT_ALBUM_URL)
mopidy.library.lookup.restore()
})
it('should fall back to retrieving image from last.fm if none provided by Mopidy', function () {
lookupResultMock = {'mock:track:uri': [{album: {images: []}}]}
var setLastFmAlbumImageStub = sinon.stub(images, '_setLastFmAlbumImage')
images._setDeprecatedAlbumImage('mock:track:uri', img_element, mopidy)
assert.isTrue(setLastFmAlbumImageStub.calledOnce)
setLastFmAlbumImageStub.restore()
})
it('should default to retrieving "extralarge" album image', function () {
lookupResultMock = {'mock:track:uri': [{album: {}}]}
var setLastFmAlbumImageStub = sinon.stub(images, '_setLastFmAlbumImage')
images._setDeprecatedAlbumImage('mock:track:uri', img_element, mopidy)
expect(setLastFmAlbumImageStub.args[0]).to.include('extralarge')
setLastFmAlbumImageStub.restore()
})
})
describe('#_setLastFmAlbumImage()', function () {
var getInfoResultMock
var getInfoStub
before(function () {
getInfoStub = sinon.stub(images, '_getLastFmAlbumInfo')
})
beforeEach(function () {
getInfoStub.reset()
})
after(function () {
getInfoStub.restore()
})
it('should use default image if track album or track artists are not available', function () {
images._setLastFmAlbumImage({}, img_element)
expect($(img_element).prop('src')).to.endWith(images.DEFAULT_ALBUM_URL)
})
it('should use correct size for setting album image', function () {
var track = {album: {name: 'albumMock', artists: [{name: 'artistMock'}]}}
getInfoResultMock = {album: {image: [
{'#text': 'mockAlbumSmallImageUri', size: 'small'},
{'#text': 'mockAlbumMedImageUri', size: 'medium'},
{'#text': 'mockAlbumLargeImageUri', size: 'large'}
]}}
getInfoStub.returns($.when(getInfoResultMock))
images._setLastFmAlbumImage(track, img_element, 'medium')
expect($(img_element).prop('src')).to.endWith('mockAlbumMedImageUri')
})
it('should default to "extralarge" if no image size is specified', function () {
var track = {album: {name: 'albumMock', artists: [{name: 'artistMock'}]}}
getInfoResultMock = {album: {image: [
{'#text': 'mockAlbumSmallImageUri', size: 'small'},
{'#text': 'mockAlbumMedImageUri', size: 'medium'},
{'#text': 'mockAlbumXLargeImageUri', size: 'extralarge'},
{'#text': 'mockAlbumLargeImageUri', size: 'large'}
]}}
getInfoStub.returns($.when(getInfoResultMock))
images._setLastFmAlbumImage(track, img_element)
expect($(img_element).prop('src')).to.endWith('mockAlbumXLargeImageUri')
})
it('should log last.fm errors', function () {
var track = {album: {name: 'albumMock', artists: [{name: 'artistMock'}]}}
getInfoStub.returns($.Deferred().reject('code', 'message'))
var consoleStub = sinon.stub(console, 'error')
images._setLastFmAlbumImage(track, img_element)
assert.isTrue(consoleStub.calledWith('Error getting album info from last.fm (%s: %s)', 'code', 'message'))
consoleStub.restore()
})
})
describe('#setArtistImage()', function () {
var getImagesResultMock
var library
var getImagesSpy
before(function () {
library = {
getImages: function () { return $.when(getImagesResultMock) }
}
mopidy.library = library
getImagesSpy = sinon.spy(mopidy.library, 'getImages')
})
afterEach(function () {
getImagesSpy.reset()
})
after(function () {
mopidy.library.getImages.restore()
})
it('should use default image if no artist URI is provided', function () {
images.setArtistImage('', '', img_element, mopidy)
expect($(img_element).prop('src')).to.endWith(images.DEFAULT_ARTIST_URL)
})
it('should get artist image from Mopidy, if available', function () {
getImagesResultMock = {'mock:artist:uri': [{uri: 'mockImageUri'}]}
var setArtistImageFromTrackStub = sinon.stub(images, '_setArtistImageFromTrack')
images.setArtistImage('mock:artist:uri', 'mock:track:uri', img_element, mopidy)
assert.isTrue(getImagesSpy.calledOnce)
expect($(img_element).prop('src')).to.endWith('mockImageUri')
assert.isFalse(setArtistImageFromTrackStub.called)
setArtistImageFromTrackStub.restore()
})
it('should fall back to retrieving artist image from last.fm', function () {
getImagesResultMock = {'mock:artist:uri': []}
var setArtistImageFromTrackStub = sinon.stub(images, '_setArtistImageFromTrack')
images.setArtistImage('mock:artist:uri', 'mock:track:uri', img_element, mopidy)
assert.isTrue(getImagesSpy.calledOnce)
assert.isTrue(setArtistImageFromTrackStub.calledOnce)
setArtistImageFromTrackStub.restore()
})
})
describe('#_setArtistImageFromTrack()', function () {
var lookupResultMock
var library
var getInfoStub
before(function () {
library = {
lookup: function () { return $.when(lookupResultMock) }
}
mopidy.library = library
getInfoStub = sinon.stub(images, '_getLastFmArtistInfo')
})
afterEach(function () {
getInfoStub.reset()
})
after(function () {
getInfoStub.restore()
})
it('should set artist image from last.fm using available Mopidy track information', function () {
lookupResultMock = {'mock:track:uri': [{album: {artists: [{name: 'artistMock'}]}}]}
var getInfoResultMock = {mbid: 'artistIDLookupMock', name: 'artistNameMock'}
getInfoStub.returns($.when(getInfoResultMock))
var setLastFmArtistImageStub = sinon.stub(images, '_setLastFmArtistImage')
images._setArtistImageFromTrack('mock:track:uri', img_element, mopidy, 'small')
assert(getInfoStub.called)
assert(setLastFmArtistImageStub.calledWithMatch('artistIDLookupMock'))
setLastFmArtistImageStub.restore()
})
it('should set artist info from last.fm using MusicBrainz ID, if available', function () {
lookupResultMock = {'mock:track:uri': [{album: {artists: [{musicbrainz_id: 'artistIDMock'}]}}]}
var setLastFmArtistImageStub = sinon.stub(images, '_setLastFmArtistImage')
images._setArtistImageFromTrack('mock:track:uri', img_element, mopidy, 'small')
assert(setLastFmArtistImageStub.calledWithMatch('artistIDMock'))
setLastFmArtistImageStub.restore()
})
it('should log last.fm errors', function () {
lookupResultMock = {'mock:track:uri': [{album: {artists: [{name: 'artistMock'}]}}]}
getInfoStub.returns($.Deferred().reject('code', 'message'))
var consoleStub = sinon.stub(console, 'error')
images._setArtistImageFromTrack('mock:track:uri', img_element, mopidy, 'small')
assert.isTrue(consoleStub.calledWith('Error retrieving artist info from last.fm. (%s: %s)', 'code', 'message'))
consoleStub.restore()
})
})
describe('#_setLastFmArtistImage()', function () {
var getInfoResultMock
var getInfoStub
before(function () {
getInfoStub = sinon.stub(images.lastFM.artist, 'getInfo')
})
beforeEach(function () {
getInfoStub.reset()
})
after(function () {
getInfoStub.restore()
})
it('should use default image if artist MusicBrainz ID is not available', function () {
images._setLastFmArtistImage('', img_element)
expect($(img_element).prop('src')).to.endWith(images.DEFAULT_ARTIST_URL)
})
it('should use correct size for setting album image', function () {
getInfoResultMock = {artist: {image: [
{'#text': 'mockAlbumSmallImageUri', size: 'small'},
{'#text': 'mockAlbumMedImageUri', size: 'medium'},
{'#text': 'mockAlbumLargeImageUri', size: 'large'}
]}}
getInfoStub.yieldsTo('success', getInfoResultMock)
images._setLastFmArtistImage('artistIDMock', img_element, 'medium')
expect($(img_element).prop('src')).to.endWith('mockAlbumMedImageUri')
})
it('should default to "extralarge" if no image size is specified', function () {
getInfoResultMock = {artist: {image: [
{'#text': 'mockAlbumSmallImageUri', size: 'small'},
{'#text': 'mockAlbumMedImageUri', size: 'medium'},
{'#text': 'mockAlbumXLargeImageUri', size: 'extralarge'},
{'#text': 'mockAlbumLargeImageUri', size: 'large'}
]}}
getInfoStub.yieldsTo('success', getInfoResultMock)
images._setLastFmArtistImage('artistIDMock', img_element)
expect($(img_element).prop('src')).to.endWith('mockAlbumXLargeImageUri')
})
it('should log last.fm errors', function () {
getInfoStub.yieldsTo('error', 'code', 'message')
var consoleStub = sinon.stub(console, 'error')
images._setLastFmArtistImage('artistIDMock', img_element)
assert.isTrue(consoleStub.calledWith('Error retrieving artist info from last.fm. (%s: %s)', 'code', 'message'))
consoleStub.restore()
})
})
})

56
tests/js/test_library.js Normal file
View File

@ -0,0 +1,56 @@
var chai = require('chai')
var expect = chai.expect
var assert = chai.assert
chai.use(require('chai-string'))
chai.use(require('chai-jquery'))
var sinon = require('sinon')
var library = require('../../mopidy_musicbox_webclient/static/js/library.js')
describe('Library', function () {
var selectID = '#selectSearchService'
var schemesArray = ['mockScheme1', 'mockScheme2', 'mockScheme3']
var mopidy = { getUriSchemes: function () { return $.when(schemesArray) } }
before(function () {
$(document.body).append('<select id="selectSearchService"></select>')
$('#selectSearchService').selectmenu()
})
describe('#getSearchSchemes()', function () {
beforeEach(function () {
$(selectID).empty()
})
it('should add human-readable options for backend schemes', function () {
uriHumanList = [['mockScheme2', 'mockUriHuman2']]
library.getSearchSchemes([], mopidy)
assert.equal($(selectID).children().length, schemesArray.length + 1)
expect($(selectID).children(':eq(2)')).to.have.text('MockUriHuman2')
})
it('should get default value from cookie', function () {
$.cookie('searchScheme', 'mockScheme3')
library.getSearchSchemes([], mopidy)
expect($(selectID + ' option:selected')).to.have.value('mockScheme3')
})
it('should default to "all" backends if no cookie is available', function () {
$.removeCookie('searchScheme')
library.getSearchSchemes([], mopidy)
expect($(selectID + ' option:selected')).to.have.value('all')
})
it('should capitalize first character of backend schema', function () {
library.getSearchSchemes([], mopidy)
expect($(selectID).children(':eq(1)')).to.have.text('MockScheme1')
})
it('should blacklist services that should not be searched', function () {
library.getSearchSchemes(['mockScheme2'], mopidy)
assert.equal($(selectID).children().length, schemesArray.length)
expect($(selectID).children()).not.to.contain('mockScheme2')
})
})
})

View File

@ -0,0 +1,529 @@
var chai = require('chai')
var expect = chai.expect
var assert = chai.assert
chai.use(require('chai-string'))
chai.use(require('chai-jquery'))
var sinon = require('sinon')
var SyncedProgressTimer = require('../../mopidy_musicbox_webclient/static/js/synced_timer.js')
describe('SyncedTimer', function () {
var MAX_ATTEMPTS = 8
var syncedProgressTimer
var mopidy
var playback
var getTimePositionStub
var clock
function setFakeTimers () {
clock = sinon.useFakeTimers()
syncedProgressTimer._progressTimer = new ProgressTimer({
callback: $.proxy(syncedProgressTimer.timerCallback, syncedProgressTimer),
disableRequestAnimationFrame: true // No window available during testing - use fallback mechanism to schedule updates
})
}
function restoreFakeTimers () {
clock.restore()
}
before(function () {
$(document.body).append(
'<div id="slidercontainer"><!-- slider for track position -->' +
'<span id="songelapsed"></span>' +
'<span id="songlength"></span>' +
'<label for="trackslider" disabled="disabled">Position</label>' +
'<input id="trackslider" name="trackslider"/>' +
'</div>'
)
$('#trackslider').slider() // Initialize slider
$('#trackslider').on('slidestart', function () {
syncedProgressTimer.stop()
$('#trackslider').on('change', function () { syncedProgressTimer.updatePosition($(this).val()) })
})
$('#trackslider').on('slidestop', function () {
$('#trackslider').off('change')
syncedProgressTimer.updatePosition($(this).val())
// Simulate doSeekPos($(this).val())
syncedProgressTimer.set($(this).val())
})
playback = {
getTimePosition: function () { return $.when(1000) },
getState: function () { return $.when('stopped') }
}
getTimePositionStub = sinon.stub(playback, 'getTimePosition')
// Simulate Mopidy's track position advancing 250ms between each call for 0:01 to 0:10
for (var i = 0; i < 10000 / 250; i++) {
getTimePositionStub.onCall(i).returns($.when((i + 1) * 250))
}
mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'}))
mopidy.playback = playback
})
beforeEach(function () {
syncedProgressTimer = new SyncedProgressTimer(MAX_ATTEMPTS, mopidy)
syncedProgressTimer._isConnected = true
})
afterEach(function () {
getTimePositionStub.reset()
})
describe('#SyncedTimer()', function () {
it('should add text nodes to DOM for position and duration indicators', function () {
expect($('#songelapsed')).to.have.text('')
expect($('#songlength')).to.have.text('')
})
it('should start out in unsynced state', function () {
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED)
})
})
describe('#format()', function () {
it('should set value of text node', function () {
assert.equal(SyncedProgressTimer.format(1000), '0:01')
})
it('should handle Infinity', function () {
assert.equal(SyncedProgressTimer.format(Infinity), '')
})
it('should handle zero', function () {
assert.equal(SyncedProgressTimer.format(0), '0:00')
})
})
describe('#timerCallback()', function () {
beforeEach(function () {
setFakeTimers()
})
afterEach(function () {
restoreFakeTimers()
})
it('should not try to sync unless connected to mopidy', function () {
var _doSyncStub = sinon.stub(syncedProgressTimer, '_doSync')
syncedProgressTimer._isConnected = false
syncedProgressTimer.set(0, 1000).start()
clock.tick(1000)
assert.isFalse(_doSyncStub.called, '_doSync called')
syncedProgressTimer.stop()
_doSyncStub.restore()
})
it('should update text nodes', function () {
var updateStub = sinon.stub(syncedProgressTimer, '_update')
syncedProgressTimer.set(0, 1000).start()
assert.isTrue(updateStub.called, '_update not called')
syncedProgressTimer.stop()
updateStub.restore()
})
it('should attempt to perform a sync as soon as timer is started', function () {
var syncStub = sinon.stub(syncedProgressTimer, '_doSync')
syncedProgressTimer.set(0, 1000).start() // 'start' will immediately schedule a sync.
clock.tick(250)
assert.isTrue(syncStub.called, '_doSync not called')
syncedProgressTimer.stop()
syncStub.restore()
})
it('should not attempt to perform a sync untill scheduled', function () {
var syncStub = sinon.stub(syncedProgressTimer, '_doSync')
syncedProgressTimer.set(0, 5000).start()
syncedProgressTimer._scheduleSync(500)
clock.tick(250)
assert.isFalse(syncStub.called, 'next _doSync should only have been called after 500ms')
syncStub.reset()
clock.tick(500)
assert.isTrue(syncStub.called, 'next _doSync not called after 500ms')
syncedProgressTimer.stop()
syncStub.restore()
})
it('should perform sync', function () {
// Simulate runtime on a 5-second track
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED, 'Timer was initialized in incorrect state')
syncedProgressTimer.set(0, 5000).start()
var wasSyncing = false
for (var i = 0; i < 4; i++) {
clock.tick(250)
wasSyncing = wasSyncing || syncedProgressTimer.syncState === SyncedProgressTimer.SYNC_STATE.SYNCING
}
syncedProgressTimer.stop()
assert.isTrue(wasSyncing, 'Timer never entered the "syncing" state')
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED, 'Timer failed to sync')
syncedProgressTimer.stop()
})
})
describe('#_update()', function () {
it('should set duration to "" for tracks with infinite duration (e.g. streams)', function () {
syncedProgressTimer._update(1000, Infinity)
assert.equal(syncedProgressTimer.durationNode.nodeValue, '')
})
it('should show "(wait)" while untill syncing starts', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
syncedProgressTimer._update(1000, 2000)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '(wait)')
})
it('should show "(sync)" while trying to sync up with Mopidy', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
syncedProgressTimer._update(1000, 2000)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '(sync)')
})
it('should update position text and position track slider when synced', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED
syncedProgressTimer._update(1000, 2000)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:01')
assert.equal($('#trackslider').val(), 1000)
})
})
describe('#scheduleSync', function () {
beforeEach(function () {
setFakeTimers()
})
afterEach(function () {
restoreFakeTimers()
})
it('should schedule sync when scheduled time arrives', function () {
clock.tick(0)
syncedProgressTimer._scheduleSync(1000)
assert.isFalse(syncedProgressTimer._isSyncScheduled)
clock.tick(1001)
assert.isTrue(syncedProgressTimer._isSyncScheduled)
})
it('should clear schedule on each call', function () {
var clearSpy = sinon.spy(window, 'clearTimeout')
clock.tick(0)
syncedProgressTimer._isSyncScheduled = true
syncedProgressTimer._scheduleSync(1000)
assert.isFalse(syncedProgressTimer._isSyncScheduled)
var scheduleID = syncedProgressTimer._scheduleID
clock.tick(1001)
syncedProgressTimer._scheduleSync(1000)
assert(clearSpy.calledWith(scheduleID))
window.clearTimeout.restore()
})
})
describe('#_doSync', function () {
beforeEach(function () {
setFakeTimers()
})
afterEach(function () {
restoreFakeTimers()
})
it('should not try to sync until timer has been set', function () {
syncedProgressTimer._doSync(0, Infinity)
assert.isFalse(getTimePositionStub.called, 'tried to do sync even though the timer has not been set')
})
it('should request position from Mopidy', function () {
syncedProgressTimer._doSync(1000, 2000)
assert.isTrue(getTimePositionStub.called, 'getTimePosition not called')
})
it('should set state to "SYNCING" as soon as the first sync attempt is made', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED
syncedProgressTimer._doSync(100, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
})
it('should set state to synced after two consecutive successful syncs (i.e. time drift < 500ms)', function () {
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED)
clock.tick(250)
syncedProgressTimer._doSync(250, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
clock.tick(250)
syncedProgressTimer._doSync(500, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED)
})
it('should re-initialize and set state to syncing if time drift is more than 500ms', function () {
var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync')
syncedProgressTimer._doSync(1000, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts)
assert.isNull(syncedProgressTimer._previousSyncPosition)
assert(scheduleStub.calledWith(1000), 'Expected next sync to be scheduled 1s from now')
scheduleStub.restore()
})
it('should step back exponentially while syncing', function () {
var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync')
for (var i = 0; i < syncedProgressTimer._maxAttempts; i++) {
syncedProgressTimer._doSync(i * 250, 2000)
assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts - i - 1, 'Incorrect number of sync attempts remaining')
assert(scheduleStub.calledWith(0.25 * (Math.pow(2, i)) * 1000), 'Incorrect sync time scheduled: ' + scheduleStub.getCall(i))
scheduleStub.reset()
}
scheduleStub.restore()
})
it('should check sync every 32s once synced', function () {
var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync')
syncedProgressTimer._syncAttemptsRemaining = 0
syncedProgressTimer._doSync(250, 2000)
assert(scheduleStub.calledWith(32000))
scheduleStub.restore()
})
it('should not sync unless track playback is progressing', function () {
getTimePositionStub.restore()
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED)
clock.tick(250)
syncedProgressTimer._doSync(250, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
clock.tick(250)
syncedProgressTimer._doSync(250, 2000)
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING)
// Restore getTimePositionStub to previous state
getTimePositionStub = sinon.stub(playback, 'getTimePosition')
// Simulate Mopidy's track position advancing 250ms between each call for 0:01 to 0:10
for (var i = 0; i < 10000 / 250; i++) {
getTimePositionStub.onCall(i).returns($.when((i + 1) * 250))
}
})
})
describe('#set()', function () {
it('should throw exception if no arguments are provided', function () {
assert.throw(function () { syncedProgressTimer.set() }, Error)
})
it('should set position if only one argument is provided', function () {
syncedProgressTimer.set(1000)
assert.equal(syncedProgressTimer._progressTimer._state.position, 1000)
})
it('should update position and track slider immediately', function () {
syncedProgressTimer.stop()
syncedProgressTimer.set(1000, 2000)
expect($('#songelapsed').text()).to.equal('0:01')
assert.equal($('#trackslider').val(), 1000)
})
})
describe('#start()', function () {
it('should start timer', function () {
var startStub = sinon.stub(syncedProgressTimer._progressTimer, 'start')
syncedProgressTimer.start()
assert(startStub.called)
syncedProgressTimer.stop()
startStub.restore()
})
it('should always start in unsynced state', function () {
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED
syncedProgressTimer.start()
assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED)
syncedProgressTimer.stop()
})
it('should schedule a sync immediately', function () {
var scheduleSpy = sinon.spy(syncedProgressTimer, '_scheduleSync')
syncedProgressTimer.set(0, 1000)
syncedProgressTimer._isSyncScheduled = false
syncedProgressTimer.start()
assert(scheduleSpy.calledWith(0))
syncedProgressTimer.stop()
syncedProgressTimer._scheduleSync.restore()
})
})
describe('#stop()', function () {
it('should stop timer', function () {
var stopStub = sinon.stub(syncedProgressTimer._progressTimer, 'stop')
syncedProgressTimer.stop()
assert(stopStub.called)
syncedProgressTimer.stop()
stopStub.restore()
})
it('should show last synced position if stopped while busy syncing', function () {
syncedProgressTimer.set(1000, 5000)
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED
syncedProgressTimer._previousSyncPosition = 1000
syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING
syncedProgressTimer._update(2000, 5000)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '(sync)')
syncedProgressTimer.stop()
assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:01')
expect($('#songelapsed').text()).to.equal('0:01')
})
it('should cancel any scheduled syncs', function () {
var cancelSpy = sinon.spy(window, 'clearTimeout')
syncedProgressTimer._isSyncScheduled = true
syncedProgressTimer.stop()
assert.isFalse(syncedProgressTimer._isSyncScheduled)
assert(cancelSpy.calledWith(syncedProgressTimer._scheduleID))
window.clearTimeout.restore()
})
})
describe('#reset()', function () {
it('should reset timer to "" - "" ', function () {
var stopStub = sinon.stub(syncedProgressTimer, 'stop')
var setStub = sinon.stub(syncedProgressTimer, 'set')
syncedProgressTimer.reset()
assert(stopStub.called)
assert(setStub.called)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '', 'Position node was not reset')
assert.equal(syncedProgressTimer.durationNode.nodeValue, '', 'Duration node was not reset')
stopStub.restore()
setStub.restore()
})
})
describe('#updatePosition()', function () {
it('should format and set position node', function () {
var formatSpy = sinon.spy(SyncedProgressTimer, 'format')
assert.equal(syncedProgressTimer.positionNode.nodeValue, '')
syncedProgressTimer.updatePosition(1000)
assert.isTrue(formatSpy.called)
expect(syncedProgressTimer.positionNode.nodeValue).to.equal('0:01')
SyncedProgressTimer.format.restore()
})
it('should set position to "" if timer has not been initialized', function () {
syncedProgressTimer.set(1000, 2000)
expect(syncedProgressTimer.positionNode.nodeValue).to.equal('0:01')
syncedProgressTimer.updatePosition(0)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:00', 'Position node was not reset')
syncedProgressTimer.reset()
syncedProgressTimer.updatePosition(0)
assert.equal(syncedProgressTimer.positionNode.nodeValue, '', 'Position node was not reset')
})
})
describe('integration tests', function () {
beforeEach(function () {
setFakeTimers()
})
afterEach(function () {
restoreFakeTimers()
})
it('simulate 30-second test run, ', function () {
// Initialize
syncedProgressTimer.reset()
expect($('#songelapsed').text()).to.equal('')
expect($('#songlength').text()).to.equal('')
assert.equal($('#trackslider').val(), 0)
// Set song info
syncedProgressTimer.set(0, 30000)
expect($('#songelapsed').text()).to.equal('0:00')
expect($('#songlength').text()).to.equal('0:30')
assert.equal($('#trackslider').val(), 0)
// Start
syncedProgressTimer.start()
clock.tick(40)
expect($('#songelapsed').text()).to.equal('(wait)')
expect($('#songlength').text()).to.equal('0:30')
assert.equal($('#trackslider').val(), 0)
// Syncing
clock.tick(250)
expect($('#songelapsed').text()).to.equal('(sync)')
expect($('#songlength').text()).to.equal('0:30')
assert.equal($('#trackslider').val(), 0)
// Synced
clock.tick(1000)
expect($('#songelapsed').text()).to.equal('0:01')
expect($('#songlength').text()).to.equal('0:30')
assert.isAtLeast($('#trackslider').val(), 1000)
// Move slider
$('#trackslider').trigger('slidestart')
clock.tick(250)
$('#trackslider').val(5000).slider('refresh')
$('#trackslider').trigger('change')
clock.tick(250)
$('#trackslider').trigger('slidestop')
clock.tick(1000) // Position should remain '0:05' as the timer should not be running after a slider change
expect($('#songelapsed').text()).to.equal('0:05')
// Start -> Sync -> Stop
syncedProgressTimer.start()
clock.tick(40)
expect($('#songelapsed').text()).to.equal('(sync)')
syncedProgressTimer._previousSyncPosition = 1000
syncedProgressTimer.stop()
expect($('#songelapsed').text()).to.equal('0:01')
expect($('#songlength').text()).to.equal('0:30')
syncedProgressTimer.stop()
})
})
describe('regression tests for https://github.com/adamcik/media-progress-timer/issues/3', function () {
it('should not be possible to set position > duration', function () {
syncedProgressTimer.set(1000, 2000).start()
assert.equal(syncedProgressTimer._duration, 2000)
syncedProgressTimer.set(3000)
assert.equal(syncedProgressTimer._progressTimer._state.position, 1999, 'Expected position to be less than duration')
syncedProgressTimer.stop()
})
it('should keep timer running even if an update would cause position > duration', function () {
setFakeTimers()
clock.tick(0)
clock.tick(1000)
syncedProgressTimer.set(0, 1000).start()
clock.tick(2000)
assert.isNotNull(syncedProgressTimer._progressTimer._updateId)
syncedProgressTimer.stop()
restoreFakeTimers()
})
})
})

View File

@ -1,22 +1,39 @@
from __future__ import unicode_literals
from __future__ import absolute_import, division, print_function, unicode_literals
from mopidy_musicbox_webclient import MusicBoxExtension
import unittest
import mock
from mopidy_musicbox_webclient import Extension
def test_get_default_config():
ext = MusicBoxExtension()
class ExtensionTests(unittest.TestCase):
config = ext.get_default_config()
def test_get_default_config(self):
ext = Extension()
assert '[musicbox_webclient]' in config
assert 'enabled = true' in config
config = ext.get_default_config()
assert '[musicbox_webclient]' in config
assert 'enabled = true' in config
assert 'websocket_host =' in config
assert 'websocket_port =' in config
assert 'on_track_click = PLAY_ALL' in config
def test_get_config_schema():
ext = MusicBoxExtension()
def test_get_config_schema(self):
ext = Extension()
schema = ext.get_config_schema()
assert 'musicbox' in schema
schema = ext.get_config_schema()
assert 'musicbox' in schema
assert 'websocket_host' in schema
assert 'websocket_port' in schema
assert 'on_track_click' in schema
# TODO Write more tests
def test_setup(self):
registry = mock.Mock()
ext = Extension()
ext.setup(registry)
calls = [mock.call('http:app', {'name': ext.ext_name, 'factory': ext.factory})]
registry.add.assert_has_calls(calls, any_order=True)

67
tests/test_web.py Normal file
View File

@ -0,0 +1,67 @@
from __future__ import absolute_import, division, print_function, unicode_literals
import mock
import mopidy.config as config
import tornado.testing
import tornado.web
import tornado.websocket
from mopidy_musicbox_webclient import Extension
from mopidy_musicbox_webclient.web import StaticHandler
class BaseTest(tornado.testing.AsyncHTTPTestCase):
def get_app(self):
extension = Extension()
self.config = config.Proxy({'musicbox_webclient': {
'enabled': True,
'musicbox': True,
'websocket_host': '',
'websocket_port': '',
}
})
return tornado.web.Application(extension.factory(self.config, mock.Mock()))
class StaticFileHandlerTest(BaseTest):
def test_static_handler(self):
response = self.fetch('/vendors/mopidy/mopidy.js', method='GET')
assert response.code == 200
def test_get_version(self):
assert StaticHandler.get_version(None, None) == Extension.version
class RedirectHandlerTest(BaseTest):
def test_redirect_handler(self):
response = self.fetch('/', method='GET', follow_redirects=False)
assert response.code == 301
response.headers['Location'].endswith('index.html')
class IndexHandlerTest(BaseTest):
def test_index_handler(self):
response = self.fetch('/index.html', method='GET')
assert response.code == 200
def test_get_title(self):
response = self.fetch('/index.html', method='GET')
body = tornado.escape.to_unicode(response.body)
assert '<title>MusicBox on localhost</title>' in body
def test_initialize_sets_dictionary_objects(self):
response = self.fetch('/index.html', method='GET')
body = tornado.escape.to_unicode(response.body)
assert 'data-is-musicbox="true"' in body
assert 'data-has-alarmclock="false"' in body
assert 'data-websocket-url=""' in body

81
tests/test_webclient.py Normal file
View File

@ -0,0 +1,81 @@
from __future__ import absolute_import, division, print_function, unicode_literals
import unittest
import mock
import mopidy.config as mopidy_config
from mopidy_musicbox_webclient import Extension
from mopidy_musicbox_webclient.webclient import Webclient
class WebclientTests(unittest.TestCase):
def setUp(self):
config = mopidy_config.Proxy(
{
'musicbox_webclient': {
'enabled': True,
'musicbox': False,
'websocket_host': 'host_mock',
'websocket_port': 999,
}
})
self.ext = Extension()
self.mmw = Webclient(config)
def test_get_version(self):
assert self.mmw.get_version() == self.ext.version
def test_get_websocket_url_uses_config_file(self):
assert self.mmw.get_websocket_url(mock.Mock()) == 'ws://host_mock:999/mopidy/ws'
def test_get_websocket_url_uses_request_host(self):
config = mopidy_config.Proxy(
{
'musicbox_webclient': {
'enabled': True,
'musicbox': False,
'websocket_host': '',
'websocket_port': 999,
}
})
request_mock = mock.Mock(spec='tornado.HTTPServerRequest')
request_mock.host = '127.0.0.1'
request_mock.protocol = 'https'
self.mmw.config = config
assert self.mmw.get_websocket_url(request_mock) == 'wss://127.0.0.1:999/mopidy/ws'
def test_get_websocket_url_uses_http_port(self):
config = mopidy_config.Proxy(
{
'http': {
'port': 999
},
'musicbox_webclient': {
'enabled': True,
'musicbox': False,
'websocket_host': '127.0.0.1',
'websocket_port': '',
}
})
request_mock = mock.Mock(spec='tornado.HTTPServerRequest')
request_mock.host = '127.0.0.1'
request_mock.protocol = 'https'
self.mmw.config = config
assert self.mmw.get_websocket_url(request_mock) == 'wss://127.0.0.1:999/mopidy/ws'
def test_has_alarmclock(self):
assert not self.mmw.has_alarm_clock()
def test_is_musicbox(self):
assert not self.mmw.is_music_box()
def test_default_click_action(self):
assert self.mmw.get_default_click_action() == 'PLAY_ALL'

88
tidy.js Normal file
View File

@ -0,0 +1,88 @@
var tidy = require('tidy-html5').tidy_html5
var fs = require('fs')
// Traverse directory structure looking for 'html' or 'htm' files.
var getAllHtmlFilesFromFolder = function (dir) {
var filesystem = require('fs')
var results = []
filesystem.readdirSync(dir).forEach(function (file) {
file = dir + '/' + file
var stat = filesystem.statSync(file)
if (stat && stat.isDirectory()) {
results = results.concat(getAllHtmlFilesFromFolder(file))
} else {
var extension = file.substr(file.lastIndexOf('.') + 1).toUpperCase()
if (extension === 'HTM' || extension === 'HTML') {
results.push(file)
}
}
})
return results
}
// Read file contents.
function readFiles (dirname, onFileContent) {
var filenames = getAllHtmlFilesFromFolder(dirname)
filenames.forEach(function (filename) {
fs.readFile(filename, 'utf-8', function (err, content) {
if (err) {
throw (err)
}
onFileContent(filename, content)
})
})
}
var util = require('util')
// Trap stderr output so that we can detect parsing errors.
function hook_stderr (callback) {
var old_write = process.stderr.write
process.stderr.write = (function (write) {
return function (string, encoding, fd) {
write.apply(process.stdout, arguments)
callback(string, encoding, fd)
}
})(process.stderr.write)
return function () {
process.stderr.write = old_write
}
}
var unhook = hook_stderr(function (string, encoding, fd) {
if (string.indexOf('Error:') > 0) {
errors.push(string)
}
})
var errorsOccurred = false
var errors = []
// Exit with status 1 so that tox can detect errors.
process.on('exit', function () {
if (errorsOccurred === true) {
process.exit(1)
}
})
// Start linter
function processFiles (callback) {
console.log('Starting HTML linter...')
readFiles('mopidy_musicbox_webclient/static', function (filename, content) {
console.log('\n' + filename)
var result = tidy(content, {'quiet': true})
if (errors.length > 0) {
console.error('\nHTML errors detected:\n' + errors.join(''))
errors = []
errorsOccurred = true
}
})
}
processFiles(function () {
unhook()
})

59
tox.ini
View File

@ -1,23 +1,76 @@
[tox]
envlist = py27, flake8
envlist = py27, flake8, test, eslint, csslint, tidy
[testenv]
passenv = TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH
sitepackages = true
whitelist_externals =
py.test
deps =
mock
mopidy
pytest
pytest-capturelog
pytest-cov
pytest-xdist
responses
install_command = pip install --allow-unverified=mopidy --pre {opts} {packages}
commands =
py.test \
--basetemp={envtmpdir} \
--junit-xml=xunit-{envname}.xml \
--cov=mopidy_musicbox_webclient --cov-report=term-missing \
{posargs}
{posargs:tests/}
[testenv:flake8]
sitepackages = false
deps =
flake8
flake8-import-order
pep8-naming
skip_install = true
commands = flake8
commands = flake8 --show-source --statistics --max-line-length 120 {posargs:mopidy_musicbox_webclient tests}
[testenv:test]
sitepackages = false
whitelist_externals =
/bin/bash
deps =
nodeenv
skip_install = true
commands =
- nodeenv --prebuilt {toxworkdir}/node_env
bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm test'
[testenv:eslint]
sitepackages = false
whitelist_externals =
/bin/bash
deps =
nodeenv
skip_install = true
commands =
- nodeenv --prebuilt {toxworkdir}/node_env
bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm run eslint'
[testenv:csslint]
sitepackages = false
whitelist_externals =
/bin/bash
deps =
nodeenv
skip_install = true
commands =
- nodeenv --prebuilt {toxworkdir}/node_env
bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm run csslint'
[testenv:tidy]
sitepackages = false
whitelist_externals =
/bin/bash
deps =
nodeenv
skip_install = true
commands =
- nodeenv --prebuilt {toxworkdir}/node_env
bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm run tidy'