diff --git a/.csslintrc b/.csslintrc new file mode 100644 index 0000000..a271b01 --- /dev/null +++ b/.csslintrc @@ -0,0 +1 @@ +--format=compact diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5b838e3 --- /dev/null +++ b/.eslintrc @@ -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, + } +} diff --git a/.gitignore b/.gitignore index 1b83512..8c6a739 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.travis.yml b/.travis.yml index 637d177..ec29c38 100644 --- a/.travis.yml +++ b/.travis.yml @@ -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" diff --git a/README.rst b/README.rst index 1dc0007..a66f594 100644 --- a/README.rst +++ b/README.rst @@ -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 `_) -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 `_ 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 `_. +Mopidy MusicBox Webclient (MMW) is a frontend extension and JavaScript-based web client especially written for +`Mopidy `_. -.. 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 `_. +- 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 `_). +- Now remembers which backend was searched previously, and automatically selects that backend as the default search target. + (Addresses: `#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 `_). +- Optimized updating of 'now playing' icons in tracklists. + (Addresses: `#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 `_). +- Context menu is now available for all tracks in browse pane. (Fixes: `#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 `_). +- 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 `_). +- Replaced JavaScript confirmation prompt on 'Streams' pane with jQuery equivalent. + (Fixes: `#191 `_). +- Clearing the queue should no longer trigger an album cover image lookup. + (Fixes: `#201 `_). + v2.2.0 (2016-03-01) ------------------- diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..7f456f9 --- /dev/null +++ b/karma.conf.js @@ -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' } + ] + } + }) +} diff --git a/mopidy_musicbox_webclient/__init__.py b/mopidy_musicbox_webclient/__init__.py index 1884794..2bc628e 100644 --- a/mopidy_musicbox_webclient/__init__.py +++ b/mopidy_musicbox_webclient/__init__.py @@ -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): diff --git a/mopidy_musicbox_webclient/ext.conf b/mopidy_musicbox_webclient/ext.conf index fa7898e..f5d8a17 100644 --- a/mopidy_musicbox_webclient/ext.conf +++ b/mopidy_musicbox_webclient/ext.conf @@ -3,3 +3,4 @@ enabled = true musicbox = false websocket_host = websocket_port = +on_track_click = PLAY_ALL diff --git a/mopidy_musicbox_webclient/static/css/webclient.css b/mopidy_musicbox_webclient/static/css/webclient.css index 68ece16..48d4e5a 100644 --- a/mopidy_musicbox_webclient/static/css/webclient.css +++ b/mopidy_musicbox_webclient/static/css/webclient.css @@ -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; diff --git a/mopidy_musicbox_webclient/static/index.html b/mopidy_musicbox_webclient/static/index.html index 22a9158..db57490 100644 --- a/mopidy_musicbox_webclient/static/index.html +++ b/mopidy_musicbox_webclient/static/index.html @@ -1,30 +1,14 @@ - Musicbox + {{ title }} - - - + + + + + @@ -52,11 +36,10 @@ - +
-
- - + diff --git a/mopidy_musicbox_webclient/static/js/controls.js b/mopidy_musicbox_webclient/static/js/controls.js index 2638886..41d5471 100644 --- a/mopidy_musicbox_webclient/static/js/controls.js +++ b/mopidy_musicbox_webclient/static/js/controls.js @@ -1,665 +1,658 @@ -/*********************************** - * play tracks from a browse list * - ***********************************/ -function playBrowsedTracks(action, trackIndex) { - $('#popupBrowse').popup('close'); - toast('Loading...'); +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + define([], factory) + } else if (typeof module === 'object' && module.exports) { + module.exports = factory() + } else { + root.controls = factory() + } +}(this, function () { + 'use strict' - if (typeof trackIndex === 'undefined') { - trackIndex = $('#popupBrowse').data("tlid"); - } - if (action == PLAY_ALL) { - mopidy.tracklist.clear(); - // Default for radio streams is to just add the selected URI. - if (isStreamUri(browseTracks[trackIndex].uri)) { - action = PLAY_NOW; - } - } - var trackUris = []; - switch (action) { - case PLAY_NOW: - case PLAY_NEXT: - case ADD_THIS_BOTTOM: - trackUris.push(browseTracks[trackIndex].uri); - break; - case PLAY_ALL: - case ADD_ALL_BOTTOM: - trackUris = getUris(browseTracks); - break; - default: - break; - } - var maybePlay = function(tlTracks) { - if (action === PLAY_NOW || action === PLAY_ALL) { - var playIndex = (action === PLAY_ALL) ? trackIndex : 0; - mopidy.playback.play({'tl_track': tlTracks[playIndex]}); - } - }; - - switch (action) { - case PLAY_NOW: - case PLAY_NEXT: - mopidy.tracklist.index().then(function (currentIndex) { - mopidy.tracklist.add({'at_position': currentIndex + 1, 'uris': trackUris}).then(maybePlay); - }); - break; - case ADD_THIS_BOTTOM: - case ADD_ALL_BOTTOM: - case PLAY_ALL: - mopidy.tracklist.add({'uris': trackUris}).then(maybePlay); - break; - default: - break; - } - return false; -} + var controls = { -/********************************* - * play an uri from a tracklist * - *********************************/ -function playTrack(action) { - var hash = document.location.hash.split('?'); - var divid = hash[0].substr(1); + /** + * 'onClick' handler for tracks that are rendered in a list. + * + * Adds tracks to current tracklist and starts playback if necessary. + * + * @param {string} action - The action to perform. Valid actions are: + * PLAY_NOW: add the track at 'trackIndex' and start playback. + * PLAY_NEXT: insert track after currently playing track. + * ADD_THIS_BOTTOM: add track to bottom of tracklist. + * ADD_ALL_BOTTOM: add all tracks in in the list to bottom of + * tracklist. + * PLAY_ALL: clear tracklist and start playback of the track + * with URI provided in 'trackUri'. + * @param {object} mopidy - The Mopidy.js object that should be used to communicate with the + * Mopidy server. + * @param {string} trackUri - (Optional) The URI of the specific track that the action should + * be performed on. If no URI is provided then the 'data' attribute + * of the popup DIV is assumed to contain the track URI. + * @param {string} playlistUri - (Optional) The URI of the playlist containing the tracks + * to be played. If no URI is provided then the 'list' attribute + * of the popup DIV is assumed to contain the playlist URI. + */ - // Search page default click behaviour adds and plays selected track only. - if (action == PLAY_NOW && divid == 'search') { - action = PLAY_NOW_SEARCH; - } - - $('#popupTracks').popup('close'); - $('#controlspopup').popup('close'); - toast('Loading...'); + playTracks: function (action, mopidy, trackUri, playlistUri) { + $('#popupTracks').popup('close') + toast('Loading...') - playlisturi = $('#popupTracks').data("list"); - uri = $('#popupTracks').data("track"); + trackUri = trackUri || $('#popupTracks').data('track') + if (typeof trackUri === 'undefined') { + throw new Error('No track URI provided for playback.') + } + playlistUri = playlistUri || $('#popupTracks').data('list') + if (typeof playlistUri === 'undefined') { + throw new Error('No playlist URI provided for playback.') + } - var trackUris = getTracksFromUri(playlisturi); - //find track that was selected - for (var selected = 0; selected < trackUris.length; selected++) { - if (trackUris[selected] == uri) { - break; - } - } - switch (action) { - case ADD_THIS_BOTTOM: - case PLAY_NEXT: - case PLAY_NOW_SEARCH: - trackUris = [trackUris[selected]]; - selected = 0; - } - switch (action) { - case PLAY_NOW: - case PLAY_NOW_SEARCH: + action = controls.getAction(action) + + if (action === PLAY_ALL) { + mopidy.tracklist.clear() + } + + var trackUris = controls._getTrackURIsForAction(action, trackUri, playlistUri) + // Add the tracks and start playback if necessary. + switch (action) { + case PLAY_NOW: + case PLAY_NEXT: + // Find track that is currently playing. + mopidy.tracklist.index().then(function (currentIndex) { + // Add browsed track just below it. + mopidy.tracklist.add({at_position: currentIndex + 1, uris: trackUris}).then(function (tlTracks) { + if (action === PLAY_NOW) { // Start playback immediately. + mopidy.playback.stop().then(function () { + mopidy.playback.play({tlid: tlTracks[0].tlid}) + }) + } + }) + }) + break + case ADD_THIS_BOTTOM: + case ADD_ALL_BOTTOM: + case PLAY_ALL: + mopidy.tracklist.add({uris: trackUris}).then(function (tlTracks) { + if (action === PLAY_ALL) { // Start playback of selected track immediately. + mopidy.tracklist.filter({uri: [trackUri]}).then(function (tlTracks) { + mopidy.playback.stop().then(function () { + mopidy.playback.play({tlid: tlTracks[0].tlid}) + }) + }) + } + }) + break + default: + throw new Error('Unexpected tracklist action identifier: ' + action) + } + + if (window[$(document.body).data('on-track-click')] === DYNAMIC) { + // Save last 'action' - will become default for future 'onClick' events + var previousAction = $.cookie('onTrackClick') + if (typeof previousAction === 'undefined' || action !== previousAction) { + $.cookie('onTrackClick', action, { expires: 365 }) + updatePlayIcons('', '', controls.getIconForAction(action)) + } + } + }, + + /* Getter function for 'action' variable. Also checks config settings and cookies if required. */ + getAction: function (action) { + if (typeof action === 'undefined' || action.length === 0) { // Action parameter not provided, use defaults + action = window[$(document.body).data('on-track-click')] + } + if (action === DYNAMIC) { // Re-use last action stored in cookie. + action = $.cookie('onTrackClick') + if (typeof action === 'undefined') { + action = PLAY_ALL // Backwards-compatible default value. + } + } + return action + }, + + /* Retrieves the Font Awesome character for the given action. */ + getIconForAction: function (action) { + action = controls.getAction(action) + + switch (parseInt(action)) { + case PLAY_ALL: + return 'fa fa-play-circle' + case PLAY_NOW: + return 'fa fa-play-circle-o' + case PLAY_NEXT: + return 'fa fa-level-down' + case ADD_THIS_BOTTOM: + return 'fa fa-plus-square-o' + case ADD_ALL_BOTTOM: + return 'fa fa-plus-square' + default: + throw new Error('Unkown tracklist action identifier: ' + action) + } + }, + + /* Retrieves the relevant track URIs for the given action. */ + _getTrackURIsForAction: function (action, trackUri, playlistUri) { + var trackUris = [] + // Fill 'trackUris', by determining which tracks should be added. + switch (parseInt(action)) { + case PLAY_NOW: + case PLAY_NEXT: + case ADD_THIS_BOTTOM: + // Process single track + trackUris.push(trackUri) + break + case PLAY_ALL: + case ADD_ALL_BOTTOM: + // Process all tracks in playlist + trackUris = getTracksFromUri(playlistUri, false) + break + default: + throw new Error('Unexpected tracklist action identifier: ' + action) + } + return trackUris + }, + + /** ****************************************************** + * play an uri from the queue + *********************************************************/ + + /** * + * Plays a Track from a Playlist. + * @param tlid + * @returns {boolean} + */ + playQueueTrack: function (tlid) { + // Stop directly, for user feedback + mopidy.playback.stop() + $('#popupQueue').popup('close') + toast('Loading...') + + tlid = tlid || $('#popupQueue').data('tlid') + mopidy.playback.play({'tlid': parseInt(tlid)}) + }, + + /** ********************************* + * remove a track from the queue * + ***********************************/ + removeTrack: function (tlid) { + $('#popupQueue').popup('close') + toast('Deleting...') + + tlid = tlid || $('#popupQueue').data('tlid') + mopidy.tracklist.remove({'tlid': [parseInt(tlid)]}) + }, + + clearQueue: function () { mopidy.tracklist.clear().then( - mopidy.tracklist.add({'uris': trackUris}).then( - function(tlTracks) { - mopidy.playback.play({'tl_track': tlTracks[selected]}); - } - ) - ); - break; - case PLAY_NEXT: - mopidy.tracklist.index().then(function(currentIndex) { - mopidy.tracklist.add({'at_position': currentIndex + 1, 'uris': trackUris}); - }); - break; - case ADD_THIS_BOTTOM: - case ADD_ALL_BOTTOM: - mopidy.tracklist.add({'uris': trackUris}); - break; - } - return false; -} + resetSong() + ) + return false + }, -/*** - * Plays a Track given by an URI from the given playlist URI. - * @param track_uri, playlist_uri - * @returns {boolean} - */ -function playTrackByUri(track_uri, playlist_uri) { - function findAndPlayTrack(tlTracks) { - if (tlTracks.length > 0) { - // Find track that was selected - for (var selected = 0; selected < tlTracks.length; selected++) { - if (tlTracks[selected].track.uri == track_uri) { - mopidy.playback.play({'tl_track': tlTracks[selected]}); - return; + savePressed: function (key) { + if (key === 13) { + controls.saveQueue() + return false + } + return true + }, + + showSavePopup: function () { + mopidy.tracklist.getTracks().then(function (tracks) { + if (tracks.length > 0) { + $('#saveinput').val('') + $('#popupSave').popup('open') } + }) + }, + + saveQueue: function () { + mopidy.tracklist.getTracks().then(function (tracks) { + var playlistName = $('#saveinput').val().trim() + if (playlistName !== null && playlistName !== '') { + controls.getPlaylistByName(playlistName, 'm3u', false).then(function (exists) { + if (exists) { + $('#popupSave').popup('close') + $('#popupOverwrite').popup('open') + $('#overwriteConfirmBtn').click(function () { + controls.initSave(playlistName, tracks) + }) + } else { + controls.initSave(playlistName, tracks) + } + }) + } + }) + return false + }, + + initSave: function (playlistName, tracks) { + $('#popupOverwrite').popup('close') + $('#popupSave').popup('close') + $('#saveinput').val('') + toast('Saving...') + mopidy.playlists.create({'name': playlistName, 'uri_scheme': 'm3u'}).then(function (playlist) { + playlist.tracks = tracks + mopidy.playlists.save({'playlist': playlist}).then() + }) + }, + + refreshPlaylists: function () { + mopidy.playlists.refresh().then(function () { + playlists = {} + $('#playlisttracksdiv').hide() + $('#playlistslistdiv').show() + }) + return false + }, + + /** *********** + * Buttons * + *************/ + + doShuffle: function () { + mopidy.playback.stop() + mopidy.tracklist.shuffle() + mopidy.playback.play() + }, + + /* Toggle state of play button */ + setPlayState: function (nwplay) { + if (nwplay) { + $('#btplayNowPlaying >i').removeClass('fa-play').addClass('fa-pause') + $('#btplayNowPlaying').attr('title', 'Pause') + $('#btplay >i').removeClass('fa-play').addClass('fa-pause') + $('#btplay').attr('title', 'Pause') + mopidy.playback.getTimePosition().then(processCurrentposition, console.error) + syncedProgressTimer.start() + } else { + $('#btplayNowPlaying >i').removeClass('fa-pause').addClass('fa-play') + $('#btplayNowPlaying').attr('title', 'Play') + $('#btplay >i').removeClass('fa-pause').addClass('fa-play') + $('#btplay').attr('title', 'Play') + syncedProgressTimer.stop() } - } - console.error('Failed to find and play selected track ', track_uri); - return; - } + play = nwplay + }, - // Stop directly, for user feedback - mopidy.tracklist.clear(); - - //this is deprecated, remove when popuptracks is removed completly - $('#popupTracks').popup('close'); - $('#controlspopup').popup('close'); - //end of deprecated - - toast('Loading...'); - - mopidy.tracklist.add({'uris': [playlist_uri]}).then(function(tlTracks) { - // Can fail for all sorts of reasons. If so, just add individually. - if (tlTracks.length === 0) { - var trackUris = getTracksFromUri(playlist_uri, false); - mopidy.tracklist.add({'uris': trackUris}).then(findAndPlayTrack); - } else { - findAndPlayTrack(tlTracks); - } - }); - return false; -} - -/******************************************************** - * play an uri from the queue - *********************************************************/ - -/*** - * Plays a Track from a Playlist. - * @param uri - * @param tlid - * @returns {boolean} - */ -function playTrackQueueByTlid(uri, tlid) { - //stop directly, for user feedback - mopidy.playback.stop(); - $('#popupQueue').popup('close'); - toast('Loading...'); - - tlid = parseInt(tlid); - mopidy.tracklist.filter({ - 'tlid': [tlid] - }).then( - function(tlTracks) { - if (tlTracks.length > 0) { - mopidy.playback.play({'tl_track': tlTracks[0]}); - return; - } - console.log('Failed to play selected track ', tlid); - } - ); - return false; -} - -/*** - * @deprecated - * @returns {boolean} - */ -function playTrackQueue() { - uri = $('#popupQueue').data("track"); - tlid = $('#popupQueue').data("tlid"); - return playTrackQueueByTlid(uri, tlid); -} - -/*********************************** - * remove a track from the queue * - ***********************************/ -function removeTrack() { - $('#popupQueue').popup('close'); - toast('Deleting...'); - - tlid = parseInt($('#popupQueue').data("tlid")); - console.log(tlid); - mopidy.tracklist.remove({'tlid': [tlid]}); -} - -function clearQueue() { - mopidy.tracklist.clear().then( - resetSong() - ); - return false; -} - -function savePressed(key) { - if (key == 13) { - saveQueue(); - return false; - } - return true; -} - -function showSavePopup(){ - mopidy.tracklist.getTracks().then(function(tracks) { - if (tracks.length > 0) { - $('#saveinput').val(''); - $('#popupSave').popup('open'); - } - }); - -} - -function saveQueue() { - mopidy.tracklist.getTracks().then(function(tracks) { - var playlistName = $('#saveinput').val().trim(); - if (playlistName !== null && playlistName !== "") { - getPlaylistByName(playlistName, 'm3u', false).then(function(exists) { - if (exists) { - $('#popupSave').popup('close'); - $('#popupOverwrite').popup('open'); - $('#overwriteConfirmBtn').click(function() { - initSave(playlistName, tracks); - }); + // play or pause + doPlay: function () { + toast('Please wait...', 250) + if (!play) { + mopidy.playback.play() + } else { + if (isStreamUri(songdata.track.uri)) { + mopidy.playback.stop() } else { - initSave(playlistName, tracks); + mopidy.playback.pause() } - }); - } - }); - return false; -} - -function initSave(playlistName, tracks) { - $('#popupOverwrite').popup('close'); - $('#popupSave').popup('close'); - $('#saveinput').val(''); - toast('Saving...'); - mopidy.playlists.create({'name': playlistName, 'uri_scheme': "m3u"}).then(function(playlist) { - playlist.tracks = tracks; - mopidy.playlists.save({'playlist': playlist}).then(); - }); -} - -function refreshPlaylists() { - mopidy.playlists.refresh().then(function() { - playlists = {}; - $('#playlisttracksdiv').hide(); - $('#playlistslistdiv').show(); - }); - return false; -} - -/************* - * Buttons * - *************/ - -function doShuffle() { - mopidy.playback.stop(); - mopidy.tracklist.shuffle(); - mopidy.playback.play(); -} - -/* Toggle state of play button */ -function setPlayState(nwplay) { - if (nwplay) { - $("#btplayNowPlaying >i").removeClass('fa-play').addClass('fa-pause'); - $("#btplayNowPlaying").attr('title', 'Pause'); - $("#btplay >i").removeClass('fa-play').addClass('fa-pause'); - $("#btplay").attr('title', 'Pause'); - mopidy.playback.getTimePosition().then(processCurrentposition, console.error); - startProgressTimer(); - } else { - $("#btplayNowPlaying >i").removeClass('fa-pause').addClass('fa-play'); - $("#btplayNowPlaying").attr('title', 'Play'); - $("#btplay >i").removeClass('fa-pause').addClass('fa-play'); - $("#btplay").attr('title', 'Play'); - progressTimer.stop(); - } - play = nwplay; -} - -//play or pause -function doPlay() { - toast('Please wait...', 250); - if (!play) { - mopidy.playback.play(); - } else { - if(isStreamUri(songdata.track.uri)) { - mopidy.playback.stop(); - } else { - mopidy.playback.pause(); - } - } - setPlayState(!play); -} - -function doPrevious() { - toast('Playing previous track...'); - mopidy.playback.previous(); -} - -function doNext() { - toast('Playing next track...'); - mopidy.playback.next(); -} - -function backbt() { - history.back(); - return false; -} - -/************* - * Options * - *************/ -function setTracklistOption(name, new_value) { - if (!new_value) { - $("#"+name+"bt").attr('style', 'color:#2489ce'); - } else { - $("#"+name+"bt").attr('style', 'color:#66DD33'); - } - return new_value; -} - -function setRepeat(nwrepeat) { - if (repeat != nwrepeat) { - repeat = setTracklistOption("repeat", nwrepeat); - } -} - -function setRandom(nwrandom) { - if (random != nwrandom) { - random = setTracklistOption("random", nwrandom); - } -} - -function setConsume(nwconsume) { - if (consume != nwconsume) { - consume = setTracklistOption("consume", nwconsume); - } -} - -function setSingle(nwsingle) { - if (single != nwsingle) { - single = setTracklistOption("single", nwsingle); - } -} - -function doRandom() { - mopidy.tracklist.setRandom({'value': !random}).then(); -} - -function doRepeat() { - mopidy.tracklist.setRepeat({'value': !repeat}).then(); -} - -function doConsume() { - mopidy.tracklist.setConsume({'value': !consume}).then(); -} - -function doSingle() { - mopidy.tracklist.setSingle({'value': !single}).then(); -} - - -/*********************************************** - * Track Slider * - * Use a timer to prevent looping of commands * - ***********************************************/ -function doSeekPos(value) { - if (!positionChanging) { - positionChanging = value; - mopidy.playback.seek({'time_position': Math.round(value)}).then( function() { - positionChanging = null; - }); - } -} - -function setPosition(pos) { - if (!positionChanging && $("#trackslider").val() != pos) { - setProgressTimer(pos); - } -} - -/*********************************************** - * Volume slider * - * Use a timer to prevent looping of commands * - ***********************************************/ - -function setVolume(value) { - if (!volumeChanging && !volumeSliding && $("#volumeslider").val() != value) { - $( "#volumeslider" ).off( "change"); - $( "#volumeslider" ).val(value).slider('refresh'); - $( "#volumeslider" ).on( "change", function() { doVolume( $(this).val() ); } ); - } -} - -function doVolume(value) { - if (!volumeChanging) { - volumeChanging = value; - mopidy.playback.setVolume({'volume': parseInt(volumeChanging)}).then( function() { - volumeChanging = null; - }); - } -} - -function setMute(nwmute) { - if (mute != nwmute) { - mute = nwmute; - if (mute) { - $("#mutebt").attr('class', 'fa fa-volume-off'); - } else { - $("#mutebt").attr('class', 'fa fa-volume-up'); - } - } -} - -function doMute() { - mopidy.mixer.setMute({'mute': !mute}); -} - -/************ - * Stream * - ************/ -function streamPressed(key) { - if (key == 13) { - playStreamUri(); - return false; - } - return true; -} - -function playStreamUri(uri) { - //value of name is based on the passing of an uri as a parameter or not - var nwuri = uri || $('#streamuriinput').val().trim(); - var service = $('#selectstreamservice').val(); - if (!uri && service) { - nwuri = service + ':' + nwuri; - } - if (isServiceUri(nwuri) || isStreamUri(nwuri) || validUri(nwuri)) { - toast('Playing...'); - //stop directly, for user feedback - mopidy.playback.stop(); - //hide ios/android keyboard - document.activeElement.blur(); - clearQueue(); - $("input").blur(); - mopidy.tracklist.add({'uris': [nwuri]}); - mopidy.playback.play(); - } else { - toast('No valid url!'); - } - return false; -} - -function getCurrentlyPlaying() { - $('#streamuriinput').val(songdata.track.uri); - var name = songdata.track.name; - if (songdata.track.artists) { - var artistStr = artistsToString(songdata.track.artists); - if (artistStr) { - name = artistStr + ' - ' + name; - } - } - $('#streamnameinput').val(name); - return true; -} - -function getUriSchemes() { - uriSchemes = {}; - return mopidy.getUriSchemes().then(function(schemes) { - for (var i = 0; i < schemes.length; i++) { - uriSchemes[schemes[i].toLowerCase()] = true; - } - }); -} - -function getPlaylistByName(name, scheme, create) { - var uri_scheme = scheme || ''; - var uri = ''; - if (uri_scheme && !uriSchemes[uri_scheme]) { - return Mopidy.when(false); - } - return mopidy.playlists.asList().catch(console.error.bind(console)).then(function(plists) { - for (var i = 0; i < plists.length; i++) { - if ((plists[i].name === name) && (uri_scheme === '' || getScheme(plists[i].uri) === uri_scheme)) { - return plists[i]; } - } - if (create) { - return mopidy.playlists.create({'name': name, 'uri_scheme': uri_scheme}).done(function(plist) { - console.log("Created playlist '%s'", plist.name); - return plist; - }); - } - console.log("Can't find playist '%s", name); - return Mopidy.when(false); - }); -} + controls.setPlayState(!play) + }, -function getPlaylistFull(uri) { - return mopidy.playlists.lookup({'uri': uri}).then(function(pl) { - playlists[uri] = pl; - return pl; - }); -} + doPrevious: function () { + toast('Playing previous track...') + mopidy.playback.previous() + }, -function getFavourites() { - return getPlaylistByName(STREAMS_PLAYLIST_NAME, - STREAMS_PLAYLIST_SCHEME, - true).then(function(playlist) { - if (playlist) { - return getPlaylistFull(playlist.uri); - } - return Mopidy.when(false); - }); -} + doNext: function () { + toast('Playing next track...') + mopidy.playback.next() + }, -function addToFavourites(newTracks) { - getFavourites().catch(console.error.bind(console)).then(function(favourites) { - if (favourites) { - if (favourites.tracks) { - Array.prototype.push.apply(favourites.tracks, newTracks); + backbt: function () { + history.back() + return false + }, + + /** *********** + * Options * + *************/ + setTracklistOption: function (name, new_value) { + if (!new_value) { + $('#' + name + 'bt').attr('style', 'color:#2489ce') } else { - favourites.tracks = newTracks; + $('#' + name + 'bt').attr('style', 'color:#66DD33') } - mopidy.playlists.save({'playlist': favourites}).then(function(s) { - showFavourites(); - }); - } - }); -} + return new_value + }, -function addFavourite(uri, name) { - var uri = uri || $('#streamuriinput').val().trim(); - var name = name || $('#streamnameinput').val().trim(); - mopidy.library.lookup({'uris': [uri]}).then(function(results) { - var newTracks = results[uri]; - if (newTracks.length == 1) { - // TODO: Supporting adding an entire playlist? - if (name) { - newTracks[0].name = name; // User overrides name. + setRepeat: function (nwrepeat) { + if (repeat !== nwrepeat) { + repeat = controls.setTracklistOption('repeat', nwrepeat) } - addToFavourites(newTracks); - } else { - if (newTracks.length === 0) { - console.log('No tracks to add'); + }, + + setRandom: function (nwrandom) { + if (random !== nwrandom) { + random = controls.setTracklistOption('random', nwrandom) + } + }, + + setConsume: function (nwconsume) { + if (consume !== nwconsume) { + consume = controls.setTracklistOption('consume', nwconsume) + } + }, + + setSingle: function (nwsingle) { + if (single !== nwsingle) { + single = controls.setTracklistOption('single', nwsingle) + } + }, + + doRandom: function () { + mopidy.tracklist.setRandom({'value': !random}).then() + }, + + doRepeat: function () { + mopidy.tracklist.setRepeat({'value': !repeat}).then() + }, + + doConsume: function () { + mopidy.tracklist.setConsume({'value': !consume}).then() + }, + + doSingle: function () { + mopidy.tracklist.setSingle({'value': !single}).then() + }, + + /** ********************************************* + * Track Slider * + * Use a timer to prevent looping of commands * + ***********************************************/ + doSeekPos: function (value) { + if (!positionChanging) { + positionChanging = value + mopidy.playback.seek({'time_position': Math.round(value)}).then(function () { + positionChanging = null + }) + } + }, + + setPosition: function (pos) { + if (!positionChanging && $('#trackslider').val() !== pos) { + syncedProgressTimer.set(pos) + } + }, + + /** ********************************************* + * Volume slider * + * Use a timer to prevent looping of commands * + ***********************************************/ + + setVolume: function (value) { + if (!volumeChanging && !volumeSliding && $('#volumeslider').val() !== value) { + $('#volumeslider').off('change') + $('#volumeslider').val(value).slider('refresh') + $('#volumeslider').on('change', function () { controls.doVolume($(this).val()) }) + } + }, + + doVolume: function (value) { + if (!volumeChanging) { + volumeChanging = value + mopidy.playback.setVolume({'volume': parseInt(volumeChanging)}).then(function () { + volumeChanging = null + }) + } + }, + + setMute: function (nwmute) { + if (mute !== nwmute) { + mute = nwmute + if (mute) { + $('#mutebt').attr('class', 'fa fa-volume-off') + } else { + $('#mutebt').attr('class', 'fa fa-volume-up') + } + } + }, + + doMute: function () { + mopidy.mixer.setMute({'mute': !mute}) + }, + + /** ********** + * Stream * + ************/ + streamPressed: function (key) { + if (key === 13) { + controls.playStreamUri() + return false + } + return true + }, + + playStreamUri: function (uri) { + // value of name is based on the passing of an uri as a parameter or not + var nwuri = uri || $('#streamuriinput').val().trim() + var service = $('#selectstreamservice').val() + if (!uri && service) { + nwuri = service + ':' + nwuri + } + if (isServiceUri(nwuri) || isStreamUri(nwuri) || validUri(nwuri)) { + toast('Playing...') + // stop directly, for user feedback + mopidy.playback.stop() + // hide ios/android keyboard + document.activeElement.blur() + controls.clearQueue() + $('input').blur() + mopidy.tracklist.add({'uris': [nwuri]}) + mopidy.playback.play() } else { - console.log('Too many tracks (%d) to add', tracks.length); + toast('No valid url!') } - } - }); -} + return false + }, -function deleteFavourite(index) { - getFavourites().then(function(favourites) { - if (favourites && favourites.tracks && index < favourites.tracks.length) { - var name = favourites.tracks[index].name; - if (confirm("Are you sure you want to remove '" + name + "'?")) { - favourites.tracks.splice(index, 1); - mopidy.playlists.save({'playlist': favourites}).then(function(s) { - showFavourites(); - }); + getCurrentlyPlaying: function () { + $('#streamuriinput').val(songdata.track.uri) + var name = songdata.track.name + if (songdata.track.artists) { + var artistStr = artistsToString(songdata.track.artists) + if (artistStr) { + name = artistStr + ' - ' + name + } } - } - }); -} + $('#streamnameinput').val(name) + return true + }, -function showFavourites() { - $('#streamuristable').empty(); - getFavourites().then(function(favourites) { - if (!favourites) { - return; - } - var tmp = ''; - - $.cookie.json = true; - if ($.cookie('streamUris')) { - tmp = ''; - } - if (favourites.tracks) { - var child = ''; - for (var i = 0; i < favourites.tracks.length; i++) { - child = '
  •  ' + - '' + - ' '; - child += '

    ' + favourites.tracks[i].name + '

  • '; - tmp += child; - } - } - $('#streamuristable').html(tmp); - }); -} + getUriSchemes: function () { + uriSchemes = {} + return mopidy.getUriSchemes().then(function (schemes) { + for (var i = 0; i < schemes.length; i++) { + uriSchemes[schemes[i].toLowerCase()] = true + } + }) + }, -// TODO: Remove this upgrade path in next major release. -function upgradeStreamUrisToFavourites() { - toast('Converting streamUris...'); - $.cookie.json = true; - var streamUris = $.cookie('streamUris'); // Read the cookie. - if (streamUris) { - var uris = []; // Prepare a list of uris to lookup. - for (var key in streamUris) { - var rs = streamUris[key]; - if (rs) { - uris.push(rs[1]); + getPlaylistByName: function (name, scheme, create) { + var uri_scheme = scheme || '' + var uri = '' + if (uri_scheme && !uriSchemes[uri_scheme]) { + return Mopidy.when(false) } - } - mopidy.library.lookup({'uris': uris}).then(function(results) { - var tracks = []; // Prepare a list of tracks to add. - for (var key in streamUris) { - var rs = streamUris[key]; - if (rs) { - var track = results[rs[1]][0]; - if (track) { - track.name = rs[0] || track.name; // Use custom name if provided. - tracks.push(track); - } else { - console.log("Skipping unplayable streamUri " + rs[1]); + return mopidy.playlists.asList().catch(console.error.bind(console)).then(function (plists) { + for (var i = 0; i < plists.length; i++) { + if ((plists[i].name === name) && (uri_scheme === '' || getScheme(plists[i].uri) === uri_scheme)) { + return plists[i] } } + if (create) { + return mopidy.playlists.create({'name': name, 'uri_scheme': uri_scheme}).done(function (plist) { + console.log("Created playlist '%s'", plist.name) + return plist + }) + } + console.log("Can't find playist '%s", name) + return Mopidy.when(false) + }) + }, + + getPlaylistFull: function (uri) { + return mopidy.playlists.lookup({'uri': uri}).then(function (pl) { + playlists[uri] = pl + return pl + }) + }, + + getFavourites: function () { + return controls.getPlaylistByName(STREAMS_PLAYLIST_NAME, + STREAMS_PLAYLIST_SCHEME, + true).then(function (playlist) { + if (playlist) { + return controls.getPlaylistFull(playlist.uri) + } + return Mopidy.when(false) + }) + }, + + addToFavourites: function (newTracks) { + controls.getFavourites().catch(console.error.bind(console)).then(function (favourites) { + if (favourites) { + if (favourites.tracks) { + Array.prototype.push.apply(favourites.tracks, newTracks) + } else { + favourites.tracks = newTracks + } + mopidy.playlists.save({'playlist': favourites}).then(function (s) { + controls.showFavourites() + }) + } + }) + }, + + addFavourite: function (uri, name) { + uri = uri || $('#streamuriinput').val().trim() + name = name || $('#streamnameinput').val().trim() + mopidy.library.lookup({'uris': [uri]}).then(function (results) { + var newTracks = results[uri] + if (newTracks.length === 1) { + // TODO: Supporting adding an entire playlist? + if (name) { + newTracks[0].name = name // User overrides name. + } + controls.addToFavourites(newTracks) + } else { + if (newTracks.length === 0) { + console.log('No tracks to add') + } else { + console.log('Too many tracks (%d) to add', tracks.length) + } + } + }) + }, + + showDeleteStreamPopup: function (index) { + controls.getFavourites().then(function (favourites) { + if (favourites && favourites.tracks && index < favourites.tracks.length) { + var name = favourites.tracks[index].name + $('.popupStreamName').html(favourites.tracks[index].name) + $('#popupConfirmDelete').data('index', index) + $('#popupConfirmDelete').popup('open') + } + }) + }, + + deleteFavourite: function (index) { + index = index || $('#popupConfirmDelete').data('index') + controls.getFavourites().then(function (favourites) { + if (favourites && favourites.tracks && index < favourites.tracks.length) { + favourites.tracks.splice(index, 1) + mopidy.playlists.save({'playlist': favourites}).then(function (s) { + controls.showFavourites() + }) + $('#popupConfirmDelete').popup('close') + } + }) + }, + + showFavourites: function () { + $('#streamuristable').empty() + controls.getFavourites().then(function (favourites) { + if (!favourites) { + return + } + var tmp = '' + + $.cookie.json = true + if ($.cookie('streamUris')) { + tmp = '' + } + if (favourites.tracks) { + var child = '' + for (var i = 0; i < favourites.tracks.length; i++) { + child = + '
  • ' + + ' ' + + '' + + ' ' + child += '

    ' + favourites.tracks[i].name + '

  • ' + tmp += child + } + } else { + tmp = 'Your saved favourite streams/tracks will be shown here' + } + $('#streamuristable').html(tmp) + }) + }, + + // TODO: Remove this upgrade path in next major release. + upgradeStreamUrisToFavourites: function () { + toast('Converting streamUris...') + $.cookie.json = true + var streamUris = $.cookie('streamUris') // Read the cookie. + if (streamUris) { + var uris = [] // Prepare a list of uris to lookup. + for (var key in streamUris) { + var rs = streamUris[key] + if (rs) { + uris.push(rs[1]) + } + } + mopidy.library.lookup({'uris': uris}).then(function (results) { + var tracks = [] // Prepare a list of tracks to add. + for (var key in streamUris) { + var rs = streamUris[key] + if (rs) { + var track = results[rs[1]][0] + if (track) { + track.name = rs[0] || track.name // Use custom name if provided. + tracks.push(track) + } else { + console.log('Skipping unplayable streamUri ' + rs[1]) + } + } + } + controls.addToFavourites(tracks) + $.cookie('streamUris', null) // Delete the cookie now we're done. + console.log(tracks.length + ' streamUris added to favourites') + }) + } else { + console.log('No streamUris cookie found') } - addToFavourites(tracks); - $.cookie('streamUris', null); // Delete the cookie now we're done. - console.log(tracks.length + " streamUris added to favourites"); - }); - } else { - console.log("No streamUris cookie found"); + }, + + haltSystem: function () { + $.post('/settings/shutdown') + toast('Stopping system...', 10000) + setTimeout(function () { + window.history.back() + }, 10000) + }, + + rebootSystem: function () { + $.post('/settings/reboot') + toast('Rebooting...', 10000) + setTimeout(function () { + window.history.back() + }, 10000) + } + } -} - -function haltSystem() { - $.post("/settings/shutdown"); - toast('Stopping system...', 10000); - setTimeout(function() { - window.history.back(); - }, 10000); -} - -function rebootSystem() { - $.post("/settings/reboot"); - toast('Rebooting...', 10000); - setTimeout(function() { - window.history.back(); - }, 10000); -} + return controls +})) diff --git a/mopidy_musicbox_webclient/static/js/custom_scripting.js b/mopidy_musicbox_webclient/static/js/custom_scripting.js new file mode 100644 index 0000000..f8b6ca1 --- /dev/null +++ b/mopidy_musicbox_webclient/static/js/custom_scripting.js @@ -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 +})) + diff --git a/mopidy_musicbox_webclient/static/js/functionsvars.js b/mopidy_musicbox_webclient/static/js/functionsvars.js index d0b99fc..ec90cde 100644 --- a/mopidy_musicbox_webclient/static/js/functionsvars.js +++ b/mopidy_musicbox_webclient/static/js/functionsvars.js @@ -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 = '
      '; - 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 += '
    '; - $(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 += '
  • ' + track.name + ' [Stream]

  • ' + 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 += + '
  • ' + + '' + + '' + + '

    ' + track.name + '

    ' + + if (listLength === 1 || (!hasSameAlbum(previousTrack, track) && !hasSameAlbum(track, nextTrack))) { + html += renderSongLiAlbumInfo(track) + } + html += '
  • ' + 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 += ' - ' + track.album.name + '

    ' + } + 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 += + '
  • ' + + '' + + '

    ' + track.album.name + '

    ' + + renderSongLiTrackArtists(track) + '

  • ' + // Retrieve album covers + images.setAlbumImage(track.uri, getjQueryID(target + '-cover', track.uri, true), mopidy, 'small') + } else if (previousTrack && !hasSameAlbum(previousTrack, track)) { + // Small divider + html += '
  •  
  • ' + } + 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( + '
  • No tracks found...

  • ' + ) + } + var opt = '' + if (optional) { + opt = ' backnav-optional' + } + $(target).prepend( + '
  • Back

  • ' + ) + } +} + +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 = '
  • ' + - '' + - '' + - '' + - '

    ' + name + '

    ' + - '
  • '; - 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 = '
  • ' + - '' + - '' + - '' + - '

    ' + name + '

    ' + - '
  • '; - 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 += '
  • ' + results[i].name + ' [Stream]

  • '; - 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 += '
  •  
  • '; - } - iconClass = getMediaClass(newalbum[0].uri); - var liID = targetmin + '-' + newalbum[0].uri; - if (target == CURRENT_PLAYLIST_TABLE) { - html += '
  • ' + - '' + - '' + - '' + - '

    ' + newalbum[0].name + "

    "; - } else { - html += '

  • ' + - '' + - '' + - '' + - '

    ' + newalbum[0].name + "

    "; - } - - 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 += '' + newalbum[0].album.name + '

    '; - html += '
  • '; - - 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 += '
  • '; - html += '

    ' + results[i].album.name + '

    '; - } - 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 += '

  • '; - 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 = '
  • '; - child += '

    ' + pl[i].name + "

    "; - child += '

    '; - child += '' + timeFromSeconds(pl[i].length / 1000) + ''; + popupData[pl[i].uri] = pl[i] + child = '

  • ' + child += '

    ' + pl[i].name + 'h1>' + child += '

    ' + child += '' + timeFromSeconds(pl[i].length / 1000) + '' 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 += ' / ' + pl[i].album.name + '

    '; - child += '

  • '; - tmp += child; + child += ' / ' + pl[i].album.name + '

    ' + child += '' + 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) + }) } diff --git a/mopidy_musicbox_webclient/static/js/gui.js b/mopidy_musicbox_webclient/static/js/gui.js index 827dc13..fea794c 100644 --- a/mopidy_musicbox_webclient/static/js/gui.js +++ b/mopidy_musicbox_webclient/static/js/gui.js @@ -3,32 +3,32 @@ * do- functions interact with the server * show- functions do both */ -/******************** +/** ****************** * Song Info Sreen * ********************/ -function resetSong() { - setPlayState(false); - setPosition(0); - var data = new Object({}); - data.tlid = -1; - data.track = new Object({}); - data.track.name = ''; - data.track.artists = ''; - data.track.length = 0; - data.track.uri = ' '; - setSongInfo(data); +function resetSong () { + controls.setPlayState(false) + controls.setPosition(0) + var data = {} + data.tlid = -1 + data.track = {} + data.track.name = '' + data.track.artists = '' + data.track.length = 0 + data.track.uri = '' + setSongInfo(data) } -function resizeMb() { - $("#infoname").html(songdata.track.name); - $("#infoartist").html(artiststext); +function resizeMb () { + $('#infoname').html(songdata.track.name) + $('#infoartist').html(artiststext) if ($(window).width() <= 960) { // $('#playlisttracksdiv').hide(); // $('#playlistslistdiv').show(); } else { - $('#playlisttracksdiv').show(); - $('#playlistslistdiv').show(); + $('#playlisttracksdiv').show() + $('#playlistslistdiv').show() } // //set height of playlist scrollers /* if ($(window).width() > 960) { @@ -60,7 +60,6 @@ function resizeMb() { $('#playlistslistdiv').show(); } - if (isMobileWebkit && ($(window).width() > 480)) { playlistslistScroll.refresh(); playlisttracksScroll.refresh(); @@ -68,609 +67,594 @@ function resizeMb() { */ } -function setSongTitle(title, refresh_ui) { - songdata.track.name = title; - $("#modalname").html(title); +function setSongTitle (title, refresh_ui) { + songdata.track.name = title + $('#modalname').html(title) if (refresh_ui) { - resizeMb(); + resizeMb() } } -function setSongInfo(data) { +function setSongInfo (data) { // console.log(data, songdata); - if (!data ) { return; } - if (data.tlid == songdata.tlid) { return; } + if (!data) { return } + if (data.tlid === songdata.tlid) { return } if (!data.track.name || data.track.name === '') { - var name = data.track.uri.split('/'); - data.track.name = decodeURI(name[name.length - 1]); + var name = data.track.uri.split('/') + data.track.name = decodeURI(name[name.length - 1]) } - updatePlayIcons(data.track.uri, data.tlid); - artistshtml = ''; - artiststext = ''; + updatePlayIcons(data.track.uri, data.tlid, controls.getIconForAction()) + artistshtml = '' + artiststext = '' if (validUri(data.track.name)) { for (var key in streamUris) { - rs = streamUris[key]; - if (rs && rs[1] == data.track.name) { - data.track.name = (rs[0] || rs[1]); + rs = streamUris[key] + if (rs && rs[1] === data.track.name) { + data.track.name = (rs[0] || rs[1]) } } } - songdata = data; + songdata = data - setSongTitle(data.track.name, false); - songlength = Infinity; + setSongTitle(data.track.name, false) + songlength = Infinity if (!data.track.length || data.track.length === 0) { - $('#trackslider').next().find('.ui-slider-handle').hide(); - $('#trackslider').slider('disable'); + $('#trackslider').next().find('.ui-slider-handle').hide() + $('#trackslider').slider('disable') // $('#streamnameinput').val(data.track.name); // $('#streamuriinput').val(data.track.uri); } else { - songlength = data.track.length; - $('#trackslider').slider('enable'); - $('#trackslider').next().find('.ui-slider-handle').show(); + songlength = data.track.length + $('#trackslider').slider('enable') + $('#trackslider').next().find('.ui-slider-handle').show() } - var arttmp = ''; + var arttmp = '' - if(data.track.artists) { - for (var j = 0; j < data.track.artists.length; j++) { - artistshtml += '' + data.track.artists[j].name + ''; - artiststext += data.track.artists[j].name; - if (j != data.track.artists.length - 1) { - artistshtml += ', '; - artiststext += ', '; - } - } - arttmp = artistshtml; + if (data.track.artists) { + for (var j = 0; j < data.track.artists.length; j++) { + artistshtml += '' + data.track.artists[j].name + '' + artiststext += data.track.artists[j].name + if (j !== data.track.artists.length - 1) { + artistshtml += ', ' + artiststext += ', ' + } + } + arttmp = artistshtml } if (data.track.album && data.track.album.name) { - $("#modalalbum").html('' + data.track.album.name + ''); - getCover(data.track.uri, '#infocover, #controlspopupimage', 'extralarge'); + $('#modalalbum').html('' + data.track.album.name + '') } else { - $("#modalalbum").html(''); - $("#infocover").attr('src', 'images/default_cover.png'); - $("#controlspopupimage").attr('src', 'images/default_cover.png'); + $('#modalalbum').html('') } + images.setAlbumImage(data.track.uri, '#infocover, #albumCoverImg', mopidy) - $("#modalartist").html(arttmp); + $('#modalartist').html(arttmp) - $("#trackslider").attr("min", 0); - $("#trackslider").attr("max", songlength); - resetProgressTimer(); - progressTimer.set(0, songlength); + $('#trackslider').attr('min', 0) + $('#trackslider').attr('max', songlength) + syncedProgressTimer.reset().set(0, songlength) if (play) { - startProgressTimer(); + syncedProgressTimer.start() } - resizeMb(); + resizeMb() } -/****************** +/** **************** * display popups * ******************/ -function closePopups() { - $('#popupTracks').popup('close'); - $('#artistpopup').popup('close'); - $('#coverpopup').popup('close'); - $('#popupQueue').popup('close'); - $('#controlspopup').popup('close'); +function closePopups () { + $('#popupTracks').popup('close') + $('#artistpopup').popup('close') + $('#coverpopup').popup('close') + $('#popupQueue').popup('close') } -function popupTracks(e, listuri, trackuri, tlid) { - if (!e) - e = window.event; - $('.popupTrackName').html(popupData[trackuri].name); - $('.popupAlbumName').html(popupData[trackuri].album.name); - var child = ""; +function popupTracks (e, listuri, trackuri, tlid) { + if (!e) { + e = window.event + } + $('.popupTrackName').html(popupData[trackuri].name) + if (popupData[trackuri].album && popupData[trackuri].album.name) { + $('.popupAlbumName').html(popupData[trackuri].album.name) + $('.popupAlbumLi').show() + } else { + $('.popupAlbumLi').hide() + } + var child = '' if (popupData[trackuri].artists) { - if (popupData[trackuri].artists.length == 1) { - child = 'Show Artist'; - $('.popupArtistName').html(popupData[trackuri].artists[0].name); - $('.popupArtistHref').attr('onclick', 'showArtist("' + popupData[trackuri].artists[0].uri + '");' ); - $('.popupArtistsDiv').hide(); - $('.popupArtistsLi').show(); + if (popupData[trackuri].artists.length === 1) { + child = 'Show Artist' + $('.popupArtistName').html(popupData[trackuri].artists[0].name) + $('.popupArtistHref').attr('onclick', 'library.showArtist("' + popupData[trackuri].artists[0].uri + '");') + $('.popupArtistsDiv').hide() + $('.popupArtistsLi').show() } else { for (var j = 0; j < popupData[trackuri].artists.length; j++) { - child += '
  • ' + popupData[trackuri].artists[j].name + '
  • '; + child += '
  • ' + popupData[trackuri].artists[j].name + '
  • ' } - $('.popupArtistsLi').hide(); - $('.popupArtistsLv').html(child).show(); - $('.popupArtistsDiv').show(); + $('.popupArtistsLi').hide() + $('.popupArtistsLv').html(child).show() + $('.popupArtistsDiv').show() // this makes the viewport of the window resize somehow - $('.popupArtistsLv').listview("refresh"); + $('.popupArtistsLv').listview('refresh') } } else { - $('.popupArtistsDiv').hide(); - $('.popupArtistsLi').hide(); + $('.popupArtistsDiv').hide() + $('.popupArtistsLi').hide() } - var hash = document.location.hash.split('?'); - var divid = hash[0].substr(1); - var popupName = ''; - if (divid == 'current') { - $(".addqueue").hide(); - popupName = '#popupQueue'; - } else if (divid == 'browse') { - $(".addqueue").show(); - popupName = '#popupBrowse'; + var hash = document.location.hash.split('?') + var divid = hash[0].substr(1) + var popupName = '' + if (divid === 'current') { + $('.addqueue').hide() + popupName = '#popupQueue' } else { - $(".addqueue").show(); - popupName = '#popupTracks'; + $('.addqueue').show() + popupName = '#popupTracks' } - if (typeof tlid != 'undefined' && tlid !== '') { - $(popupName).data("list", listuri).data("track", trackuri).data("tlid", tlid).popup("open", { - x : e.pageX, - y : e.pageY - }); + if (typeof tlid !== 'undefined' && tlid !== '') { + $(popupName).data('list', listuri).data('track', trackuri).data('tlid', tlid).popup('open', { + x: e.pageX, + y: e.pageY + }) } else { - $(popupName).data("list", listuri).data("track", trackuri).popup("open", { - x : e.pageX, - y : e.pageY - }); + $(popupName).data('list', listuri).data('track', trackuri).popup('open', { + x: e.pageX, + y: e.pageY + }) } - return false; + return false } -function showAlbumPopup(popupId) { - uri = $(popupId).data("track"); - showAlbum(popupData[uri].album.uri); +function showAlbumPopup (popupId) { + uri = $(popupId).data('track') + library.showAlbum(popupData[uri].album.uri) } -/********************** +/** ******************** * initialize sockets * **********************/ -function initSocketevents() { - mopidy.on("state:online", function() { - showOffline(false); - getCurrentPlaylist(); - updateStatusOfAll(); - getPlaylists(); - getUriSchemes().then(function() { - showFavourites(); - }); - getBrowseDir(); - getSearchSchemes(); - showLoading(false); - $(window).hashchange(); - }); +function initSocketevents () { + mopidy.on('state:online', function () { + showOffline(false) + library.getCurrentPlaylist() + updateStatusOfAll() + library.getPlaylists() + controls.getUriSchemes().then(function () { + controls.showFavourites() + }) + library.getBrowseDir() + library.getSearchSchemes(searchBlacklist, mopidy) + showLoading(false) + $(window).hashchange() + }) - mopidy.on("state:offline", function() { - resetSong(); - showOffline(true); - }); + mopidy.on('state:offline', function () { + resetSong() + showOffline(true) + }) - mopidy.on("event:optionsChanged", updateOptions); + mopidy.on('event:optionsChanged', updateOptions) - mopidy.on("event:trackPlaybackStarted", function(data) { - setSongInfo(data.tl_track); - setPlayState(true); - }); + mopidy.on('event:trackPlaybackStarted', function (data) { + setSongInfo(data.tl_track) + controls.setPlayState(true) + }) - mopidy.on("event:playlistsLoaded", function(data) { - showLoading(true); - getPlaylists(); - }); + mopidy.on('event:playlistsLoaded', function (data) { + showLoading(true) + library.getPlaylists() + }) - mopidy.on("event:playlistChanged", function(data) { - $('#playlisttracksdiv').hide(); - $('#playlistslistdiv').show(); - delete playlists[data.playlist.uri]; - getPlaylists(); - }); + mopidy.on('event:playlistChanged', function (data) { + $('#playlisttracksdiv').hide() + $('#playlistslistdiv').show() + delete playlists[data.playlist.uri] + library.getPlaylists() + }) - mopidy.on("event:playlistDeleted", function(data) { - $('#playlisttracksdiv').hide(); - $('#playlistslistdiv').show(); - delete playlists[data.uri]; - getPlaylists(); - }); + mopidy.on('event:playlistDeleted', function (data) { + $('#playlisttracksdiv').hide() + $('#playlistslistdiv').show() + delete playlists[data.uri] + library.getPlaylists() + }) - mopidy.on("event:volumeChanged", function(data) { - setVolume(data.volume); - }); + mopidy.on('event:volumeChanged', function (data) { + controls.setVolume(data.volume) + }) - mopidy.on("event:muteChanged", function(data) { - setMute(data.mute); - }); + mopidy.on('event:muteChanged', function (data) { + controls.setMute(data.mute) + }) - mopidy.on("event:playbackStateChanged", function(data) { + mopidy.on('event:playbackStateChanged', function (data) { switch (data.new_state) { - case "paused": - case "stopped": - setPlayState(false); - break; - case "playing": - setPlayState(true); - break; + case 'paused': + case 'stopped': + controls.setPlayState(false) + break + case 'playing': + controls.setPlayState(true) + break } - }); + }) - mopidy.on("event:tracklistChanged", function(data) { - getCurrentPlaylist(); - }); + mopidy.on('event:tracklistChanged', function (data) { + library.getCurrentPlaylist() + }) - mopidy.on("event:seeked", function(data) { - setPosition(parseInt(data.time_position)); + mopidy.on('event:seeked', function (data) { + controls.setPosition(parseInt(data.time_position)) if (play) { - startProgressTimer(); + syncedProgressTimer.start() } - }); + }) - mopidy.on("event:streamTitleChanged", function(data) { - setSongTitle(data.title, true); - }); + mopidy.on('event:streamTitleChanged', function (data) { + setSongTitle(data.title, true) + }) } -$(document).bind("pageinit", function() { - resizeMb(); +$(document).bind('pageinit', function () { + resizeMb() +}) -}); - -/************** +/** ************ * gui stuff * **************/ -function toggleFullscreen() { - if (isMobileSafari) { alert ("To get this app in Full Screen, you have to add it to your home-screen using the Share button."); exit(); } - if (!isFullscreen() ) { // current working methods - var docElm = document.documentElement; +function toggleFullscreen () { + if (isMobileSafari) { alert('To get this app in Full Screen, you have to add it to your home-screen using the Share button.'); exit() } + if (!isFullscreen()) { // current working methods + var docElm = document.documentElement if (docElm.requestFullscreen) { - docElm.requestFullscreen(); + docElm.requestFullscreen() } else if (docElm.msRequestFullscreen) { - docElm.msRequestFullscreen(); + docElm.msRequestFullscreen() } else if (docElm.mozRequestFullScreen) { - docElm.mozRequestFullScreen(); + docElm.mozRequestFullScreen() } else if (docElm.webkitRequestFullScreen) { - docElm.webkitRequestFullScreen(); + docElm.webkitRequestFullScreen() } } else { if (document.exitFullscreen) { - document.exitFullscreen(); + document.exitFullscreen() } else if (document.msExitFullscreen) { - document.msExitFullscreen(); + document.msExitFullscreen() } else if (document.mozCancelFullScreen) { - document.mozCancelFullScreen(); + document.mozCancelFullScreen() } else if (document.webkitCancelFullScreen) { - document.webkitCancelFullScreen(); + document.webkitCancelFullScreen() } } } -function isFullscreen() { +function isFullscreen () { return (document.fullscreenElement || // alternative standard method - document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement ); // current working methods + document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement) // current working methods } -function switchContent(divid, uri) { - var hash = divid; +function switchContent (divid, uri) { + var hash = divid if (uri) { - hash += "?" + uri; + hash += '?' + uri } - location.hash = "#" + hash; + location.hash = '#' + hash } -function setHeadline(site){ - site = site.trim(); - str = $('.mainNav').find('a[href$='+site+']').text(); - if(str === ""){ - str = site.charAt(0).toUpperCase() + site.slice(1); +function setHeadline (site) { + site = site.trim() + str = $('.mainNav').find('a[href$=' + site + ']').text() + if (str === '') { + str = site.charAt(0).toUpperCase() + site.slice(1) } - $('#contentHeadline').html('' + str + ''); + $('#contentHeadline').html('' + str + '') } -//update tracklist options. -function updateOptions() { - mopidy.tracklist.getRepeat().then(processRepeat, console.error); - mopidy.tracklist.getRandom().then(processRandom, console.error); - mopidy.tracklist.getConsume().then(processConsume, console.error); - mopidy.tracklist.getSingle().then(processSingle, console.error); +// update tracklist options. +function updateOptions () { + mopidy.tracklist.getRepeat().then(processRepeat, console.error) + mopidy.tracklist.getRandom().then(processRandom, console.error) + mopidy.tracklist.getConsume().then(processConsume, console.error) + mopidy.tracklist.getSingle().then(processSingle, console.error) } -//update everything as if reloaded -function updateStatusOfAll() { - mopidy.playback.getCurrentTlTrack().then(processCurrenttrack, console.error); - mopidy.playback.getTimePosition().then(processCurrentposition, console.error); - mopidy.playback.getState().then(processPlaystate, console.error); +// update everything as if reloaded +function updateStatusOfAll () { + mopidy.playback.getCurrentTlTrack().then(processCurrenttrack, console.error) + mopidy.playback.getTimePosition().then(processCurrentposition, console.error) + mopidy.playback.getState().then(processPlaystate, console.error) - updateOptions(); + updateOptions() - mopidy.playback.getVolume().then(processVolume, console.error); - mopidy.mixer.getMute().then(processMute, console.error); + mopidy.playback.getVolume().then(processVolume, console.error) + mopidy.mixer.getMute().then(processMute, console.error) } -function locationHashChanged() { - var hash = document.location.hash.split('?'); - //remove # - var divid = hash[0].substr(1); - setHeadline(divid); +function locationHashChanged () { + var hash = document.location.hash.split('?') + // remove # + var divid = hash[0].substr(1) + setHeadline(divid) - - var uri = hash[1]; - $('.mainNav a').removeClass('ui-state-active ui-state-persist ui-btn-active'); - //i don't know why some li elements have those classes, but they do, so we need to remove them - $('.mainNav li').removeClass('ui-state-active ui-state-persist ui-btn-active'); + var uri = hash[1] + $('.mainNav a').removeClass('ui-state-active ui-state-persist ui-btn-active') + // i don't know why some li elements have those classes, but they do, so we need to remove them + $('.mainNav li').removeClass('ui-state-active ui-state-persist ui-btn-active') if ($(window).width() < 560) { - $("#panel").panel("close"); + $('#panel').panel('close') } - $('.pane').hide(); + $('.pane').hide() - $('#' + divid + 'pane').show(); + $('#' + divid + 'pane').show() - switch(divid) { + switch (divid) { case 'home': - $('#navhome a').addClass('ui-state-active ui-state-persist ui-btn-active'); - break; + $('#navhome a').addClass('ui-state-active ui-state-persist ui-btn-active') + break case 'nowPlaying': - $('#navnowPlaying a').addClass('ui-state-active ui-state-persist ui-btn-active'); - break; + $('#navnowPlaying a').addClass('ui-state-active ui-state-persist ui-btn-active') + break case 'current': - $('#navcurrent a').addClass('ui-state-active ui-state-persist ui-btn-active'); - getCurrentPlaylist(); - break; + $('#navcurrent a').addClass('ui-state-active ui-state-persist ui-btn-active') + break case 'playlists': - $('#navplaylists a').addClass('ui-state-active ui-state-persist ui-btn-active'); - break; + $('#navplaylists a').addClass('ui-state-active ui-state-persist ui-btn-active') + break case 'browse': - $('#navbrowse a').addClass('ui-state-active ui-state-persist ui-btn-active'); - break; + $('#navbrowse a').addClass('ui-state-active ui-state-persist ui-btn-active') + break case 'search': - $('#navsearch a').addClass($.mobile.activeBtnClass); - $("#searchinput").focus(); - if (customTracklists['mbw:allresultscache'] === '') { - initSearch($('#searchinput').val()); - } - break; + $('#navsearch a').addClass($.mobile.activeBtnClass) + $('#searchinput').focus() + break case 'stream': - $('#navstream a').addClass('ui-state-active ui-state-persist ui-btn-active'); - break; + $('#navstream a').addClass('ui-state-active ui-state-persist ui-btn-active') + break case 'artists': if (uri !== '') { - showArtist(uri); + library.showArtist(uri) } - break; + break case 'albums': if (uri !== '') { - showAlbum(uri); + library.showAlbum(uri) } - break; + break } - //switch the footer - switch(divid) { + // switch the footer + switch (divid) { case 'nowPlaying': - $('#normalFooter').hide(); - $('#nowPlayingFooter').show(); - break; + $('#normalFooter').hide() + $('#nowPlayingFooter').show() + break default: - $('#normalFooter').show(); - $('#nowPlayingFooter').hide(); + $('#normalFooter').show() + $('#nowPlayingFooter').hide() } // Set the page title based on the hash. - document.title = PROGRAM_NAME; - return false; + document.title = PROGRAM_NAME + return false } -/*********************** +/** ********************* * initialize software * ***********************/ -$(document).ready(function(event) { - //check for websockets +$(document).ready(function (event) { + // check for websockets if (!window.WebSocket) { - switchContent("playlists"); - $('#playlistspane').html('

    Old Browser

    Sorry. Your browser isn\'t modern enough for this webapp. Modern versions of Chrome, Firefox, Safari all will do. Maybe Opera and Internet Explorer 10 also work, but it\'s not tested.

    '); - exit; + switchContent('playlists') + $('#playlistspane').html('

    Old Browser

    Sorry. Your browser isn\'t modern enough for this webapp. Modern versions of Chrome, Firefox, Safari all will do. Maybe Opera and Internet Explorer 10 also work, but it\'s not tested.

    ') + exit } - //workaround for a bug in jQuery Mobile, without that the panel doesn't close on mobile devices... - $('.ui-panel-dismiss').on( "tap", function() { $("#panel").panel("close"); } ); - //end of workaround + // workaround for a bug in jQuery Mobile, without that the panel doesn't close on mobile devices... + $('.ui-panel-dismiss').on('tap', function () { $('#panel').panel('close') }) + // end of workaround - $(window).hashchange(); + $(window).hashchange() // Connect to server + var websocketUrl = $(document.body).data('websocket-url') if (websocketUrl) { try { mopidy = new Mopidy({ webSocketUrl: websocketUrl, callingConvention: 'by-position-or-by-name' - }); + }) } catch (e) { - showOffline(true); + showOffline(true) } } else { try { - mopidy = new Mopidy({callingConvention: 'by-position-or-by-name'}); - } catch (e) { - showOffline(true); + mopidy = new Mopidy({callingConvention: 'by-position-or-by-name'}) + } catch (e) { + showOffline(true) } } - //initialize events - initSocketevents(); + // initialize events + initSocketevents() - progressTimer = new ProgressTimer({ - callback: timerCallback, - //updateRate: 2000, - }); + syncedProgressTimer = new SyncedProgressTimer(8, mopidy) - resetSong(); + resetSong() if (location.hash.length < 2) { - switchContent("home"); + switchContent('home') } - initgui = false; - window.onhashchange = locationHashChanged; + initgui = false + window.onhashchange = locationHashChanged - //only show backbutton if in UIWebview + // only show backbutton if in UIWebview if (window.navigator.standalone) { - $("#btback").show(); + $('#btback').show() } else { - $("#btback").hide(); + $('#btback').hide() } - $(window).resize(function() { - resizeMb(); - }); + $(window).resize(function () { + resizeMb() + }) - //navigation temporary, rewrite this! - $('#songinfo').click(function() { - return switchContent('nowPlaying'); - }); - $('#controlspopupimage').click(function() { - return switchContent('current'); - }); - $('#navToggleFullscreen').click(function() { - toggleFullscreen(); - }); + // navigation temporary, rewrite this! + $('#songinfo').click(function () { + return switchContent('nowPlaying') + }) + $('#albumCoverImg').click(function () { + return switchContent('current') + }) + $('#navToggleFullscreen').click(function () { + toggleFullscreen() + }) // event handlers for full screen mode - $(document).on('webkitfullscreenchange mozfullscreenchange fullscreenchange, MSFullscreenChange', function(e) { + $(document).on('webkitfullscreenchange mozfullscreenchange fullscreenchange, MSFullscreenChange', function (e) { if (isFullscreen()) { - document.getElementById("toggletxt").innerHTML = "Exit Fullscreen"; + document.getElementById('toggletxt').innerHTML = 'Exit Fullscreen' } else { - document.getElementById("toggletxt").innerHTML = "Fullscreen"; + document.getElementById('toggletxt').innerHTML = 'Fullscreen' } - }); + }) - // remove buttons only for MusicBox - if (!isMusicBox) { - $('#navSettings').hide(); - $('#navshutdown').hide(); - $('#homesettings').hide(); - $('#homeshutdown').hide(); + // Remove MusicBox only content (e.g. settings, system pages) + if (!$(document.body).data('is-musicbox')) { + $('#navSettings').hide() + $('#navshutdown').hide() + $('#homesettings').hide() + $('#homeshutdown').hide() } - // remove Alarm Clock if it is not present - if (!hasAlarmClock) { - $('#navAlarmClock').hide(); - $('#homeAlarmClock').hide(); - $('#homeAlarmClock').nextAll().find('.ui-block-a, .ui-block-b').toggleClass('ui-block-a').toggleClass('ui-block-b'); + // Remove Alarm Clock icons if it is not present + if (!$(document.body).data('has-alarmclock')) { + $('#navAlarmClock').hide() + $('#homeAlarmClock').hide() + $('#homeAlarmClock').nextAll().find('.ui-block-a, .ui-block-b').toggleClass('ui-block-a').toggleClass('ui-block-b') } - //navigation stuff + // navigation stuff - $(document).keypress( function (event) { - //console.log('kp: '+event); - if (event.target.tagName != 'INPUT') { - var unicode=event.keyCode? event.keyCode : event.charCode; - var actualkey=String.fromCharCode(unicode); - switch(actualkey) { - case ' ': - doPlay(); - event.preventDefault(); - break; - case '>': - doNext(); - event.preventDefault(); - break; - case '<': - doPrevious(); - event.preventDefault(); - break; - } - return true; - } - }); - + $(document).keypress(function (event) { + // console.log('kp: '+event); + if (event.target.tagName !== 'INPUT') { + var unicode = event.keyCode ? event.keyCode : event.charCode + var actualkey = String.fromCharCode(unicode) + switch (actualkey) { + case ' ': + controls.doPlay() + event.preventDefault() + break + case '>': + controls.doNext() + event.preventDefault() + break + case '<': + controls.doPrevious() + event.preventDefault() + break + } + return true + } + }) if ($(window).width() < 980) { - $("#panel").panel("close"); - }else{ - $("#panel").panel("open"); + $('#panel').panel('close') + } else { + $('#panel').panel('open') } - $.event.special.swipe.horizontalDistanceThreshold = 125; // (default: 30px) Swipe horizontal displacement must be more than this. - $.event.special.swipe.verticalDistanceThreshold = 50; // (default: 75px) Swipe vertical displacement must be less than this. - $.event.special.swipe.durationThreshold = 500; + $.event.special.swipe.horizontalDistanceThreshold = 125 // (default: 30px) Swipe horizontal displacement must be more than this. + $.event.special.swipe.verticalDistanceThreshold = 50 // (default: 75px) Swipe vertical displacement must be less than this. + $.event.special.swipe.durationThreshold = 500 // swipe songinfo and panel - $( "#normalFooter, #nowPlayingFooter" ).on( "swiperight", doPrevious ); - $( "#normalFooter, #nowPlayingFooter" ).on( "swipeleft", doNext ); - $( "#nowPlayingpane, .ui-body-c, #header, #panel, .pane" ).on( "swiperight", function() { - if(!$(event.target).is("#normalFooter") && !$(event.target).is("#nowPlayingFooter")) { - $("#panel").panel("open"); - event.stopImmediatePropagation(); } - } ); - $( "#nowPlayingpane, .ui-body-c, #header, #panel, .pane" ).on( "swipeleft", function() { - if(!$(event.target).is("#normalFooter") && !$(event.target).is("#nowPlayingFooter")) { - $("#panel").panel("close"); - event.stopImmediatePropagation(); } - } ); - - $( "#trackslider" ).on( "slidestart", function() { - progressTimer.stop(); - $( "#trackslider" ).on( "change", function() { updatePosition( $(this).val() ); } ); - } ); - - $( "#trackslider" ).on( "slidestop", function() { - $( "#trackslider" ).off( "change"); - doSeekPos( $(this).val() ); - } ); - - $( "#volumeslider" ).on( "slidestart", function() { volumeSliding = true; } ); - $( "#volumeslider" ).on( "slidestop", function() { volumeSliding = false; } ); - $( "#volumeslider" ).on( "change", function() { doVolume( $(this).val() ); } ); -}); - -function updatePlayIcons (uri, tlid) { - //update styles of listviews - $('#currenttable li').each(function() { - var eachTlid = $(this).attr('tlid'); - if (typeof eachTlid != 'undefined') { - eachTlid = parseInt(eachTlid); + $('#normalFooter, #nowPlayingFooter').on('swiperight', controls.doPrevious) + $('#normalFooter, #nowPlayingFooter').on('swipeleft', controls.doNext) + $('#nowPlayingpane, .ui-body-c, #header, #panel, .pane').on('swiperight', function (event) { + if (!$(event.target).is('#normalFooter') && !$(event.target).is('#nowPlayingFooter')) { + $('#panel').panel('open') + event.stopImmediatePropagation() } - if (this.id == 'currenttable-' + uri && eachTlid == tlid) { - $(this).addClass('currenttrack'); - } else { - $(this).removeClass("currenttrack"); - } - }); + }) + $('#nowPlayingpane, .ui-body-c, #header, #panel, .pane').on('swipeleft', function (event) { + if (!$(event.target).is('#normalFooter') && !$(event.target).is('#nowPlayingFooter')) { + $('#panel').panel('close') + event.stopImmediatePropagation() + } + }) - $('#playlisttracks li').each(function() { - if (this.id == 'playlisttracks-' + uri) { - $(this).addClass('currenttrack2'); - } else { - $(this).removeClass("currenttrack2"); - } - }); + $('#trackslider').on('slidestart', function () { + syncedProgressTimer.stop() + $('#trackslider').on('change', function () { syncedProgressTimer.updatePosition($(this).val()) }) + }) - $('#trackresulttable li').each(function() { - if (this.id == 'trackresulttable-' + uri) { - $(this).addClass('currenttrack2'); - } else { - $(this).removeClass("currenttrack2"); - } - }); + $('#trackslider').on('slidestop', function () { + $('#trackslider').off('change') + syncedProgressTimer.updatePosition($(this).val()) + controls.doSeekPos($(this).val()) + }) - $('#artiststable li').each(function() { - if (this.id == 'artiststable-' + uri) { - $(this).addClass('currenttrack2'); - } else { - $(this).removeClass("currenttrack2"); - } - }); + $('#volumeslider').on('slidestart', function () { volumeSliding = true }) + $('#volumeslider').on('slidestop', function () { volumeSliding = false }) + $('#volumeslider').on('change', function () { controls.doVolume($(this).val()) }) +}) - $('#albumstable li').each(function() { - if (this.id == 'albumstable-' + uri) { - $(this).addClass('currenttrack2'); - } else { - $(this).removeClass("currenttrack2"); - } - }); - $('#browselist li').each(function() { - if (this.id == 'browselisttracks-' + uri) { - $(this).addClass('currenttrack2'); - } else { - $(this).removeClass("currenttrack2"); - } - }); +function updatePlayIcons (uri, tlid, popupMenuIcon) { + // Update styles of listviews + if (arguments.length < 3) { + throw new Error('Missing parameters for "updatePlayIcons" function call.') + } + var listviews = [PLAYLIST_TABLE, SEARCH_TRACK_TABLE, ARTIST_TABLE, ALBUM_TABLE, BROWSE_TABLE] + var target = CURRENT_PLAYLIST_TABLE.substr(1) + if (uri && typeof tlid === 'number' && tlid >= 0) { + $(CURRENT_PLAYLIST_TABLE).children('li.song.albumli').each(function () { + var eachTlid = $(this).attr('tlid') + if (typeof eachTlid !== 'undefined') { + eachTlid = parseInt(eachTlid) + } + if (this.id === getjQueryID(target, uri) && eachTlid === tlid) { + if (!$(this).hasClass('currenttrack')) { + $(this).addClass('currenttrack') + } + } else if ($(this).hasClass('currenttrack')) { + $(this).removeClass('currenttrack') + } + }) + } + + var popupElement + + for (var i = 0; i < listviews.length; i++) { + target = listviews[i].substr(1) + $(listviews[i]).children('li.song.albumli').each(function () { + if (uri) { + if (this.id === getjQueryID(target, uri)) { + $(this).addClass('currenttrack2') + } else { + $(this).removeClass('currenttrack2') + } + } + if (popupMenuIcon) { + popupElement = $(this).children('a.moreBtn').children('i').first() + if (!popupElement.hasClass(popupMenuIcon)) { + popupElement.removeClass() + popupElement.addClass(popupMenuIcon) + } + } + }) + } } diff --git a/mopidy_musicbox_webclient/static/js/images.js b/mopidy_musicbox_webclient/static/js/images.js index 652fae3..19f0c28 100644 --- a/mopidy_musicbox_webclient/static/js/images.js +++ b/mopidy_musicbox_webclient/static/js/images.js @@ -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) + }}) } - }}); -} \ No newline at end of file + } + return images +})) diff --git a/mopidy_musicbox_webclient/static/js/library.js b/mopidy_musicbox_webclient/static/js/library.js index 3a62e05..13e63e8 100644 --- a/mopidy_musicbox_webclient/static/js/library.js +++ b/mopidy_musicbox_webclient/static/js/library.js @@ -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 = '
  • Show {count} more
  • '; - - // Artist results - var child = ''; - var pattern = '
  • {name}
  • '; - 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('
  • ', '
  • '); - } - - 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 = '
  • '; - pattern += '
    {albumName}
    '; - pattern += '

    {artistName}

    '; - pattern += '
  • '; - - 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('
  • ', '
  • '); - } + }, - 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( + '
  • No tracks found...

  • ' + ) + 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 = '
  • Show {count} more
  • ' + + // Artist results + var child = '' + var pattern = '
  • {name}
  • ' + 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('
  • ', '
  • ') + } + + 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 = '
  • ' + pattern += '
    {albumName}
    ' + pattern += '

    {artistName}

    ' + pattern += '
  • ' + + 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('
  • ', '
  • ') + } + + 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 +})) diff --git a/mopidy_musicbox_webclient/static/js/process_ws.js b/mopidy_musicbox_webclient/static/js/process_ws.js index d8cfeeb..a6bfe40 100644 --- a/mopidy_musicbox_webclient/static/js/process_ws.js +++ b/mopidy_musicbox_webclient/static/js/process_ws.js @@ -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 = '
  • Back

  • '; - 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 += '
  • ' + - '' + - '' + - '

    ' + resultArr[i].name + '

  • '; - 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 += '
  • ' + resultArr[i].name + '

  • '; + html += '
  • ' + + '

    ' + resultArr[i].name + '

  • ' } } - $('#browselist').html(child); - if (browseStack.length > 0 ) { - child = getMediaHuman(uri); - iconClass = getMediaClass(uri); - $('#browsepath').html(' ' + 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 = '
  • '; - if(isSpotifyStarredPlaylist(resultArr[i])) { - starred = li_html + '★ Spotify Starred Tracks
  • ' + tmp; + var li_html = '
  • ' + if (isSpotifyStarredPlaylist(resultArr[i])) { + starred = li_html + '★ Spotify Starred Tracks
  • ' + tmp } else if (isFavouritesPlaylist(resultArr[i])) { - favourites = li_html + '♥ Musicbox Favourites'; + favourites = li_html + '♥ Musicbox Favourites' } else { - tmp = tmp + li_html + ' ' + resultArr[i].name + ''; + tmp = tmp + li_html + ' ' + resultArr[i].name + '' } } // 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) } diff --git a/mopidy_musicbox_webclient/static/js/progress_timer.js b/mopidy_musicbox_webclient/static/js/progress_timer.js deleted file mode 100644 index 3e97fad..0000000 --- a/mopidy_musicbox_webclient/static/js/progress_timer.js +++ /dev/null @@ -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; -} diff --git a/mopidy_musicbox_webclient/static/js/synced_timer.js b/mopidy_musicbox_webclient/static/js/synced_timer.js new file mode 100644 index 0000000..aaa6c04 --- /dev/null +++ b/mopidy_musicbox_webclient/static/js/synced_timer.js @@ -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 +})) diff --git a/mopidy_musicbox_webclient/static/mb.appcache b/mopidy_musicbox_webclient/static/mb.appcache index 0b24062..153d912 100644 --- a/mopidy_musicbox_webclient/static/mb.appcache +++ b/mopidy_musicbox_webclient/static/mb.appcache @@ -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 diff --git a/mopidy_musicbox_webclient/static/system.html b/mopidy_musicbox_webclient/static/system.html index a252a6e..1c4a929 100644 --- a/mopidy_musicbox_webclient/static/system.html +++ b/mopidy_musicbox_webclient/static/system.html @@ -23,8 +23,8 @@
    diff --git a/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js b/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js index 138605f..9ca7e85 100644 --- a/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js +++ b/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js @@ -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; -} - -}()); \ No newline at end of file + return ProgressTimer; +})); \ No newline at end of file diff --git a/mopidy_musicbox_webclient/web.py b/mopidy_musicbox_webclient/web.py index 093f4f6..3ee687b 100644 --- a/mopidy_musicbox_webclient/web.py +++ b/mopidy_musicbox_webclient/web.py @@ -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 diff --git a/mopidy_musicbox_webclient/webclient.py b/mopidy_musicbox_webclient/webclient.py new file mode 100644 index 0000000..e4c6e79 --- /dev/null +++ b/mopidy_musicbox_webclient/webclient.py @@ -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') diff --git a/package.json b/package.json new file mode 100644 index 0000000..97fb1ac --- /dev/null +++ b/package.json @@ -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" +} diff --git a/screenshots/queue_desktop.png b/screenshots/queue_desktop.png new file mode 100644 index 0000000..5113a03 Binary files /dev/null and b/screenshots/queue_desktop.png differ diff --git a/setup.py b/setup.py index 17b680e..286077f 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ setup( ], entry_points={ 'mopidy.ext': [ - 'musicbox_webclient = mopidy_musicbox_webclient:MusicBoxExtension', + 'musicbox_webclient = mopidy_musicbox_webclient:Extension', ], }, classifiers=[ diff --git a/tests/js/dummy_tracklist.js b/tests/js/dummy_tracklist.js new file mode 100644 index 0000000..0401dba --- /dev/null +++ b/tests/js/dummy_tracklist.js @@ -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 +})) diff --git a/tests/js/test_controls.js b/tests/js/test_controls.js new file mode 100644 index 0000000..23a6825 --- /dev/null +++ b/tests/js/test_controls.js @@ -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('
    ') + $('#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') + }) + }) +}) diff --git a/tests/js/test_custom_scripting.js b/tests/js/test_custom_scripting.js new file mode 100644 index 0000000..5b3e171 --- /dev/null +++ b/tests/js/test_custom_scripting.js @@ -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() + }) +}) diff --git a/tests/js/test_images.js b/tests/js/test_images.js new file mode 100644 index 0000000..68004b9 --- /dev/null +++ b/tests/js/test_images.js @@ -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 = $('') + }) + 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() + }) + }) +}) diff --git a/tests/js/test_library.js b/tests/js/test_library.js new file mode 100644 index 0000000..47c2637 --- /dev/null +++ b/tests/js/test_library.js @@ -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('') + $('#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') + }) + }) +}) diff --git a/tests/js/test_synced_timer.js b/tests/js/test_synced_timer.js new file mode 100644 index 0000000..3e296d8 --- /dev/null +++ b/tests/js/test_synced_timer.js @@ -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( + '
    ' + + '' + + '' + + '' + + '' + + '
    ' + ) + $('#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() + }) + }) +}) diff --git a/tests/test_extension.py b/tests/test_extension.py index 31c685e..5ce0d43 100644 --- a/tests/test_extension.py +++ b/tests/test_extension.py @@ -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) diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..a77484c --- /dev/null +++ b/tests/test_web.py @@ -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 'MusicBox on localhost' 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 diff --git a/tests/test_webclient.py b/tests/test_webclient.py new file mode 100644 index 0000000..e15acaa --- /dev/null +++ b/tests/test_webclient.py @@ -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' diff --git a/tidy.js b/tidy.js new file mode 100644 index 0000000..bf857e0 --- /dev/null +++ b/tidy.js @@ -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() +}) diff --git a/tox.ini b/tox.ini index ead2ff1..22ff53e 100644 --- a/tox.ini +++ b/tox.ini @@ -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'