├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── mopidy_simple_webclient ├── __init__.py ├── ext.conf └── static │ ├── behavior │ ├── client.js │ └── sprintf.js │ ├── images │ ├── eject.png │ ├── next.png │ ├── pause.png │ ├── play.png │ ├── previous.png │ ├── repeat.png │ ├── shuffle.png │ ├── spinner.gif │ └── stop.png │ ├── index.html │ └── styles │ └── client.css ├── screenshots ├── getting-started.png ├── playback-control.png └── playlist-selection.png └── setup.py /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Peter Odding 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | ---------------------------------------- 23 | 24 | The simple Mopidy webclient builds on top of the following projects: 25 | 26 | - jQuery (the JavaScript library). Licensed under the MIT license, refer to 27 | https://github.com/jquery/jquery/blob/2.0.2/MIT-LICENSE.txt for details. 28 | 29 | - Bootstrap (the CSS framework). The version currently used is licensed under 30 | the Apache License, refer to https://github.com/twbs/bootstrap/blob/v2.3.2/LICENSE 31 | for details. 32 | 33 | - sprintf.js. Licensed under the BSD license, refer to 34 | https://github.com/alexei/sprintf.js/blob/master/LICENSE for details 35 | (I used http://www.what-license.com/ to identify the license). 36 | 37 | - Several icons from the Humanity icon theme included in Ubuntu Linux. The most 38 | authoritative source for a license I could find was 39 | http://bazaar.launchpad.net/~ubuntu-art-pkg/human-icon-theme/ubuntu/view/head:/COPYING 40 | which states that the "Creative Commons Attribution-ShareAlike 3.0 License" 41 | applies. To be honest it's not clear to me if using these icons requires my 42 | work to be licensed under the same license as well. If it turns out that this 43 | is true I'd rather find a different icon set, because the CC BY-SA license 44 | for software doesn't make sense. 45 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | include mopidy_simple_webclient/ext.conf 4 | graft mopidy_simple_webclient/static 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Simple Mopidy webclient 2 | ======================= 3 | 4 | The simple Mopidy webclient is an HTTP client for the `Mopidy music server`_ 5 | that's designed to be simple and minimalistic in an attempt to create a touch 6 | friendly web interface that works in most (mobile) web browsers. 7 | 8 | .. contents:: 9 | 10 | Introduction 11 | ------------ 12 | 13 | `Mopidy music server`_ is an awesome piece of software that provides a 14 | headless_ music player compatible with the MPD_ protocol and is capable of 15 | streaming music from Spotify_. I'm running Mopidy on two `Raspberry Pi`_ 16 | computers, one in my living room and one in my bedroom. 17 | 18 | The only thing I was missing was a simple client with playback control, volume 19 | control and playlist selection that would work on my smart phones, iPad and 20 | laptops. Despite the plentitude of `HTTP clients`_ referenced in the Mopidy 21 | documentation and the fact that `any MPD client`_ should work I didn't succeed 22 | in finding a client that actually worked well for me on all of the mentioned 23 | devices :-( 24 | 25 | After wasting two days on my search for a simple Mopidy client that would Just 26 | Work (TM) I decided to take up the Mopidy developers' promise that Mopidy is 27 | easy to extend by developing my own web interface. It took three iterations to 28 | build something I was happy with. 29 | 30 | First iteration: Server side PHP 31 | The first proof of concept was a simple PHP_ script using Mopidy's `JSON-RPC 32 | API`_. Once I had playback control implemented I decided that writing PHP 33 | makes me sad so I switched to Python_ (and Flask) instead. 34 | 35 | Second iteration: Server side Python 36 | The second proof of concept got a lot further than the first: I implemented 37 | playback control, volume control and playlist selection. While working on this 38 | implementation it began to dawn on me that a JavaScript client using 39 | asynchronous HTTP connections could be a lot more responsive than any server 40 | side implementation (and potentially simpler to boot). 41 | 42 | Third iteration: Client side JavaScript 43 | JavaScript_ is not exactly my favorite language but the experience of writing 44 | a Mopidy web client wasn't all that bad. Once I had everything running I 45 | really appreciated the elegance of only needing HTML, CSS and JavaScript to 46 | build a simple but usable Mopidy client! I didn't even use Mopidy.js_ because 47 | I started out by porting Python code built on top of Mopidy's `JSON-RPC API`_. 48 | 49 | Getting started 50 | --------------- 51 | 52 | As mentioned in the introduction above the simple Mopidy webclient is a client 53 | side JavaScript application. Despite this the client is published as a Python 54 | package. This package contains the client side code plus the minimal amount of 55 | glue (18 lines of Python code :-) needed to expose the client as a proper 56 | Mopidy HTTP extension. The Python package is available on PyPI_ which means 57 | it's very easy to install the client: 58 | 59 | .. code-block:: bash 60 | 61 | $ sudo pip install Mopidy-Simple-Webclient 62 | 63 | After installation you need to restart your Mopidy daemon to load the new 64 | extension. I'm running Mopidy as a system daemon so I would use the following 65 | command: 66 | 67 | .. code-block:: bash 68 | 69 | $ sudo service mopidy restart 70 | 71 | Accessing the web interface 72 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 73 | 74 | Once you've installed the extension and restarted your Mopidy daemon, the 75 | Mopidy web interface should look similar to this: 76 | 77 | .. image:: https://github.com/xolox/mopidy-simple-webclient/raw/master/screenshots/getting-started.png 78 | :alt: Mopidy webserver start screen. 79 | 80 | Click on the 'simple-webclient' link to open the simple Mopidy webclient. 81 | 82 | The playlist selection interface 83 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 84 | 85 | The simple Mopidy webclient doesn't have a playlist / tracklist editing 86 | interface and it also doesn't provide a way to browse your music collection. 87 | Instead you are expected to create a playlist in a more full featured Mopidy 88 | client or Spotify_ and select this playlist in the simple Mopidy web client. 89 | Selecting a playlist looks similar to this: 90 | 91 | .. image:: https://github.com/xolox/mopidy-simple-webclient/raw/master/screenshots/playlist-selection.png 92 | :alt: Playlist selection interface of the simple Mopidy webclient. 93 | 94 | Please note that I've only been using Mopidy for a couple of days (at the time 95 | I'm writing this) so I'm still getting to grips with how Mopidy works and this 96 | means I've only tested the playlist selection interface with Spotify 97 | playlists (not with local playlists). 98 | 99 | The playback control interface 100 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 101 | 102 | Once the Mopidy track list (the 'current playlist') contains some tracks the 103 | simple Mopidy webclient will switch to the playback control interface which 104 | looks like this: 105 | 106 | .. image:: https://github.com/xolox/mopidy-simple-webclient/raw/master/screenshots/playback-control.png 107 | :alt: Playback control interface of the simple Mopidy webclient. 108 | 109 | Here's an explanation of the main elements in the playback control interface: 110 | 111 | - At the top is the track title, followed by the album name and the artist(s). 112 | 113 | - Below the track info are the playback control buttons. When nothing is 114 | playing this shows previous/play/next buttons. While a track is playing this 115 | shows previous/pause/stop/next buttons. 116 | 117 | - The line of blue/grey dots is the volume control. HTML5_ has fancy slider 118 | controls for this but the web browser on my smartphone isn't fancy enough to 119 | support those so I created a simple touch friendly control instead. 120 | 121 | - The button "Select playlist" brings you back to the playlist selection 122 | interface and the other other two buttons do what you would expect them to 123 | :-). 124 | 125 | Future improvements 126 | ------------------- 127 | 128 | Some ideas for if/when I find the time to continue work on this client: 129 | 130 | Real time state changes 131 | It would be awesome to enable instant server → client notifications instead of 132 | a 10 second polling interval. It looks like this requires websockets. Not sure 133 | those will work on my smart phone. Even if they don't, maybe I can add 134 | optional support (graceful degradation)? 135 | 136 | Enable cover art 137 | It's not yet clear to me how cover art works in Mopidy, but other clients can 138 | do it so I should be able to as well :-) 139 | 140 | Enable server side configuration 141 | Mopidy's extension mechanism already forces me to use a configuration file, so 142 | why not add some useful options to that, like the ability to change the page 143 | title? This is not trivial because it would involve the first "server side" 144 | logic in this project (on the other hand that opens the door to 145 | functionality not available to pure JavaScript clients). 146 | 147 | Upgrade jQuery/Bootstrap, bundle the files 148 | Right now jQuery_ and Bootstrap_ are loaded from the Google and Bootstrap CDNs 149 | but at some point the referenced versions will disappear from the web. Why not 150 | upgrade to the latest versions and bundle the files in the git repository and 151 | Python source distributions? 152 | 153 | Contact 154 | ------- 155 | 156 | The latest version of the simple Mopidy webclient is available on PyPI_ and 157 | GitHub_. For bug reports please create an issue on GitHub_. If you have 158 | questions, suggestions, etc. feel free to send me an e-mail at 159 | `peter@peterodding.com`_. 160 | 161 | License 162 | ------- 163 | 164 | This software is licensed under the `MIT license`_. 165 | 166 | © 2014 Peter Odding. 167 | 168 | The simple Mopidy webclient uses the following projects: 169 | 170 | `Mopidy music server`_ 171 | Licensed under the Apache License, refer to the `Mopidy license`_ file. 172 | 173 | jQuery_ 174 | Licensed under the MIT license, refer to the `jQuery license`_ file. 175 | 176 | Bootstrap_ 177 | The version used is licensed under the Apache License, refer to the 178 | `Bootstrap license`_ file (newer versions are licensed under the MIT 179 | license). 180 | 181 | sprintf.js_ 182 | Licensed under the BSD license, refer to the `sprintf.js license`_ file (tip: 183 | I used what-license.com_ to identify the license :-). 184 | 185 | `Humanity icon theme`_ 186 | Licensed under the Creative Commons Attribution-ShareAlike 3.0 license, refer 187 | to the `Humanity license`_ file. It's not clear to me if using these icons 188 | with attribution and without alterations requires my work to be licensed 189 | under the same license as well (I'm hoping it doesn't, I'm afraid it does). 190 | If it turns out that this is true I'd rather find a different icon set 191 | because using CC BY-SA license for software doesn't make any sense. 192 | 193 | .. External references: 194 | .. _any MPD client: http://en.wikipedia.org/wiki/Music_Player_Daemon#Clients 195 | .. _Bootstrap license: https://github.com/twbs/bootstrap/blob/v2.3.2/LICENSE 196 | .. _Bootstrap: http://getbootstrap.com/ 197 | .. _GitHub: https://github.com/xolox/mopidy-simple-webclient 198 | .. _headless: http://en.wikipedia.org/wiki/Headless_software 199 | .. _HTML5: http://en.wikipedia.org/wiki/HTML5 200 | .. _HTTP clients: https://docs.mopidy.com/en/latest/clients/http/ 201 | .. _Humanity icon theme: https://launchpad.net/human-icon-theme 202 | .. _Humanity license: http://bazaar.launchpad.net/~ubuntu-art-pkg/human-icon-theme/ubuntu/view/head:/COPYING 203 | .. _JavaScript: http://en.wikipedia.org/wiki/JavaScript 204 | .. _jQuery license: https://github.com/jquery/jquery/blob/2.0.2/MIT-LICENSE.txt 205 | .. _jQuery: http://jquery.com/ 206 | .. _JSON-RPC API: https://docs.mopidy.com/en/latest/api/http/#http-api 207 | .. _MIT license: http://en.wikipedia.org/wiki/MIT_License 208 | .. _Mopidy license: https://github.com/mopidy/mopidy/blob/develop/LICENSE 209 | .. _Mopidy music server: https://www.mopidy.com/ 210 | .. _Mopidy.js: https://docs.mopidy.com/en/latest/api/js/#mopidy-js 211 | .. _MPD: http://en.wikipedia.org/wiki/Music_Player_Daemon 212 | .. _peter@peterodding.com: peter@peterodding.com 213 | .. _PHP: http://en.wikipedia.org/wiki/PHP 214 | .. _PyPI: https://pypi.python.org/pypi/Mopidy-Simple-Webclient 215 | .. _Python: http://en.wikipedia.org/wiki/Python_(programming_language) 216 | .. _Raspberry Pi: http://en.wikipedia.org/wiki/Raspberry_Pi 217 | .. _Spotify: http://en.wikipedia.org/wiki/Spotify 218 | .. _sprintf.js license: https://github.com/alexei/sprintf.js/blob/master/LICENSE 219 | .. _sprintf.js: https://github.com/alexei/sprintf.js 220 | .. _what-license.com: http://www.what-license.com/ 221 | -------------------------------------------------------------------------------- /mopidy_simple_webclient/__init__.py: -------------------------------------------------------------------------------- 1 | # Simple Mopidy web client. 2 | # 3 | # Author: Peter Odding 4 | # Last Change: June 8, 2015 5 | # URL: https://github.com/xolox/mopidy-simple-webclient 6 | 7 | import os.path 8 | import mopidy.config 9 | import mopidy.ext 10 | 11 | __version__ = '0.1.1' 12 | 13 | class Extension(mopidy.ext.Extension): 14 | 15 | ext_name = 'simple-webclient' 16 | version = __version__ 17 | 18 | def get_default_config(self): 19 | directory = os.path.dirname(os.path.abspath(__file__)) 20 | return mopidy.config.read(os.path.join(directory, 'ext.conf')) 21 | 22 | def setup(self, registry): 23 | directory = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') 24 | registry.add('http:static', dict(name=self.ext_name, path=directory)) 25 | -------------------------------------------------------------------------------- /mopidy_simple_webclient/ext.conf: -------------------------------------------------------------------------------- 1 | [simple-webclient] 2 | enabled = true 3 | -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/behavior/client.js: -------------------------------------------------------------------------------- 1 | /* Simple Mopidy web client. 2 | * 3 | * Author: Peter Odding 4 | * Last Change: June 8, 2015 5 | * URL: https://github.com/xolox/mopidy-simple-webclient 6 | */ 7 | 8 | // Initialize the Mopidy client after all resources have been loaded. 9 | $(function() { 10 | mopidy_client = new MopidyClient(); 11 | }); 12 | 13 | // MopidyClient {{{1 14 | 15 | function MopidyClient() { 16 | this.init(); 17 | return this; 18 | }; 19 | 20 | // MopidyClient.init() - Initialize the Mopidy client. {{{2 21 | 22 | MopidyClient.prototype.init = function() { 23 | // Initialize a logging handler for structured logging messages. 24 | this.logger = new Logger(); 25 | this.logger.info("Initializing Mopidy client .."); 26 | // Track the connection state. 27 | this.connected = false; 28 | // Use an incrementing integer to uniquely identify JSON RPC calls. 29 | this.id = 1; 30 | // Enable tweaks for mobile devices. 31 | this.enable_mobile_tweaks(); 32 | // Install global event handlers. 33 | this.install_event_handlers(); 34 | // Ask the user which Mopidy server to connect to. 35 | this.select_server(); 36 | }; 37 | 38 | // MopidyClient.enable_mobile_tweaks() {{{2 39 | 40 | MopidyClient.prototype.enable_mobile_tweaks = function() { 41 | if (navigator.userAgent.match(/mobile/i)) { 42 | $('body').addClass('mobile'); 43 | } 44 | }; 45 | 46 | // MopidyClient.install_event_handlers() {{{2 47 | 48 | MopidyClient.prototype.install_event_handlers = function() { 49 | var client = this; 50 | $('#select-server form').submit(function() { client.connect($('#server-url').val()); return false; }); 51 | $('#play-button').click(function() { client.play(); }); 52 | $('#pause-button').click(function() { client.pause(); }); 53 | $('#stop-button').click(function() { client.stop(); }); 54 | $('#previous-track-button').click(function() { client.previous_track(); }); 55 | $('#next-track-button').click(function() { client.next_track(); }); 56 | $('#toggle-shuffle-button').click(function() { client.toggle_shuffle(); }); 57 | $('#toggle-repeat-button').click(function() { client.toggle_repeat(); }); 58 | $('#select-playlist-button').click(function() { client.select_playlist(); }); 59 | $('#cancel-playlist-selection-button').click(function() { client.show_now_playing(); }); 60 | }; 61 | 62 | // MopidyClient.select_server() - Ask the user which Mopidy server to connect to. {{{2 63 | 64 | MopidyClient.prototype.select_server = function() { 65 | // Pre fill the Mopidy server's base URL. This logic is intended to support 66 | // 1) Mopidy running on a separate domain and 2) Mopidy running on a specific 67 | // URL prefix. 68 | var url = document.location.href; 69 | // Remove fragment identifiers from the URL. 70 | url = url.replace(/#.*$/, ''); 71 | // Remove an optional (redundant) filename from the URL. 72 | url = url.replace(/index\.html$/, ''); 73 | // Remove the extension name from the URL. 74 | url = url.replace(/\/simple-webclient\/$/, '/'); 75 | // Pre fill the form field. 76 | $('#server-url').val(url); 77 | // Try to connect automatically. 78 | this.logger.info("Trying to connect automatically .."); 79 | $('#select-server form').submit(); 80 | }; 81 | 82 | // MopidyClient.error_handler() {{{2 83 | 84 | MopidyClient.prototype.error_handler = function(e) { 85 | this.logger.error("Exception handler called! (%s)", e); 86 | if (!this.connected) { 87 | this.show('select-server'); 88 | $('#connect-error').html(sprintf("Error: Failed to connect to %s", this.base_url)); 89 | } else { 90 | $('#runtime-error').html(sprintf("Warning: Encountered unhandled error! (review the console log for details)")); 91 | $('#runtime-error').show(); 92 | console.log(e); 93 | } 94 | }; 95 | 96 | // MopidyClient.connect() - Connect to the Mopidy server. {{{2 97 | 98 | MopidyClient.prototype.connect = function(base_url) { 99 | // Store the Mopidy server base URL. 100 | this.base_url = base_url; 101 | // If the user entered a URL without a scheme we'll default to the http:// scheme. 102 | if (!this.base_url.match(/^\w+:/)) 103 | this.base_url = 'http://' + this.base_url; 104 | this.logger.debug("Mopidy server base URL is " + this.base_url); 105 | // Concatenate the base URL and the /mopidy/rpc/ path. 106 | this.rpc_url = this.base_url.replace(/\/*$/, '/mopidy/rpc'); 107 | this.logger.debug("Mopidy server RPC URL is " + this.rpc_url); 108 | // Switch to the `now playing' interface. 109 | this.show_now_playing(); 110 | }; 111 | 112 | // MopidyClient.show_now_playing() {{{2 113 | 114 | MopidyClient.prototype.show_now_playing = function() { 115 | this.show('now-playing'); 116 | this.refresh_gui(); 117 | }; 118 | 119 | // MopidyClient.refresh_gui() - Refresh the GUI. {{{2 120 | 121 | var gui_refresh_interval = null; 122 | 123 | MopidyClient.prototype.refresh_gui = function() { 124 | if (document.location.hash == '#now-playing') { 125 | this.call('core.playback.get_current_track', function(current_track) { 126 | if (!current_track) { 127 | this.logger.info("Nothing is currently playing, starting play list selection .."); 128 | this.select_playlist(); 129 | } else { 130 | this.render_current_track(current_track); 131 | } 132 | }); 133 | } 134 | if (gui_refresh_interval != null) 135 | clearTimeout(gui_refresh_interval); 136 | gui_refresh_interval = setTimeout(function() { 137 | mopidy_client.refresh_gui(); 138 | }, 10000); 139 | }; 140 | 141 | // MopidyClient.select_playlist() - Ask the user which play list to load. {{{2 142 | 143 | MopidyClient.prototype.select_playlist = function() { 144 | // Show the play list selection interface. 145 | $('#available-playlists').hide(); 146 | $('#no-playlists-message').hide(); 147 | this.show('select-playlist'); 148 | $('#loading-playlists-spinner').show(); 149 | // Fetch the available play lists from the server. 150 | this.call('core.playlists.get_playlists', function(playlists) { 151 | this.logger.info("Found %i play lists.", playlists.length); 152 | var labels = []; 153 | for (var i = 0; i < playlists.length; i++) { 154 | var name = playlists[i].name; 155 | var size = playlists[i].tracks ? playlists[i].tracks.length : 0; 156 | var classes = 'btn btn-large'; 157 | if (name == 'Starred') 158 | classes += ' btn-primary'; 159 | var onclick = sprintf('mopidy_client.load_playlist(%s)', JSON.stringify(name)); 160 | labels.push(sprintf('', 161 | classes, html_encode(onclick), html_encode(name), size)); 162 | } 163 | if (labels.length > 0) { 164 | $('#loading-playlists-spinner').hide(); 165 | $('#available-playlists').html(labels.join('\n')); 166 | $('#available-playlists').show(); 167 | } else { 168 | $('#loading-playlists-spinner').hide(); 169 | $('#available-playlists').hide(); 170 | $('#no-playlists-message').show(); 171 | } 172 | }); 173 | }; 174 | 175 | // MopidyClient.load_playlist() - Load the selected play list. {{{2 176 | 177 | MopidyClient.prototype.load_playlist = function(name) { 178 | // Fetch the available play lists from the server. 179 | this.call('core.playlists.get_playlists', function(playlists) { 180 | for (var i = 0; i < playlists.length; i++) { 181 | var playlist = playlists[i]; 182 | // Match the selected play list by name. 183 | if (playlist.name == name) { 184 | // Clear all existing tracks from the track list. 185 | this.logger.debug("Clearing track list .."); 186 | this.call('core.tracklist.clear', function() { 187 | this.logger.debug("Adding tracks to track list .."); 188 | this.call({ 189 | method: 'core.tracklist.add', 190 | params: [playlist.tracks], 191 | done: function() { 192 | this.call('core.playback.play', function() { 193 | this.show_now_playing(); 194 | }); 195 | } 196 | }); 197 | }); 198 | // Stop looking, we found the relevant play list. 199 | break; 200 | } 201 | } 202 | }); 203 | }; 204 | 205 | // MopidyClient.render_current_track() - Update the current track info. {{{2 206 | 207 | MopidyClient.prototype.render_current_track = function(current_track) { 208 | this.logger.info("Rendering track information .."); 209 | // Show the now playing interface. 210 | this.show('now-playing'); 211 | // Render the "$track from $album by $artist" text. 212 | var now_playing = []; 213 | now_playing.push(sprintf('%s
', this.link_to_spotify(current_track))); 214 | if (current_track.album && current_track.album.name != 'Unknown') { 215 | now_playing.push('from'); 216 | now_playing.push(sprintf('%s
', this.link_to_spotify(current_track.album))); 217 | } 218 | if (current_track.artists) { 219 | var artists = []; 220 | for (var i = 0; i < current_track.artists.length; i++) { 221 | var artist_name = this.link_to_spotify(current_track.artists[i]); 222 | artists.push(sprintf('%s', artist_name)); 223 | } 224 | now_playing.push('by'); 225 | now_playing.push(sprintf('%s', artists.join(', '))); 226 | } 227 | $('#track-info').html(now_playing.join('\n')); 228 | this.update_play_state(); 229 | } 230 | 231 | // MopidyClient.update_play_state() {{{2 232 | 233 | MopidyClient.prototype.update_play_state = function() { 234 | // Update the pause/resume toggle button. 235 | this.call('core.playback.get_state', function(state) { 236 | var button = $('#toggle-playback-button'); 237 | if (state == 'playing') { 238 | $('#play-button').hide(); 239 | $('#pause-button').show(); 240 | $('#stop-button').show(); 241 | } else { 242 | $('#play-button').show(); 243 | $('#pause-button').hide(); 244 | $('#stop-button').hide(); 245 | } 246 | }); 247 | // Update the shuffle toggle button. 248 | this.call('core.tracklist.get_random', function(enabled) { 249 | var label = $('#toggle-shuffle-button span'); 250 | label.text(enabled ? "Disable shuffle" : "Enable shuffle"); 251 | }); 252 | // Update the repeat toggle button. 253 | this.call('core.tracklist.get_repeat', function(enabled) { 254 | var label = $('#toggle-repeat-button span'); 255 | label.text(enabled ? "Disable repeat" : "Enable repeat"); 256 | }); 257 | // Update the volume level. 258 | this.call('core.playback.get_volume', function(volume_level) { 259 | var markers = $('#volume-control span'); 260 | var step_size = 100 / markers.length; 261 | for (var i = 0; i < markers.length; i++) 262 | if ((i * step_size) <= volume_level) 263 | $(markers[i]).addClass('filled'); 264 | else 265 | $(markers[i]).removeClass('filled'); 266 | }); 267 | }; 268 | 269 | // MopidyClient.play() {{{2 270 | 271 | MopidyClient.prototype.play = function() { 272 | // this.call('core.playback.resume'); 273 | this.call('core.playback.play', this.update_play_state); 274 | }; 275 | 276 | // MopidyClient.pause() {{{2 277 | 278 | MopidyClient.prototype.pause = function() { 279 | this.call('core.playback.pause', this.update_play_state); 280 | }; 281 | 282 | // MopidyClient.stop() {{{2 283 | 284 | MopidyClient.prototype.stop = function() { 285 | this.call('core.playback.stop', this.update_play_state); 286 | }; 287 | 288 | // MopidyClient.previous_track() {{{2 289 | 290 | MopidyClient.prototype.previous_track = function() { 291 | this.call('core.playback.previous', function() { 292 | this.refresh_gui(); 293 | }); 294 | }; 295 | 296 | // MopidyClient.next_track() {{{2 297 | 298 | MopidyClient.prototype.next_track = function() { 299 | this.call('core.playback.next', function() { 300 | this.refresh_gui(); 301 | }); 302 | }; 303 | 304 | // MopidyClient.toggle_shuffle() {{{2 305 | 306 | MopidyClient.prototype.toggle_shuffle = function() { 307 | this.call('core.tracklist.get_random', function(enabled) { 308 | this.call({ 309 | method: 'core.tracklist.set_random', 310 | params: [!enabled] 311 | }); 312 | this.refresh_gui(); 313 | }); 314 | }; 315 | 316 | // MopidyClient.toggle_repeat() {{{2 317 | 318 | MopidyClient.prototype.toggle_repeat = function() { 319 | this.call('core.tracklist.get_repeat', function(enabled) { 320 | this.call({ 321 | method: 'core.tracklist.set_repeat', 322 | params: [!enabled] 323 | }); 324 | this.refresh_gui(); 325 | }); 326 | }; 327 | 328 | // MopidyClient.set_volume() {{{2 329 | 330 | MopidyClient.prototype.set_volume = function(volume_level) { 331 | this.call({method: 'core.playback.set_volume', params: [volume_level]}); 332 | this.update_play_state(); 333 | }; 334 | 335 | // MopidyClient.show() - Bring the given interface to the front. {{{2 336 | 337 | MopidyClient.prototype.show = function(element_id) { 338 | this.logger.info("Showing element with ID %s ..", element_id); 339 | $('.hidden-by-default').hide(0, function() { 340 | $(sprintf('#%s', element_id)).show(0); 341 | }); 342 | document.location.href = sprintf('#%s', element_id); 343 | }; 344 | 345 | // MopidyClient.link_to_spotify() - Generate hyper links to the Spotify web player. {{{2 346 | 347 | MopidyClient.prototype.link_to_spotify = function (object) { 348 | var result = html_encode(object.name); 349 | if (object.uri) { 350 | var tokens = object.uri.split(':'); 351 | if (tokens.length == 3 && tokens[0] == 'spotify') { 352 | var kind = tokens[1], identifier = tokens[2]; 353 | result = sprintf('%s', kind, identifier, result); 354 | } 355 | } 356 | return result; 357 | }; 358 | 359 | // MopidyClient.call() - Call Mopidy API methods using JSON RPC. {{{2 360 | 361 | MopidyClient.prototype.call = function() { 362 | // Unpack the arguments. 363 | if (arguments.length == 2) { 364 | var method = arguments[0]; 365 | var params = []; 366 | var callback = arguments[1]; 367 | } else { 368 | var settings = arguments[0]; 369 | var method = settings.method; 370 | var params = settings.params || []; 371 | var callback = settings.done; 372 | } 373 | // Generate a unique id for this call. 374 | var request_id = this.id; 375 | this.id += 1; 376 | // Generate the JSON request body. 377 | var request_body = JSON.stringify({ 378 | jsonrpc: '2.0', 379 | method: method, 380 | params: params, 381 | id: request_id 382 | }); 383 | this.logger.debug("Generated request body: %s", request_body); 384 | // Make the call. 385 | this.logger.debug("Sending request .."); 386 | jQuery.ajax({ 387 | url: this.rpc_url, 388 | type: 'POST', 389 | data: request_body 390 | }).done(function(data) { 391 | mopidy_client.connected = true; 392 | if (data.error) { 393 | console.log(data); 394 | throw "Mopidy API reported error: " + data.error.data; 395 | } else if (data.id != request_id) { 396 | throw "Response id " + data.id + " doesn't match request id " + request_id + "!"; 397 | } 398 | if (callback) 399 | jQuery.proxy(callback, mopidy_client)(data.result); 400 | }).error(function(e) { 401 | mopidy_client.error_handler(e); 402 | }); 403 | }; 404 | 405 | // Logger {{{1 406 | 407 | function Logger() { 408 | return this; 409 | }; 410 | 411 | // Logger.log() {{{2 412 | 413 | Logger.prototype.log = function(severity, args) { 414 | // Get the current date and time. 415 | var now = new Date(); 416 | var timestamp = sprintf( 417 | '%i-%02d-%02d %02d:%02d:%02d', 418 | now.getFullYear(), now.getMonth(), now.getDate(), 419 | now.getHours(), now.getMinutes(), now.getSeconds() 420 | ); 421 | // Render the log message. 422 | var message = sprintf.apply(null, args); 423 | console.log(timestamp + ' ' + severity + ' ' + message); 424 | }; 425 | 426 | // Logger.error() {{{2 427 | 428 | Logger.prototype.error = function() { 429 | this.log('ERROR', arguments); 430 | }; 431 | 432 | // Logger.warning() {{{2 433 | 434 | Logger.prototype.warning = function() { 435 | this.log('WARN', arguments); 436 | }; 437 | 438 | // Logger.info() {{{2 439 | 440 | Logger.prototype.info = function() { 441 | this.log('INFO', arguments); 442 | }; 443 | 444 | // Logger.debug() {{{2 445 | 446 | Logger.prototype.debug = function() { 447 | this.log('DEBUG', arguments); 448 | }; 449 | 450 | // Miscellaneous functions. {{{1 451 | 452 | // html_encode(string) {{{2 453 | 454 | function html_encode(string) { 455 | return string.replace(/&/g, '&') 456 | .replace(/"/g, '"') 457 | .replace(/'/g, ''') 458 | .replace(//g, '>'); 460 | }; 461 | -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/behavior/sprintf.js: -------------------------------------------------------------------------------- 1 | (function(window) { 2 | var re = { 3 | not_string: /[^s]/, 4 | number: /[dief]/, 5 | text: /^[^\x25]+/, 6 | modulo: /^\x25{2}/, 7 | placeholder: /^\x25(?:([1-9]\d*)\$|\(([^\)]+)\))?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fiosuxX])/, 8 | key: /^([a-z_][a-z_\d]*)/i, 9 | key_access: /^\.([a-z_][a-z_\d]*)/i, 10 | index_access: /^\[(\d+)\]/, 11 | sign: /^[\+\-]/ 12 | } 13 | 14 | function sprintf() { 15 | var key = arguments[0], cache = sprintf.cache 16 | if (!(cache[key] && cache.hasOwnProperty(key))) { 17 | cache[key] = sprintf.parse(key) 18 | } 19 | return sprintf.format.call(null, cache[key], arguments) 20 | } 21 | 22 | sprintf.format = function(parse_tree, argv) { 23 | var cursor = 1, tree_length = parse_tree.length, node_type = "", arg, output = [], i, k, match, pad, pad_character, pad_length, is_positive = true, sign = "" 24 | for (i = 0; i < tree_length; i++) { 25 | node_type = get_type(parse_tree[i]) 26 | if (node_type === "string") { 27 | output[output.length] = parse_tree[i] 28 | } 29 | else if (node_type === "array") { 30 | match = parse_tree[i] // convenience purposes only 31 | if (match[2]) { // keyword argument 32 | arg = argv[cursor] 33 | for (k = 0; k < match[2].length; k++) { 34 | if (!arg.hasOwnProperty(match[2][k])) { 35 | throw new Error(sprintf("[sprintf] property '%s' does not exist", match[2][k])) 36 | } 37 | arg = arg[match[2][k]] 38 | } 39 | } 40 | else if (match[1]) { // positional argument (explicit) 41 | arg = argv[match[1]] 42 | } 43 | else { // positional argument (implicit) 44 | arg = argv[cursor++] 45 | } 46 | 47 | if (get_type(arg) == "function") { 48 | arg = arg() 49 | } 50 | 51 | if (re.not_string.test(match[8]) && (get_type(arg) != "number" && isNaN(arg))) { 52 | throw new TypeError(sprintf("[sprintf] expecting number but found %s", get_type(arg))) 53 | } 54 | 55 | if (re.number.test(match[8])) { 56 | is_positive = arg >= 0 57 | } 58 | 59 | switch (match[8]) { 60 | case "b": 61 | arg = arg.toString(2) 62 | break 63 | case "c": 64 | arg = String.fromCharCode(arg) 65 | break 66 | case "d": 67 | case "i": 68 | arg = parseInt(arg, 10) 69 | break 70 | case "e": 71 | arg = match[7] ? arg.toExponential(match[7]) : arg.toExponential() 72 | break 73 | case "f": 74 | arg = match[7] ? parseFloat(arg).toFixed(match[7]) : parseFloat(arg) 75 | break 76 | case "o": 77 | arg = arg.toString(8) 78 | break 79 | case "s": 80 | arg = ((arg = String(arg)) && match[7] ? arg.substring(0, match[7]) : arg) 81 | break 82 | case "u": 83 | arg = arg >>> 0 84 | break 85 | case "x": 86 | arg = arg.toString(16) 87 | break 88 | case "X": 89 | arg = arg.toString(16).toUpperCase() 90 | break 91 | } 92 | if (re.number.test(match[8]) && (!is_positive || match[3])) { 93 | sign = is_positive ? "+" : "-" 94 | arg = arg.toString().replace(re.sign, "") 95 | } 96 | else { 97 | sign = "" 98 | } 99 | pad_character = match[4] ? match[4] === "0" ? "0" : match[4].charAt(1) : " " 100 | pad_length = match[6] - (sign + arg).length 101 | pad = match[6] ? (pad_length > 0 ? str_repeat(pad_character, pad_length) : "") : "" 102 | output[output.length] = match[5] ? sign + arg + pad : (pad_character === "0" ? sign + pad + arg : pad + sign + arg) 103 | } 104 | } 105 | return output.join("") 106 | } 107 | 108 | sprintf.cache = {} 109 | 110 | sprintf.parse = function(fmt) { 111 | var _fmt = fmt, match = [], parse_tree = [], arg_names = 0 112 | while (_fmt) { 113 | if ((match = re.text.exec(_fmt)) !== null) { 114 | parse_tree[parse_tree.length] = match[0] 115 | } 116 | else if ((match = re.modulo.exec(_fmt)) !== null) { 117 | parse_tree[parse_tree.length] = "%" 118 | } 119 | else if ((match = re.placeholder.exec(_fmt)) !== null) { 120 | if (match[2]) { 121 | arg_names |= 1 122 | var field_list = [], replacement_field = match[2], field_match = [] 123 | if ((field_match = re.key.exec(replacement_field)) !== null) { 124 | field_list[field_list.length] = field_match[1] 125 | while ((replacement_field = replacement_field.substring(field_match[0].length)) !== "") { 126 | if ((field_match = re.key_access.exec(replacement_field)) !== null) { 127 | field_list[field_list.length] = field_match[1] 128 | } 129 | else if ((field_match = re.index_access.exec(replacement_field)) !== null) { 130 | field_list[field_list.length] = field_match[1] 131 | } 132 | else { 133 | throw new SyntaxError("[sprintf] failed to parse named argument key") 134 | } 135 | } 136 | } 137 | else { 138 | throw new SyntaxError("[sprintf] failed to parse named argument key") 139 | } 140 | match[2] = field_list 141 | } 142 | else { 143 | arg_names |= 2 144 | } 145 | if (arg_names === 3) { 146 | throw new Error("[sprintf] mixing positional and named placeholders is not (yet) supported") 147 | } 148 | parse_tree[parse_tree.length] = match 149 | } 150 | else { 151 | throw new SyntaxError("[sprintf] unexpected placeholder") 152 | } 153 | _fmt = _fmt.substring(match[0].length) 154 | } 155 | return parse_tree 156 | } 157 | 158 | var vsprintf = function(fmt, argv, _argv) { 159 | _argv = (argv || []).slice(0) 160 | _argv.splice(0, 0, fmt) 161 | return sprintf.apply(null, _argv) 162 | } 163 | 164 | /** 165 | * helpers 166 | */ 167 | function get_type(variable) { 168 | return Object.prototype.toString.call(variable).slice(8, -1).toLowerCase() 169 | } 170 | 171 | function str_repeat(input, multiplier) { 172 | return Array(multiplier + 1).join(input) 173 | } 174 | 175 | /** 176 | * export to either browser or node.js 177 | */ 178 | if (typeof exports !== "undefined") { 179 | exports.sprintf = sprintf 180 | exports.vsprintf = vsprintf 181 | } 182 | else { 183 | window.sprintf = sprintf 184 | window.vsprintf = vsprintf 185 | 186 | if (typeof define === "function" && define.amd) { 187 | define(function() { 188 | return { 189 | sprintf: sprintf, 190 | vsprintf: vsprintf 191 | } 192 | }) 193 | } 194 | } 195 | })(typeof window === "undefined" ? this : window); 196 | -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/eject.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/eject.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/next.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/next.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/pause.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/play.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/previous.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/previous.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/repeat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/repeat.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/shuffle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/shuffle.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/spinner.gif -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/images/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/mopidy_simple_webclient/static/images/stop.png -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/index.html: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | Music player 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 |

To get started please enter the address of your Mopidy server below and click on the connect button. The default should be fine in most circumstances.

29 |
30 |

31 |

32 |

33 |
34 |
35 | 36 |
37 |

Select a playlist below to start playing the tracks in that playlist. 38 | Alternatively you can return 39 | without selecting a playlist.

40 |

41 |

42 |
43 |

You don't have any playlists!

44 |

That's a bit of a problem because this Mopidy client expects to get 45 | started by loading a playlist into the play queue!

46 |

Please use another Mopidy client to create a playlist or create a 47 | playlist in Spotify (and make sure the Spotify plug-in for Mopidy is 48 | enabled).

49 |
50 |
51 | 52 |
53 |
54 |
55 |

56 | 57 | 58 | 59 | 60 | 61 |

62 |

63 | 64 | 65 | 66 | 67 | 68 | 69 |
70 |

71 |

72 | 76 | 80 | 84 |

85 |
86 |
87 |

88 | 89 | 90 | -------------------------------------------------------------------------------- /mopidy_simple_webclient/static/styles/client.css: -------------------------------------------------------------------------------- 1 | /* Simple Mopidy web client. 2 | * 3 | * Author: Peter Odding 4 | * Last Change: October 19, 2014 5 | * URL: https://github.com/xolox/mopidy-simple-webclient 6 | */ 7 | 8 | /* Global styles. {{{1 */ 9 | 10 | body { 11 | margin: 1em; 12 | font-size: 2em; 13 | line-height: 1.75em; 14 | color: black; 15 | background: white; 16 | } 17 | 18 | .spinner { 19 | display: block; 20 | margin: 2em auto; 21 | width: 2em; 22 | height: 2em; 23 | } 24 | 25 | .hidden-by-default { 26 | display: none; 27 | } 28 | 29 | .dialog { 30 | margin: 0 auto; 31 | min-width: 60%; 32 | max-width: 25em; 33 | text-align: justify; 34 | } 35 | 36 | /* Server selection interface. {{{1 */ 37 | 38 | #select-server input[type=text] { 39 | display: block; 40 | width: 20em; 41 | } 42 | 43 | /* Play list selection interface. {{{1 */ 44 | 45 | #available-playlists button { 46 | display: block; 47 | margin: 0.5em auto; 48 | } 49 | 50 | .error-message { 51 | margin: 1em; 52 | padding: 0.5em; 53 | border: 0.1em solid #ffaaff; 54 | background-color: #ffeeff; 55 | border-radius: 0.25em; 56 | } 57 | 58 | /* Now playing interface. {{{1 */ 59 | 60 | #now-playing { 61 | text-align: center; 62 | } 63 | 64 | /* Track metadata. {{{2 */ 65 | 66 | #now-playing a:link, 67 | #now-playing a:visited { 68 | color: inherit; 69 | text-decoration: none; 70 | } 71 | 72 | #now-playing a:hover, 73 | #now-playing a:active { 74 | text-decoration: underline; 75 | } 76 | 77 | #now-playing .track-name { 78 | font-size: 1.3em; 79 | font-weight: bold; 80 | } 81 | 82 | #now-playing .album-name, 83 | #now-playing .artist-name { 84 | font-size: 1.2em; 85 | } 86 | 87 | #now-playing .from-album, 88 | #now-playing .by-artist { 89 | font-style: italic; 90 | opacity: 0.25; 91 | } 92 | 93 | /* Playback control. {{{2 */ 94 | 95 | #now-playing #controls { 96 | text-align: center; 97 | } 98 | 99 | #now-playing #controls img { 100 | width: 2.5em; 101 | height: 2.5em; 102 | } 103 | 104 | #now-playing #controls button img { 105 | width: 2em; 106 | height: 2em; 107 | } 108 | 109 | .dimmed-image-button { 110 | opacity: 0.5; 111 | } 112 | 113 | .hover-image-button:hover { 114 | opacity: 1; 115 | } 116 | 117 | #playback-controls, #volume-control, #misc-controls { 118 | margin: 1em auto; 119 | } 120 | 121 | #misc-controls button { 122 | margin: 0.25em; 123 | } 124 | 125 | /* Volume control. {{{2 */ 126 | 127 | #volume-control { 128 | width: 16em; 129 | } 130 | 131 | #volume-control .step { 132 | display: block; 133 | margin: 0.3em; 134 | width: 2em; 135 | height: 2em; 136 | float: left; 137 | background-color: #eee; 138 | border-radius: 1em; 139 | } 140 | 141 | #volume-control .filled { 142 | background-color: LightSteelBlue; 143 | } 144 | 145 | /* Mobile tweaks. {{{1 */ 146 | 147 | body.mobile { 148 | font-size: 3em; 149 | line-height: 1.5em; 150 | } 151 | 152 | body.mobile .dimmed-image-button { 153 | opacity: 1; 154 | } 155 | 156 | body.mobile button { 157 | padding: 0.5em 0.75em; 158 | font-size: 1em; 159 | line-height: 1.5em; 160 | text-align: center; 161 | } 162 | -------------------------------------------------------------------------------- /screenshots/getting-started.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/screenshots/getting-started.png -------------------------------------------------------------------------------- /screenshots/playback-control.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/screenshots/playback-control.png -------------------------------------------------------------------------------- /screenshots/playlist-selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xolox/mopidy-simple-webclient/fa4def1f34469b6539c27e98324f6b83604c226d/screenshots/playlist-selection.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Setup script for the `Mopidy-Simple-Webclient' package. 4 | # 5 | # Author: Peter Odding 6 | # Last Change: October 19, 2014 7 | # URL: https://github.com/xolox/mopidy-simple-webclient 8 | 9 | import os 10 | import setuptools 11 | import re 12 | 13 | def get_contents(filename): 14 | """Get the contents of a file relative to the source distribution directory.""" 15 | root = os.path.dirname(os.path.abspath(__file__)) 16 | with open(os.path.join(root, filename)) as handle: 17 | return handle.read() 18 | 19 | def get_version(filename): 20 | """Extract the version number from a Python module.""" 21 | contents = get_contents(filename) 22 | metadata = dict(re.findall('__([a-z]+)__ = [\'"]([^\'"]+)', contents)) 23 | return metadata['version'] 24 | 25 | setuptools.setup( 26 | name='Mopidy-Simple-Webclient', 27 | version=get_version('mopidy_simple_webclient/__init__.py'), 28 | description="Very simple and mobile friendly web interface for the Mopidy music server", 29 | long_description=get_contents('README.rst'), 30 | url='https://github.com/xolox/mopidy-simple-webclient', 31 | author='Peter Odding', 32 | author_email='peter@peterodding.com', 33 | packages=setuptools.find_packages(), 34 | zip_safe=False, 35 | include_package_data=True, 36 | install_requires=[ 37 | 'Mopidy >= 0.19.4', 38 | 'setuptools', 39 | ], 40 | entry_points={ 41 | 'mopidy.ext': [ 42 | 'simple-webclient = mopidy_simple_webclient:Extension', 43 | ], 44 | }, 45 | classifiers=[ 46 | 'Environment :: No Input/Output (Daemon)', 47 | 'Intended Audience :: End Users/Desktop', 48 | 'License :: OSI Approved :: MIT License', 49 | 'Operating System :: OS Independent', 50 | 'Programming Language :: Python :: 2', 51 | 'Topic :: Multimedia :: Sound/Audio :: Players', 52 | ]) 53 | --------------------------------------------------------------------------------