├── .bowerrc ├── .gitignore ├── Dockerfile ├── LICENSE.txt ├── README.md ├── bower.json ├── gulpfile.js ├── images └── screenshot.png ├── index.js ├── lib ├── alsa │ └── volume.js └── spop │ ├── client.js │ └── middleware.js ├── package.json └── public ├── favicon.png ├── images └── cover.svg ├── index.html ├── scripts ├── app.js ├── config │ ├── requestTimes.js │ └── routes.js ├── directives │ ├── dropdown.html │ ├── dropdown.js │ ├── loadSrc.js │ ├── longClick.js │ ├── slider.html │ ├── slider.js │ ├── trackTimer.html │ ├── trackTimer.js │ └── visible.js ├── filters │ ├── coordinates.js │ └── timeFormat.js ├── modules │ ├── album │ │ ├── albumCtrl.js │ │ ├── albumList.html │ │ ├── albumList.js │ │ ├── albumModel.js │ │ ├── albumService.js │ │ └── view.html │ ├── artist │ │ ├── artistCtrl.js │ │ ├── artistList.html │ │ ├── artistList.js │ │ ├── artistModel.js │ │ ├── artistService.js │ │ ├── biographyFilter.js │ │ └── view.html │ ├── debug │ │ ├── debug.js │ │ └── view.html │ ├── menu │ │ ├── menuCtrl.js │ │ └── view.html │ ├── playlist │ │ ├── playlistCtrl.js │ │ ├── playlistList.html │ │ ├── playlistList.js │ │ ├── playlistModel.js │ │ ├── playlistService.js │ │ └── view.html │ ├── queue │ │ ├── queueCtrl.js │ │ ├── queueList.html │ │ ├── queueList.js │ │ ├── queueModel.js │ │ ├── queueService.js │ │ └── view.html │ ├── quickPlayer │ │ ├── queueCtrl.js │ │ └── view.html │ ├── search │ │ ├── searchCtrl.js │ │ ├── searchModel.js │ │ ├── searchService.js │ │ └── view.html │ ├── status │ │ ├── statusModel.js │ │ └── statusService.js │ └── track │ │ ├── trackList.html │ │ ├── trackList.js │ │ └── trackModel.js ├── run │ ├── favicon.js │ ├── scrollTop.js │ ├── startStatusService.js │ └── visibilityEvents.js └── services │ ├── debounce.js │ ├── elementVisibility.js │ ├── throttle.js │ └── volumeService.js └── styles ├── angular-strap.less ├── app.less ├── bootstrap-variables.less ├── bootstrap.less ├── fontawesome.less ├── slider.less ├── track-timer.less ├── variables.less └── volume.less /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "public/bower_components/" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | public/bower_components 4 | npm-debug.log 5 | dist 6 | .tmp 7 | public/fonts 8 | .netbeans.xml 9 | public/styles/app.css 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:0.10-slim 2 | 3 | RUN apt-get update \ 4 | && apt-get install git ca-certificates -y --no-install-recommends \ 5 | && rm -rf /var/lib/apt/lists* \ 6 | && git clone https://github.com/xemle/spop-web.git /spop-web \ 7 | && cd /spop-web \ 8 | && npm install -g bower gulp-cli \ 9 | && npm install \ 10 | && bower --allow-root install \ 11 | && gulp \ 12 | && apt-get purge -y --auto-remove git ca-certificates 13 | 14 | WORKDIR /spop-web 15 | EXPOSE 3000 16 | CMD ["node", "index.js"] 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Xemle 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # spop web 2 | 3 | Best libSpotify player for the browser suitable for small devices. spop web is 4 | a web interface to [spop](https://github.com/Schnouki/spop) (Spotify client 5 | daemon) as **Advance Spotify Player for [Raspberry 6 | Pi](https://www.raspberrypi.org)** (alternative to 7 | [Volumio](https://volumio.org) or [Rune Audio](http://www.runeaudio.com) UI). 8 | 9 | ![Screenshot](/images/screenshot.png?raw=true "spop-web Queue") 10 | 11 | ## Features 12 | 13 | * Play queue with play, pause, next, and previous 14 | * List and playback of your playlists 15 | * Search tracks, albums, and artists via search or context menu 16 | * Browse tracks, albums, and similar artists from an artists 17 | * Play and append tracks or albums to the play queue 18 | * Cover art on tracks and albums 19 | * Mobile friendly e.g. long press on the cover starts playback of track or album 20 | * Multipe device support: updates are shown on all connected devices 21 | * Volume control ([alsa](http://www.alsa-project.org) only) 22 | 23 | ## Requirements 24 | 25 | * Node >= 0.10 26 | * Running spop daemon (e.g. via Volumio or RuneAudio) 27 | 28 | Install node via your package manager. To install spop, please see its own 29 | [project site](https://github.com/Schnouki/spop). 30 | 31 | If you run spop web on a Raspberry Pi it is also recommended to have an I2C DAC 32 | like HiFi Berry. 33 | 34 | ## Installation 35 | 36 | $ git clone https://github.com/xemle/spop-web.git 37 | $ cd spop-web 38 | $ npm install -g bower gulp-cli 39 | $ npm install 40 | $ bower install 41 | $ gulp 42 | 43 | spop web uses: 44 | 45 | * [Bootstrap](http://getbootstrap.com) 46 | * [FontAwesome](http://fontawesome.io) 47 | * [AngularJS](http://angularjs.org) 48 | * [AngularStrap](http://mgcrea.github.io/angular-strap) 49 | * [Express](http://expressjs.com) 50 | 51 | ## Run 52 | 53 | $ node index.js 54 | 55 | Open your browser at [localhost:3000](http://localhost:3000) 56 | 57 | For further options see `-h`: 58 | 59 | $ node index.js -h 60 | Usage: node index.js 61 | 62 | -d, --debug Turn debug mode on 63 | -p, --port=ARG Port of spop-web server. Default is 3000 64 | --spop-host=ARG Host of spop server. Default is localhost 65 | --spop-port=ARG port of spop server. Default is 6602 66 | --mixer=ARG Name of volume mixer (alsa only) 67 | -h, --help Display this help 68 | 69 | E.g. if spop daemon runs on host `192.168.1.65` start `spop-web` with 70 | 71 | $ node index.js --spop-host 192.168.1.65 72 | 73 | `spop-web` supports also volume control via [alsa](http://www.alsa-project.org). 74 | Enable volume control via `mixer` option and your preferred mixer 75 | 76 | $ node index.js --mixer Master 77 | 78 | ### Run Development Mode 79 | 80 | By default spop-web uses minified versions of css and javascript files. If you 81 | are coding some new feature you can serve un-minified files via: 82 | 83 | $ gulp dev 84 | 85 | This will start the spop-web server at [localhost:3000](http://localhost:3000) 86 | with live reloading. Now you can modify server or client files and the server 87 | restarts or browser reloads automatically on file changes. 88 | 89 | ## With Docker 90 | 91 | If you don't want to install Node / Bower / Gulp on your local machine, you can 92 | do all of that in a Docker container: 93 | 94 | $ docker build -t spop-web . 95 | $ docker run -ti -p 3000:3000 --net=host --rm spop-web 96 | 97 | Then open your browser at [localhost:3000](http://localhost:3000). 98 | 99 | ## Contribute 100 | 101 | Contributions are welcome. To do so, please: 102 | 103 | * fork this project 104 | * create a brunch for your contribution 105 | * submit a pull request 106 | 107 | ## Note on libspotify 108 | 109 | [libspotify](https://developer.spotify.com/technologies/libspotify) is 110 | deprecated since May 2015 and projects like spop and spop-web are doomed. Lets 111 | hope that the libspotify functionality lives long! Read blog entry [Do not use 112 | libspotify](https://jonaslundqvist.net/2015/05/06/do-not-use-libspotify/) from 113 | Jonas Lundqvist. 114 | 115 | We all hope that the deprecated libspotify will playback for a long time. 116 | 117 | ## Licence 118 | 119 | MIT 120 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spop-web", 3 | "main": "index.js", 4 | "version": "1.0.0", 5 | "authors": [ 6 | "Xemle " 7 | ], 8 | "license": "MIT", 9 | "ignore": [ 10 | "**/.*", 11 | "node_modules", 12 | "bower_components", 13 | "test", 14 | "tests" 15 | ], 16 | "dependencies": { 17 | "angular": "~1.4.0", 18 | "angular-route": "~1.4.0", 19 | "angular-sanitize": "~1.4.0", 20 | "bootstrap": "~3.3.4", 21 | "fontawesome": "~4.3.0", 22 | "angular-strap": "~2.2.4", 23 | "bootstrap-additions": "git://github.com/mgcrea/bootstrap-additions#~0.3.1", 24 | "angular-animate": "~1.4.0", 25 | "angular-touch": "~1.4.1", 26 | "favico.js": "~0.3.7" 27 | }, 28 | "resolutions": { 29 | "angular": "1.4.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | useref = require('gulp-useref'), 3 | gulpif = require('gulp-if'), 4 | uglify = require('gulp-uglify'), 5 | annotate = require('gulp-ng-annotate'), 6 | minifyCss = require('gulp-minify-css'), 7 | minifyHtml = require('gulp-minify-html'), 8 | templateCache = require('gulp-angular-templatecache'), 9 | inject = require('gulp-inject'), 10 | debug = require('gulp-debug'), 11 | less = require('gulp-less'), 12 | gutil = require('gulp-util'), 13 | liveServer = require('gulp-live-server'); 14 | 15 | 16 | gulp.task('ng-templates', function() { 17 | return gulp.src('public/scripts/**/*.html') 18 | .pipe(minifyHtml({empty: true})) 19 | .pipe(templateCache('templates.js', {module: 'app', root: 'scripts'})) 20 | .pipe(gulp.dest('.tmp')); 21 | }); 22 | 23 | gulp.task('copyFontsFromBower', function() { 24 | var bowerBase = 'public/bower_components/'; 25 | 26 | return gulp.src([ 27 | bowerBase + 'fontawesome/fonts/*', 28 | bowerBase + 'bootstrap/fonts/*' 29 | ]) 30 | .pipe(gulp.dest('public/fonts')); 31 | }); 32 | 33 | gulp.task('copyDist', ['copyFontsFromBower'], function() { 34 | return gulp.src([ 35 | 'public/favicon.png', 36 | 'public/images/*', 37 | 'public/fonts/*' 38 | ], {base: 'public'}) 39 | .pipe(gulp.dest('dist')); 40 | }); 41 | 42 | gulp.task('less', function() { 43 | return gulp 44 | .src('public/styles/app.less') // This was the line that needed fixing 45 | .pipe(less({ 46 | paths: ['public/styles'] 47 | }).on('error', gutil.log)) 48 | .pipe(gulp.dest('public/styles')); 49 | }); 50 | 51 | gulp.task('html', ['less', 'ng-templates'], function() { 52 | var assets = useref.assets(), 53 | sources = gulp.src('.tmp/templates.js'); 54 | 55 | return gulp.src('public/*.html') 56 | .pipe(inject(sources, {addRootSlash: false})) 57 | .pipe(assets) 58 | .pipe(gulpif('*.js', annotate())) 59 | .pipe(gulpif('*.js', uglify())) 60 | .pipe(gulpif('*.css', minifyCss())) 61 | .pipe(assets.restore()) 62 | .pipe(useref()) 63 | .pipe(gulp.dest('dist')); 64 | }); 65 | 66 | gulp.task('serve', function() { 67 | var server = liveServer('index.js', {env: {NODE_ENV: 'development'}}); 68 | server.start(); 69 | gulp.watch(['public/scripts/**', 'public/styles/*.css'], function() { 70 | server.notify.apply(server, arguments); 71 | }); 72 | gulp.watch(['index.js', 'lib/**'], function() { 73 | server.start.apply(server); 74 | }); 75 | gulp.watch('public/styles/*.less', ['less']); 76 | }); 77 | 78 | gulp.task('dev', ['copyFontsFromBower', 'less', 'serve']); 79 | 80 | gulp.task('dist', ['html', 'copyDist']); 81 | 82 | gulp.task('default', ['dist']); 83 | -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xemle/spop-web/a007f6afcbaf5121b6947fecd354cb7deaf0fdf6/images/screenshot.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var compression = require('compression'); 3 | var spopMiddleware = require('./lib/spop/middleware'); 4 | var app = express(); 5 | 6 | // Defaults 7 | var PORT = 3000; 8 | var SPOP_HOST = 'localhost'; 9 | var SPOP_PORT = 6602; 10 | 11 | var opt = require('node-getopt').create([ 12 | ['d', 'debug', 'Turn debug mode on'], 13 | ['p', 'port=ARG', 'Port of spop-web server. Default is ' + PORT], 14 | ['', 'spop-host=ARG', 'Host of spop server. Default is ' + SPOP_HOST], 15 | ['', 'spop-port=ARG', 'port of spop server. Default is ' + SPOP_PORT], 16 | ['', 'mixer=ARG', 'Name of volume mixer (alsa only)'], 17 | ['h', 'help', 'Display this help'] 18 | ]) 19 | .bindHelp() 20 | .parseSystem(); 21 | 22 | var devMode = opt.options.debug || process.env.NODE_ENV && process.env.NODE_ENV.match(/^dev/i); 23 | var spopOptions = { 24 | host: opt.options['spop-host'] || SPOP_HOST, 25 | port: +opt.options['spop-port'] || SPOP_PORT 26 | }; 27 | 28 | if (!devMode) { 29 | app.use(express.static('dist', {maxAge: '1d'})); 30 | } else { 31 | // Disable caching 32 | app.use(function(req, res, next) { 33 | res.set('Cache-Control', 'no-cache'); 34 | next(); 35 | }); 36 | // Enable live reload. Please use this with gulp serve 37 | var liveOptions = { 38 | port: 35729, 39 | src: 'http://localhost:35729/livereload.js' 40 | }; 41 | app.use(require('connect-livereload')(liveOptions)); 42 | // Serve plain javascript files 43 | var staticOptions = { 44 | etag: false 45 | }; 46 | app.use(express.static('public', staticOptions)); 47 | } 48 | 49 | app.use(compression()); 50 | app.use('/spop', spopMiddleware(spopOptions)); 51 | 52 | if (opt.options.mixer) { 53 | var volumeOptions = { 54 | mixer: opt.options.mixer 55 | }; 56 | app.use('/volume', require('./lib/alsa/volume')(volumeOptions)); 57 | } 58 | 59 | var server = app.listen(+opt.options['port'] || PORT, function () { 60 | var host = server.address().address; 61 | var port = server.address().port; 62 | var mode = devMode ? ' (dev mode)' : ''; 63 | console.log('App listening at http://%s:%s%s', host, port, mode); 64 | }); 65 | -------------------------------------------------------------------------------- /lib/alsa/volume.js: -------------------------------------------------------------------------------- 1 | var router = require('express').Router(); 2 | var spawn = require('child_process').spawn; 3 | 4 | /** 5 | * Spop middelware 6 | * 7 | * @param {Object} options Spop options. See client options 8 | */ 9 | module.exports = function(options) { 10 | 11 | /** 12 | * Extract volume information from amixer output. 13 | * 14 | * Volume information have the format of 15 | * 16 | * Mono: Playback 5 [12%] [-55.50dB] [on] 17 | * 18 | * for mono or 19 | * 20 | * Front Left: Playback 253 [99%] [-0.40dB] 21 | * Front Right: Playback 253 [99%] [-0.40dB] 22 | * 23 | * for stereo 24 | * 25 | * @param {String} data 26 | * @returns {Object} 27 | */ 28 | function extractVolume(data) { 29 | var values = data 30 | .split(/\n/) // split lines 31 | .filter(function(line) { // get lines with left bracket 32 | return line.indexOf('[') > 0; 33 | }) 34 | .map(function(line) { // get percent number 35 | return +line.match(/\[(\d+)%/)[1]; 36 | }); 37 | 38 | // Add value on mono output 39 | if (values.length === 1) { 40 | values.push(values[0]); 41 | } 42 | return { 43 | left: values[0], 44 | right: values[1] 45 | }; 46 | } 47 | 48 | function setVolume(req, res) { 49 | var value = +req.params.value, 50 | out = '', 51 | err = '', 52 | cmd; 53 | 54 | value = Math.max(0, Math.min(100, value)); 55 | cmd = spawn('amixer', ['sset', options.mixer, value + '%']); 56 | cmd.stdout.on('data', function(data) { 57 | out += data.toString(); 58 | }); 59 | cmd.stderr.on('data', function(data) { 60 | err += data.toString(); 61 | }); 62 | cmd.on('close', function(code) { 63 | if (err || code) { 64 | res.status(500).json({status: 500, code: code, msg: err}).end(); 65 | } else { 66 | res.json(extractVolume(out)).end(); 67 | } 68 | }); 69 | } 70 | 71 | function getVolume(req, res) { 72 | var out = '', 73 | err = '', 74 | cmd; 75 | 76 | cmd = spawn('amixer', ['sget', options.mixer]); 77 | cmd.stdout.on('data', function(data) { 78 | out += data.toString(); 79 | }); 80 | cmd.stderr.on('data', function(data) { 81 | err += data.toString(); 82 | }); 83 | cmd.on('close', function(code) { 84 | if (err || code) { 85 | res.status(500).json({status: 500, code: code, msg: err}).end(); 86 | } else { 87 | res.json(extractVolume(out)).end(); 88 | } 89 | }); 90 | } 91 | 92 | router.get('/:value', setVolume); 93 | router.get('/', getVolume); 94 | 95 | /** 96 | * Return volume middleware 97 | */ 98 | return router; 99 | }; 100 | -------------------------------------------------------------------------------- /lib/spop/client.js: -------------------------------------------------------------------------------- 1 | var net = require('net'), 2 | Q = require('q'); 3 | 4 | /** 5 | * Command object to hold spop command line, data buffer, and promise 6 | * 7 | * @param {String} command 8 | * @returns {Command} 9 | */ 10 | function Command(command) { 11 | this.command = command; 12 | this.data = new Buffer(''); 13 | this.promise = Q.defer(); 14 | } 15 | Command.prototype.onData = function(data) { 16 | this.data = Buffer.concat([this.data, data]); 17 | }; 18 | Command.prototype.resolve = function() { 19 | this.promise.resolve(this.data); 20 | }; 21 | Command.prototype.reject = function(err) { 22 | this.promise.reject(err); 23 | }; 24 | Command.prototype.getPromise = function() { 25 | return this.promise.promise; 26 | }; 27 | 28 | /** 29 | * Command Queue to queue multiple spop commands 30 | * 31 | * @param {Object} client 32 | * @returns {CommandQueue} 33 | */ 34 | function CommandQueue(client) { 35 | this.client = client; 36 | this.queue = [new Command('')]; // Used for spop info on connect 37 | this.current = 0; 38 | 39 | client.on('data', this.onData.bind(this)); 40 | client.on('error', this.end.bind(this)); 41 | }; 42 | CommandQueue.prototype.push = function(command) { 43 | var cmd = new Command(command); 44 | this.queue.push(cmd); 45 | return cmd.getPromise(); 46 | }; 47 | CommandQueue.prototype.onData = function(data) { 48 | if (this.current < this.queue.length) { 49 | this.queue[this.current].onData(data); 50 | if (this.isEndOfData(data)) { 51 | this.resolve(); 52 | this.nextCommand(); 53 | } 54 | } 55 | }; 56 | /** 57 | * Treat a tailing new line char as end of data 58 | * 59 | * @param {buffer} data 60 | * @returns {boolean} 61 | */ 62 | CommandQueue.prototype.isEndOfData = function(data) { 63 | var end = data.slice(data.length - 1, data.length); 64 | return end.toString().match(/\n/); 65 | }; 66 | CommandQueue.prototype.resolve = function() { 67 | if (this.current < this.queue.length) { 68 | this.queue[this.current].resolve(); 69 | this.removeHead(); 70 | } 71 | }; 72 | CommandQueue.prototype.reject = function(err) { 73 | if (this.current < this.queue.length) { 74 | this.queue[this.current].reject(err); 75 | this.removeHead(); 76 | } 77 | }; 78 | CommandQueue.prototype.rejectAll = function(err) { 79 | while (this.current < this.queue.length) { 80 | this.reject(); 81 | } 82 | }; 83 | /** 84 | * Remove all head commands to current position. 85 | */ 86 | CommandQueue.prototype.removeHead = function() { 87 | this.queue.splice(0, this.current + 1); 88 | this.current = 0; 89 | }; 90 | CommandQueue.prototype.nextCommand = function() { 91 | if (this.current < this.queue.length) { 92 | this.client.write(this.queue[this.current].command + '\r\n'); 93 | } 94 | }; 95 | CommandQueue.prototype.end = function(err) { 96 | this.client.end(); 97 | this.rejectAll(err); 98 | }; 99 | 100 | /** 101 | * Creates a spop connection an returns a simple interface to send commands 102 | * to spop server. 103 | * 104 | * @param {Object} options Spop server options with host and port. Default host 105 | * is localhost. Default port is 6602. 106 | * @returns {Object} 107 | */ 108 | module.exports = function(options) { 109 | var options = options || {}, 110 | queue, 111 | client; 112 | 113 | options.port = options.port || 6602; 114 | options.host = options.host || 'localhost'; 115 | 116 | client = net.connect(options); 117 | queue = new CommandQueue(client); 118 | 119 | return { 120 | /** 121 | * Sends a command to spop server and return a promise 122 | * 123 | * @param {String} command line 124 | * @param {boolean} lastCommand If true, the connection is automatically 125 | * closed after receiving an answer (or error) 126 | * @returns {Promise} Promise with JSON data 127 | */ 128 | command: function(command, lastCommand) { 129 | return queue.push(command).then(function(data) { 130 | return JSON.parse(data.toString()); 131 | }).finally(function() { 132 | if (lastCommand) { 133 | queue.end(); 134 | } 135 | }); 136 | }, 137 | /** 138 | * End connections (and reject all pending commands) 139 | */ 140 | end: queue.end 141 | }; 142 | }; 143 | -------------------------------------------------------------------------------- /lib/spop/middleware.js: -------------------------------------------------------------------------------- 1 | var client = require('./client'), 2 | router = require('express').Router(), 3 | ms = require('ms'); 4 | 5 | /** 6 | * Spop middelware 7 | * 8 | * @param {Object} options Spop options. See client options 9 | */ 10 | module.exports = function(options) { 11 | 12 | var cachableCommands = 'help,ls,search,uimage,uinfo'.split(','); 13 | 14 | /** 15 | * Add cache control to cachable spop commands 16 | * 17 | * @param {Object} req HTTP Request 18 | * @param {Object} res HTTP Response 19 | * @param {Object} next Next middleware function 20 | */ 21 | function cache(req, res, next) { 22 | var parts = unescape(req.path.substr(1)).split(/(\/|\s)/), 23 | command = parts[0]; 24 | 25 | if (cachableCommands.indexOf(command) < 0) { 26 | return next(); 27 | } else if (command === 'uimage') { 28 | res.set('Cache-Control', 'max-age=' + ms('30 days')); 29 | } else if (command === 'search') { 30 | res.set('Cache-Control', 'max-age=' + ms('1m')); 31 | } else { 32 | res.set('Cache-Control', 'max-age=' + ms('1 days')); 33 | } 34 | next(); 35 | } 36 | 37 | /** 38 | * Serves cover as binary JPEG file 39 | * 40 | * @param {Object} req HTTP Request 41 | * @param {Object} res HTTP Response 42 | */ 43 | function uimage(req, res) { 44 | var uri = req.params.uri, 45 | size = req.params.size ? ' ' + req.params.size : '', 46 | command = 'uimage ' + uri + size; 47 | client(options).command(command, true).then(function(data) { 48 | if (data.status === 'ok') { 49 | res.set('Content-Type', 'image/jpeg'); 50 | 51 | res.end(new Buffer(data.data, 'base64')); 52 | } else { 53 | res.status(500).json({status: 'error', message: 'Invalid response status: ' + data.status}); 54 | } 55 | }, function(err) { 56 | res.status(500).json(err); 57 | }); 58 | } 59 | 60 | /** 61 | * Generic command which serves JSON data 62 | * 63 | * @param {Object} req HTTP Request 64 | * @param {Object} res HTTP Response 65 | */ 66 | function command(req, res) { 67 | var command = unescape(req.path.substr(1)); 68 | client(options).command(command, true).then(function(data) { 69 | res.json(data); 70 | }, function(err) { 71 | res.status(500).json(err); 72 | }); 73 | } 74 | 75 | router.use(cache); 76 | 77 | router.get('/uimage/:uri/:size', uimage); 78 | router.get('/uimage/:uri', uimage); 79 | router.get('*', command); 80 | 81 | /** 82 | * Return middleware router for spop 83 | */ 84 | return router; 85 | }; 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spop-web", 3 | "version": "1.0.0", 4 | "description": "Web interface to spop (spotify client daemon)", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Xemle ", 10 | "license": "MIT", 11 | "repository": { 12 | "type": "git", 13 | "url": "git://github.com/xemle/spop-web.git" 14 | }, 15 | "dependencies": { 16 | "compression": "^1.5.1", 17 | "connect-livereload": "^0.5.3", 18 | "express": "^4.12.4", 19 | "gulp": "^3.9.0", 20 | "gulp-angular-templatecache": "^1.6.0", 21 | "gulp-debug": "^2.0.1", 22 | "gulp-if": "^1.2.5", 23 | "gulp-inject": "^1.2.0", 24 | "gulp-less": "^3.0.3", 25 | "gulp-live-server": "0.0.23", 26 | "gulp-minify-css": "^1.1.4", 27 | "gulp-minify-html": "^1.0.3", 28 | "gulp-ng-annotate": "^1.0.0", 29 | "gulp-uglify": "^1.2.0", 30 | "gulp-useref": "^1.2.0", 31 | "gulp-util": "^3.0.5", 32 | "ms": "^0.7.1", 33 | "node-getopt": "^0.2.3", 34 | "q": "^1.4.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xemle/spop-web/a007f6afcbaf5121b6947fecd354cb7deaf0fdf6/public/favicon.png -------------------------------------------------------------------------------- /public/images/cover.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 59 | 66 | 71 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Spop Web 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /public/scripts/app.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app', [ 3 | 'ngRoute', 4 | 'ngSanitize', 5 | 'ngAnimate', 6 | 'ngTouch', 7 | 'mgcrea.ngStrap.dropdown', 8 | 'mgcrea.ngStrap.aside' 9 | ]); 10 | -------------------------------------------------------------------------------- /public/scripts/config/requestTimes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .config([ 6 | '$provide', 7 | function($provide) { 8 | $provide.decorator('$http', function($delegate) { 9 | var oldGet = $delegate['get']; 10 | $delegate['get'] = function() { 11 | var args = Array.prototype.slice.apply(arguments); 12 | var start = +new Date(); 13 | return oldGet.apply($delegate, args).then(function(result) { 14 | var diff = +new Date() - start; 15 | if (args[0].match(/^\/spop/)) { 16 | console.log(args[0] + ' took ' + diff + 'ms'); 17 | } 18 | return result; 19 | }); 20 | }; 21 | return $delegate; 22 | }); 23 | } 24 | ]); -------------------------------------------------------------------------------- /public/scripts/config/routes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .config([ 6 | '$routeProvider', 7 | function($routeProvider) { 8 | $routeProvider. 9 | when('/album/:uri', { 10 | templateUrl: 'scripts/modules/album/view.html', 11 | controller: 'AlbumCtrl', 12 | resolve: { 13 | album: [ 14 | '$route', 15 | 'AlbumService', 16 | function ($route, AlbumService) { 17 | return AlbumService.get($route.current.params.uri); 18 | } 19 | ] 20 | } 21 | }). 22 | when('/artist/:uri', { 23 | templateUrl: 'scripts/modules/artist/view.html', 24 | controller: 'ArtistCtrl', 25 | resolve: { 26 | artist: [ 27 | '$route', 28 | 'ArtistService', 29 | function($route, ArtistService) { 30 | return ArtistService.get($route.current.params.uri); 31 | } 32 | ] 33 | } 34 | }). 35 | when('/playlists', { 36 | templateUrl: 'scripts/modules/playlist/view.html', 37 | controller: 'PlaylistCtrl', 38 | resolve: { 39 | playlists: [ 40 | 'PlaylistService', 41 | function (PlaylistService) { 42 | return PlaylistService.list(); 43 | } 44 | ], 45 | playlist: [ 46 | '$q', 47 | function ($q) { 48 | return $q.resolve(false); 49 | } 50 | ] 51 | } 52 | }). 53 | when('/playlists/:playlistId', { 54 | templateUrl: 'scripts/modules/playlist/view.html', 55 | controller: 'PlaylistCtrl', 56 | resolve: { 57 | playlists: [ 58 | '$q', 59 | function ($q) { 60 | return $q.resolve(false); 61 | } 62 | ], 63 | playlist: [ 64 | '$route', 65 | 'PlaylistService', 66 | function ($route, PlaylistService) { 67 | return PlaylistService.get($route.current.params.playlistId); 68 | } 69 | ] 70 | } 71 | }). 72 | when('/queue', { 73 | templateUrl: 'scripts/modules/queue/view.html', 74 | controller: 'QueueCtrl', 75 | resolve: { 76 | queue: [ 77 | 'QueueService', 78 | function (QueueService) { 79 | return QueueService.get(); 80 | } 81 | ], 82 | status: [ 83 | 'StatusService', 84 | function(StatusService) { 85 | return StatusService.status(); 86 | } 87 | ] 88 | } 89 | }). 90 | when('/search', { 91 | templateUrl: 'scripts/modules/search/view.html', 92 | controller: 'SearchCtrl', 93 | resolve: { 94 | search: [ 95 | '$location', 96 | 'SearchService', 97 | function ($location, SearchService) { 98 | var term = $location.search().q || ''; 99 | return SearchService.search(term); 100 | } 101 | ] 102 | } 103 | }). 104 | when('/debug', { 105 | templateUrl: 'scripts/modules/debug/view.html', 106 | controller: 'DebugCtrl' 107 | }). 108 | otherwise({ 109 | redirectTo: '/playlists' 110 | }); 111 | }]); -------------------------------------------------------------------------------- /public/scripts/directives/dropdown.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/scripts/directives/dropdown.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('dropdown', [ 6 | '$timeout', 7 | '$dropdown', 8 | function($timeout, $dropdown) { 9 | return { 10 | scope: { 11 | data: '=dropdown', 12 | items: '=dropdownItems', 13 | itemClick: '&dropdownItemClick' 14 | }, 15 | link: function(scope, element) { 16 | var dropdown = false; 17 | 18 | element.on('click', function() { 19 | scope.$apply(function() { 20 | scope.showDropdown(); 21 | }); 22 | }); 23 | 24 | scope.destroyDropdown = function() { 25 | if (dropdown) { 26 | dropdown.hide(); 27 | dropdown.destroy(); 28 | } 29 | }; 30 | scope.showDropdown = function($event) { 31 | var options = { 32 | container: 'body', 33 | template: 'scripts/directives/dropdown.html', 34 | show: true, 35 | trigger: 'manual', 36 | placement: 'auto bottom-left' 37 | }; 38 | if (!dropdown) { 39 | dropdown = $dropdown(element, options); 40 | dropdown.$scope.items = scope.items; 41 | dropdown.$scope.itemClick = function(item) { 42 | dropdown.hide(); 43 | scope.itemClick({data: scope.data, item: item}); 44 | }; 45 | dropdown.$scope.$on('$destroy', function() { 46 | dropdown = false; 47 | }); 48 | dropdown.$scope.$watch('$isShown', function(newVal, oldVal) { 49 | if (newVal === false && oldVal === true) { 50 | $timeout(function() { 51 | dropdown.destroy(); 52 | }); 53 | } 54 | }); 55 | } else { 56 | dropdown.hide(); 57 | } 58 | }; 59 | } 60 | }; 61 | } 62 | ]); 63 | 64 | -------------------------------------------------------------------------------- /public/scripts/directives/loadSrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('loadSrc', [ 6 | function() { 7 | return { 8 | restrict: 'A', 9 | link: function(scope, element, attrs) { 10 | function updateImage(url) { 11 | var $img = angular.element(''); 12 | $img.attr('src', url); 13 | $img.on('load', function() { 14 | element.attr('src', url); 15 | }); 16 | } 17 | 18 | scope.$watch(function() { 19 | return attrs.loadSrc; 20 | }, function(src) { 21 | if (src) { 22 | updateImage(src); 23 | } 24 | }); 25 | } 26 | }; 27 | } 28 | ]); -------------------------------------------------------------------------------- /public/scripts/directives/longClick.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('longClick', [ 6 | '$timeout', 7 | '$parse', 8 | '$filter', 9 | function($timeout, $parse, $filter) { 10 | return { 11 | link: function(scope, $element, $attrs) { 12 | var WAIT_INTERVAL = 500, 13 | MAX_MOVE_DELTA = 10, 14 | longClickExp = $parse($attrs.longClick), 15 | startPoint, 16 | timer; 17 | 18 | function start($event) { 19 | if (timer) { 20 | return eventBlackhole($event); 21 | } 22 | startPoint = $filter('coordinates')($event); 23 | timer = $timeout(function() { 24 | longClickExp(scope); 25 | timer = false; 26 | }, WAIT_INTERVAL); 27 | return eventBlackhole($event); 28 | } 29 | 30 | function cancel($event) { 31 | $timeout.cancel(timer); 32 | timer = false; 33 | return eventBlackhole($event); 34 | } 35 | 36 | /** 37 | * Calculates distance from start and cancel on maximum distance 38 | * 39 | * @param {event} $event 40 | */ 41 | function move($event){ 42 | if (!timer) { 43 | return eventBlackhole($event); 44 | } 45 | 46 | var point = $filter('coordinates')($event), 47 | deltaX = startPoint.x - point.x, 48 | deltaY = startPoint.y - point.y, 49 | delta = Math.sqrt(deltaX * deltaX + deltaY * deltaY); 50 | 51 | if (delta > MAX_MOVE_DELTA) { 52 | return cancel($event); 53 | } 54 | return eventBlackhole($event); 55 | } 56 | 57 | // http://stackoverflow.com/questions/3413683/disabling-the-context-menu-on-long-taps-on-android 58 | function eventBlackhole($event) { 59 | if (!$event) { 60 | return false; 61 | } 62 | $event.preventDefault && $event.preventDefault(); 63 | $event.stopPropagation && $event.stopPropagation(); 64 | $event.cancelBubble = true; 65 | $event.returnValue = false; 66 | return false; 67 | } 68 | 69 | $element.on('mousedown touchstart', start); 70 | $element.on('mousemove touchmove', move); 71 | $element.on('mouseleave mouseup touchend', cancel); 72 | } 73 | }; 74 | } 75 | ]); -------------------------------------------------------------------------------- /public/scripts/directives/slider.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | -------------------------------------------------------------------------------- /public/scripts/directives/slider.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .directive('slider', [ 4 | '$document', 5 | '$filter', 6 | function($document, $filter) { 7 | return { 8 | scope: { 9 | max: '=sliderMax', 10 | position: '=sliderPosition', 11 | onSlide: '&', 12 | onSlideEnd: '&' 13 | }, 14 | templateUrl: 'scripts/directives/slider.html', 15 | link: function(scope, $element) { 16 | var handle = angular.element($element.find('div')[4]), 17 | body = angular.element($document[0].body), 18 | rect; 19 | 20 | function getRect() { 21 | rect = $element[0].getBoundingClientRect(); 22 | } 23 | 24 | function getPercent(point) { 25 | var x = Math.min(Math.max(rect.left, point.x), rect.right); 26 | 27 | return 100 * (x - rect.left) / (rect.right - rect.left); 28 | } 29 | 30 | function activate() { 31 | body.on('mousemove touchmove', onMove); 32 | body.on('mouseup touchend', deactivate); 33 | // Get every time the rect to cover window resizes, too 34 | getRect(); 35 | }; 36 | 37 | function onMove($event) { 38 | var point = $filter('coordinates')($event), 39 | percent = getPercent(point); 40 | 41 | $event.preventDefault(); 42 | 43 | scope.$apply(function() { 44 | scope.percent = percent.toFixed(2); 45 | scope.onSlide({percent: percent}); 46 | }); 47 | 48 | return false; 49 | }; 50 | 51 | function deactivate($event) { 52 | var point = $filter('coordinates')($event), 53 | percent = getPercent(point); 54 | 55 | body.off('mousemove touchmove', onMove); 56 | body.off('mouseup touchend', deactivate); 57 | $event.preventDefault(); 58 | 59 | scope.$apply(function() { 60 | scope.percent = percent.toFixed(2); 61 | scope.onSlideEnd({percent: percent}); 62 | }); 63 | 64 | return false; 65 | }; 66 | 67 | handle.on('mousedown', activate); 68 | handle.on('touchstart', activate); 69 | 70 | scope.$watch('position', function(newPosition) { 71 | scope.percent = (100 * newPosition / scope.max).toFixed(2); 72 | }); 73 | } 74 | }; 75 | } 76 | ]); 77 | -------------------------------------------------------------------------------- /public/scripts/directives/trackTimer.html: -------------------------------------------------------------------------------- 1 |
2 |
{{ getTime() | timeFormat }}
3 |
4 |
{{ duration | timeFormat }}
5 |
6 |
-------------------------------------------------------------------------------- /public/scripts/directives/trackTimer.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .directive('trackTimer', [ 4 | '$interval', 5 | '$q', 6 | function($interval, $q) { 7 | return { 8 | scope: { 9 | status: '=trackTimer', 10 | onSeek: '&' 11 | }, 12 | templateUrl: 'scripts/directives/trackTimer.html', 13 | link: function(scope) { 14 | var MODE_POSITION = 0, 15 | MODE_REMAINING = 1, 16 | MODE_MAX = 2, 17 | mode = MODE_POSITION, 18 | fps = 12, 19 | timer; 20 | 21 | function setTime() { 22 | if (!scope.status) { 23 | scope.position = 0; 24 | scope.duration = 0; 25 | scope.remaining = 0; 26 | } else { 27 | scope.position = scope.status.getTime(); 28 | scope.duration = scope.status.duration; 29 | scope.remaining = scope.status.getRemainingTime(); 30 | } 31 | } 32 | 33 | function setTimer() { 34 | timer = $interval(setTime, 1000 / fps); 35 | } 36 | 37 | scope.getTime = function() { 38 | if (mode === MODE_POSITION) { 39 | return scope.position; 40 | } else if (mode === MODE_REMAINING) { 41 | return -1 * scope.remaining; 42 | } else { 43 | return 0; 44 | } 45 | }; 46 | 47 | scope.changeTimeMode = function() { 48 | mode = (mode + 1) % MODE_MAX; 49 | }; 50 | 51 | scope.onSlide = function(percent) { 52 | $interval.cancel(timer); 53 | scope.position = scope.duration * percent / 100; 54 | scope.remaining = scope.duration - scope.position; 55 | }; 56 | 57 | scope.onSlideEnd = function(percent) { 58 | scope.position = scope.duration * percent / 100; 59 | scope.remaining = scope.duration - scope.position; 60 | $q.resolve(scope.onSeek({position: scope.position})).then(function() { 61 | if (scope.status && scope.status.isPlaying()) { 62 | setTimer(); 63 | } 64 | }); 65 | }; 66 | 67 | // Update time on position changes 68 | scope.$watch(function() { 69 | return scope.status && scope.status.position; 70 | }, setTime()); 71 | // Update time on duration changes 72 | scope.$watch(function() { 73 | return scope.status && scope.status.duration; 74 | }, setTime()); 75 | 76 | // Enable or disable timer on playing status 77 | scope.$watch(function() { 78 | return scope.status && scope.status.isPlaying(); 79 | }, function (newValue) { 80 | if (newValue) { 81 | setTimer(); 82 | } else { 83 | $interval.cancel(timer); 84 | } 85 | }); 86 | scope.$on('$destroy', function() { 87 | $interval.cancel(timer); 88 | }); 89 | } 90 | }; 91 | } 92 | ]); 93 | -------------------------------------------------------------------------------- /public/scripts/directives/visible.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .directive('visible', [ 4 | '$parse', 5 | '$elementVisibility', 6 | function($parse, $elementVisibility) { 7 | return { 8 | link: function(scope, $element, attrs) { 9 | var showExp = $parse(attrs.visible || attrs.onShow), 10 | hideExp = $parse(attrs.onHide), 11 | optionsExpr = $parse(attrs.visibleOptions), 12 | options = optionsExpr(scope) || {}, 13 | element = $element[0]; 14 | 15 | function onShow() { 16 | showExp(scope); 17 | } 18 | function onHide() { 19 | hideExp(scope); 20 | } 21 | 22 | $elementVisibility.add(element, onShow, onHide, options); 23 | 24 | scope.$on('$destroy', function() { 25 | $elementVisibility.remove(element); 26 | }); 27 | } 28 | }; 29 | } 30 | ]); 31 | -------------------------------------------------------------------------------- /public/scripts/filters/coordinates.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | /** 6 | * Filter to get mouse and touch coordinates from $event 7 | */ 8 | .filter('coordinates', [ 9 | function() { 10 | //! coordinates filter functionality borrowed from ng-touch: https://github.com/angular/angular.js/blob/master/src/ngTouch/swipe.js getCoordinates() 11 | return function($event) { 12 | var originalEvent = $event.originalEvent || $event; 13 | var touches = originalEvent.touches && originalEvent.touches.length ? originalEvent.touches : [originalEvent]; 14 | var e = (originalEvent.changedTouches && originalEvent.changedTouches[0]) || touches[0]; 15 | 16 | return { 17 | x: e.clientX, 18 | y: e.clientY 19 | }; 20 | }; 21 | } 22 | ]); 23 | 24 | -------------------------------------------------------------------------------- /public/scripts/filters/timeFormat.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .filter('timeFormat', [ 6 | function() { 7 | function prefix(s, len, char) { 8 | s = '' + s; 9 | len = len || 2; 10 | char = char || '0'; 11 | while (s.length < len){ 12 | s = char + s; 13 | } 14 | return s; 15 | } 16 | return function(input) { 17 | var time, negPrefix, sec, min, hour; 18 | time = Math.abs((+input).toFixed(0)); 19 | sec = time % 60; 20 | min = ((time - sec) / 60) % 60; 21 | hour = (time - sec - min * 60) / 3600; 22 | negPrefix = (+input) < 0 ? '-' : ''; 23 | 24 | if (angular.isUndefined(input)) { 25 | return ''; 26 | } else if (hour > 0) { 27 | return negPrefix + hour + ':' + prefix(min) + ':' + prefix(sec); 28 | } else { 29 | return negPrefix + min + ':' + prefix(sec); 30 | } 31 | }; 32 | } 33 | ]); 34 | 35 | -------------------------------------------------------------------------------- /public/scripts/modules/album/albumCtrl.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .controller('AlbumCtrl', [ 6 | '$scope', 7 | 'QueueService', 8 | 'album', 9 | function($scope, QueueService, album) { 10 | $scope.album = album; 11 | 12 | $scope.playAlbum = function(album) { 13 | QueueService.playAlbum(album); 14 | }; 15 | $scope.addAlbum = function(album) { 16 | QueueService.addAlbum(album); 17 | }; 18 | 19 | } 20 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/album/albumList.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 13 | 14 |
4 | 5 | 7 |

{{ album.title }}
8 | {{ album.artist }}

9 |
11 | 12 |
-------------------------------------------------------------------------------- /public/scripts/modules/album/albumList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('albumList', [ 6 | '$location', 7 | 'QueueService', 8 | function($location, QueueService) { 9 | return { 10 | scope: { 11 | albums: '=albumList' 12 | }, 13 | templateUrl: 'scripts/modules/album/albumList.html', 14 | link: function(scope) { 15 | scope.items = [ 16 | { icon: 'fa-play', action: 'play', text: 'Play' }, 17 | { icon: 'fa-plus', action: 'append', text: 'Append' }, 18 | { icon: 'fa-music', action: 'title', text: 'Search title' }, 19 | { icon: 'fa-user', action: 'artist', text: 'Search artist' } 20 | ]; 21 | scope.itemClick = function(album, item) { 22 | if (item.action === 'play') { 23 | scope.playAlbum(album); 24 | } else if (item.action === 'append') { 25 | scope.addAlbum(album); 26 | } else if (item.action === 'title') { 27 | $location.search({q: album.title}); 28 | $location.path('/search'); 29 | } else if (item.action === 'artist') { 30 | $location.search({q: album.artist}); 31 | $location.path('/search'); 32 | } 33 | }; 34 | 35 | scope.showAlbum = function(album) { 36 | $location.search({}); 37 | $location.path('/album/' + album.uri); 38 | }; 39 | scope.playAlbum = function(album) { 40 | QueueService.playAlbum(album); 41 | }; 42 | scope.addAlbum = function(album) { 43 | return QueueService.addAlbum(album); 44 | }; 45 | 46 | } 47 | }; 48 | } 49 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/album/albumModel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('AlbumModel', [ 6 | 'TrackModel', 7 | function (TrackModel) { 8 | function AlbumModel(data) { 9 | angular.forEach(data, function(value, key) { 10 | if (key === 'tracks') { 11 | value = TrackModel.createList(value); 12 | } 13 | this[key] = value; 14 | }, this); 15 | } 16 | 17 | AlbumModel.createList = function(albums) { 18 | var result = []; 19 | angular.forEach(albums, function(album) { 20 | result.push(new AlbumModel(album)); 21 | }); 22 | return result; 23 | }; 24 | 25 | return AlbumModel; 26 | } 27 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/album/albumService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('AlbumService', [ 6 | '$http', 7 | 'AlbumModel', 8 | function($http, AlbumModel) { 9 | return { 10 | get: function(uri) { 11 | return $http.get('/spop/uinfo ' + uri).then(function (response) { 12 | var data = angular.extend({uri: uri}, response.data); 13 | return new AlbumModel(data); 14 | }); 15 | } 16 | }; 17 | } 18 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/album/view.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |

{{ album.title }}

7 | 8 |

{{ album.artist }} ({{ album.year }}), {{ album.tracks.length }} tracks

9 | 10 |

11 | 12 | 13 |

14 |
15 |
16 | 17 |
18 | 19 |
-------------------------------------------------------------------------------- /public/scripts/modules/artist/artistCtrl.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .controller('ArtistCtrl', [ 6 | '$scope', 7 | 'artist', 8 | function($scope, artist) { 9 | $scope.artist = artist; 10 | $scope.show = 'tracks'; 11 | } 12 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/artist/artistList.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 |
4 | {{artist.artist}} 5 |
-------------------------------------------------------------------------------- /public/scripts/modules/artist/artistList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('artistList', [ 6 | '$location', 7 | function($location) { 8 | return { 9 | scope: { 10 | artists: '=artistList' 11 | }, 12 | templateUrl: 'scripts/modules/artist/artistList.html', 13 | link: function(scope) { 14 | scope.showArtist = function(artist) { 15 | $location.search({}); 16 | $location.path('/artist/' + artist.uri); 17 | }; 18 | } 19 | }; 20 | } 21 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/artist/artistModel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('ArtistModel', [ 6 | 'TrackModel', 7 | 'AlbumModel', 8 | function (TrackModel, AlbumModel) { 9 | function ArtistModel(data) { 10 | angular.forEach(data, function(value, key) { 11 | if (key === 'tracks') { 12 | value = TrackModel.createList(value); 13 | value.sort(function(a, b) { 14 | return b.popularity - a.popularity; 15 | }); 16 | } else if (key === 'albums') { 17 | value = AlbumModel.createList(value); 18 | value.sort(function(a, b) { 19 | return b.popularity - a.popularity; 20 | }); 21 | } 22 | this[key] = value; 23 | }, this); 24 | this.tracks = (this.tracks || []).slice(0, 100); 25 | } 26 | 27 | ArtistModel.createList = function(artists) { 28 | var result = []; 29 | angular.forEach(artists, function(artist) { 30 | result.push(new ArtistModel(artist)); 31 | }); 32 | return result; 33 | }; 34 | 35 | return ArtistModel; 36 | } 37 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/artist/artistService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('ArtistService', [ 6 | '$http', 7 | 'ArtistModel', 8 | function($http, ArtistModel) { 9 | return { 10 | get: function(uri) { 11 | return $http.get('/spop/uinfo ' + uri).then(function (response) { 12 | var data = angular.extend({uri: uri}, response.data); 13 | return new ArtistModel(data); 14 | }); 15 | } 16 | }; 17 | } 18 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/artist/biographyFilter.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .filter('shortBiography', [ 6 | function() { 7 | return function(input) { 8 | var sentences = input.split(/\.\s/); 9 | return sentences.splice(0, Math.min(4, sentences.length)).join('. '); 10 | }; 11 | } 12 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/artist/view.html: -------------------------------------------------------------------------------- 1 |

{{ artist.artist }}

2 | 3 |
4 |
5 | 6 |
7 |
8 | 9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 | 17 |
18 | 19 |
20 | 21 |
22 | -------------------------------------------------------------------------------- /public/scripts/modules/debug/debug.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .controller('DebugCtrl', [ 4 | '$scope', 5 | '$http', 6 | function($scope, $http) { 7 | $scope.command = 'help'; 8 | $scope.execute = function() { 9 | $http.get('/spop/' + $scope.command).then(function(response) { 10 | $scope.data = JSON.stringify(response.data, 0, 2); 11 | }); 12 | }; 13 | } 14 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/debug/view.html: -------------------------------------------------------------------------------- 1 |

Spopd Debug

2 | 3 | 6 | 7 |
8 |
9 | 10 | 11 |
12 | 13 |
14 | 15 |

Result

16 |



--------------------------------------------------------------------------------
/public/scripts/modules/menu/menuCtrl.js:
--------------------------------------------------------------------------------
 1 | "use strict";
 2 | 
 3 | angular
 4 |   .module('app')
 5 |   .controller('MenuCtrl', [
 6 |     '$scope',
 7 |     '$location',
 8 |     'throttle',
 9 |     'VolumeService',
10 |     function($scope, $location, throttle, VolumeService) {
11 |       $scope.open = function(path) {
12 |         $scope.$hide();
13 |         $location.path(path);
14 |       };
15 |       function setVolume(volume) {
16 |         $scope.volume = volume.left;
17 |       }
18 |       VolumeService.get().then(setVolume);
19 | 
20 |       var throttledVolumeSet = throttle(function(percent) {
21 |         VolumeService.set(percent);
22 |       }, 200);
23 | 
24 |       $scope.onSlide = function(percent) {
25 |         throttledVolumeSet(percent);
26 |       };
27 |       $scope.onSlideEnd = function(percent) {
28 |         VolumeService.set(percent).then(setVolume);
29 |       };
30 |     }
31 |   ]);


--------------------------------------------------------------------------------
/public/scripts/modules/menu/view.html:
--------------------------------------------------------------------------------
 1 | 


--------------------------------------------------------------------------------
/public/scripts/modules/playlist/playlistCtrl.js:
--------------------------------------------------------------------------------
 1 | "use strict";
 2 | 
 3 | angular
 4 |   .module('app')
 5 |   .controller('PlaylistCtrl', [
 6 |     '$scope',
 7 |     '$location',
 8 |     'PlaylistService',
 9 |     'QueueService',
10 |     'playlists',
11 |     'playlist',
12 |     function($scope, $location, PlaylistService, QueueService, playlists, playlist) {
13 |       $scope.playlists = playlists;
14 |       $scope.playlist = playlist;
15 | 
16 |       $scope.play = function() {
17 |         PlaylistService.play($scope.playlist);
18 |       };
19 |     }
20 |   ]);


--------------------------------------------------------------------------------
/public/scripts/modules/playlist/playlistList.html:
--------------------------------------------------------------------------------
 1 | 
 2 |   
 3 |     
 6 |     
 9 |   
10 | 
4 | {{ playlist.name }} 5 | 7 | 8 |
-------------------------------------------------------------------------------- /public/scripts/modules/playlist/playlistList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('playlistList', [ 6 | '$location', 7 | 'PlaylistService', 8 | function($location, PlaylistService) { 9 | return { 10 | scope: { 11 | playlists: '=playlistList' 12 | }, 13 | templateUrl: 'scripts/modules/playlist/playlistList.html', 14 | link: function(scope) { 15 | scope.items = [ 16 | { icon: 'fa-play', action: 'play', text: 'Play' }, 17 | { icon: 'fa-plus', action: 'add', text: 'Add' } 18 | ]; 19 | scope.itemClick = function(playlist, item) { 20 | if (item.action === 'play') { 21 | PlaylistService.play(playlist); 22 | } else if (item.action === 'add') { 23 | PlaylistService.add(playlist); 24 | } 25 | }; 26 | scope.openPlaylist = function(playlist) { 27 | $location.path('/playlists/' + playlist.index); 28 | }; 29 | 30 | } 31 | }; 32 | } 33 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/playlist/playlistModel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('PlaylistModel', [ 6 | 'TrackModel', 7 | function (TrackModel) { 8 | function PlaylistModel(data) { 9 | angular.forEach(data, function(value, key) { 10 | if (key === 'tracks') { 11 | value = TrackModel.createList(value); 12 | } 13 | this[key] = value; 14 | }, this); 15 | this.name = this.name || 'Starred'; 16 | } 17 | 18 | return PlaylistModel; 19 | } 20 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/playlist/playlistService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('PlaylistService', [ 6 | '$http', 7 | 'PlaylistModel', 8 | function($http, PlaylistModel) { 9 | return { 10 | get: function(playlistId) { 11 | return $http.get('/spop/ls ' + playlistId).then(function (response) { 12 | // Extend response data with playlist index 13 | var data = angular.extend({index: playlistId}, response.data); 14 | return new PlaylistModel(data); 15 | }); 16 | }, 17 | play: function(playlist) { 18 | return $http.get('/spop/play ' + playlist.index); 19 | }, 20 | add: function(playlist) { 21 | return $http.get('/spop/add ' + playlist.index); 22 | }, 23 | list: function() { 24 | return $http.get('/spop/ls').then(function(response) { 25 | var result = []; 26 | angular.forEach(response.data.playlists || [], function(data) { 27 | result.push(new PlaylistModel(data)); 28 | }); 29 | return result; 30 | }); 31 | } 32 | }; 33 | } 34 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/playlist/view.html: -------------------------------------------------------------------------------- 1 |

Playlists

2 |

{{playlist.name}}

3 | 4 | List Playlists 5 | 6 |
7 |
-------------------------------------------------------------------------------- /public/scripts/modules/queue/queueCtrl.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .controller('QueueCtrl', [ 6 | '$rootScope', 7 | '$scope', 8 | 'QueueService', 9 | 'StatusService', 10 | 'queue', 11 | 'status', 12 | function($rootScope, $scope, QueueService, StatusService, queue, status) { 13 | $scope.queue = queue; 14 | 15 | $scope.prev = QueueService.prev; 16 | $scope.toggle = QueueService.toggle; 17 | $scope.next = QueueService.next; 18 | $scope.toggleShuffle = QueueService.toggleShuffle; 19 | $scope.toggleRepeat = QueueService.toggleRepeat; 20 | 21 | function reloadQueue() { 22 | return QueueService.get().then(function(queue) { 23 | $scope.queue = queue; 24 | return queue; 25 | }); 26 | } 27 | 28 | $scope.clear = function() { 29 | return QueueService.clear().then(function() { 30 | return reloadQueue(); 31 | }); 32 | }; 33 | 34 | $scope.onSeek = function(position) { 35 | return QueueService.seek(position * 1000); 36 | }; 37 | 38 | function setStatus(status) { 39 | $scope.status = status; 40 | $scope.currentTrack = status.current_track; 41 | } 42 | setStatus(status); 43 | 44 | $scope.$on('$destroy', $rootScope.$on('status:change', function(event, status) { 45 | setStatus(status); 46 | })); 47 | $scope.$on('$destroy', $rootScope.$on('queue:change', function() { 48 | reloadQueue(); 49 | })); 50 | $scope.$on('$destroy', $rootScope.$on('visibility:change', function(event, isHidden) { 51 | if (!isHidden) { 52 | reloadQueue(); 53 | StatusService.status().then(setStatus); 54 | } 55 | })); 56 | 57 | } 58 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/queue/queueList.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 13 | 14 |
4 | 5 | 7 |

{{ track.title }} - {{ track.duration | timeFormat }}
8 | {{ track.artist }} - {{ track.album }}

9 |
11 | 12 |
-------------------------------------------------------------------------------- /public/scripts/modules/queue/queueList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('queueList', [ 6 | '$rootScope', 7 | '$location', 8 | 'StatusService', 9 | 'QueueService', 10 | function($rootScope, $location, StatusService, QueueService) { 11 | return { 12 | scope: { 13 | tracks: '=queueList' 14 | }, 15 | templateUrl: 'scripts/modules/queue/queueList.html', 16 | link: function(scope) { 17 | scope.itemClick = function(data, item) { 18 | scope.trackMenuClick(data, item); 19 | }; 20 | scope.items = [ 21 | { icon: 'fa-play', action: 'play', text: 'Play' }, 22 | { icon: 'fa-music', action: 'title', text: 'Search title' }, 23 | { icon: 'fa-user', action: 'artist', text: 'Search artist' }, 24 | { icon: 'fa-play-circle', action: 'album', text: 'Search album' }, 25 | { icon: 'fa-trash-o', action: 'remove', text: 'Remove' } 26 | ]; 27 | scope.itemClick = function(track, item) { 28 | if (item.action === 'play') { 29 | QueueService.goto(track); 30 | } else if (item.action === 'remove') { 31 | QueueService.removeTrack(track); 32 | } else if (item.action === 'title') { 33 | $location.search({q: 'track:\'' + track.title + '\''}); 34 | $location.path('/search'); 35 | } else if (item.action === 'artist') { 36 | $location.search({q: 'artist:\'' + track.artist.replace(/,.*/, '') + '\''}); 37 | $location.path('/search'); 38 | } else if (item.action === 'album') { 39 | $location.search({q: 'album:\'' + track.album + '\''}); 40 | $location.path('/search'); 41 | } 42 | }; 43 | 44 | scope.trackClass = function(track) { 45 | if (scope.status && scope.status.isPlaying() && scope.status.uri === track.uri) { 46 | return {active: true}; 47 | } 48 | return {}; 49 | }; 50 | scope.play = function(track) { 51 | QueueService.playTrack(track); 52 | }; 53 | 54 | scope.$on('$destroy', $rootScope.$on('status:change', function(event, status) { 55 | scope.status = status; 56 | })); 57 | StatusService.status().then(function(status) { 58 | scope.status = status; 59 | }); 60 | } 61 | }; 62 | } 63 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/queue/queueModel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('QueueModel', [ 6 | 'TrackModel', 7 | function (TrackModel) { 8 | function QueueModel(data) { 9 | angular.forEach(data, function(value, key) { 10 | if (key === 'tracks') { 11 | value = TrackModel.createList(value); 12 | } 13 | this[key] = value; 14 | }, this); 15 | } 16 | 17 | return QueueModel; 18 | } 19 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/queue/queueService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('QueueService', [ 6 | '$rootScope', 7 | '$http', 8 | '$q', 9 | 'StatusService', 10 | 'QueueModel', 11 | 'StatusModel', 12 | function($rootScope, $http, $q, StatusService, QueueModel, StatusModel) { 13 | return { 14 | get: function() { 15 | return $http.get('/spop/qls').then(function(response) { 16 | return new QueueModel(response.data); 17 | }); 18 | }, 19 | prev: function() { 20 | return $http.get('/spop/prev'); 21 | }, 22 | stop: function() { 23 | return $http.get('/spop/stop'); 24 | }, 25 | play: function() { 26 | return $http.get('/spop/play'); 27 | }, 28 | toggle: function() { 29 | return $http.get('/spop/toggle'); 30 | }, 31 | seek: function(position) { 32 | return $http.get('/spop/seek ' + position.toFixed(0)).then(function(response) { 33 | return new StatusModel(response.data); 34 | }); 35 | }, 36 | next: function() { 37 | return $http.get('/spop/next'); 38 | }, 39 | goto: function(track) { 40 | return $http.get('/spop/goto ' + track.index).then(function(response) { 41 | return new StatusModel(response.data); 42 | }); 43 | }, 44 | clear: function() { 45 | return $http.get('/spop/qclear'); 46 | }, 47 | toggleRepeat: function() { 48 | return $http.get('/spop/repeat').then(function(response) { 49 | return new StatusModel(response.data); 50 | }); 51 | }, 52 | toggleShuffle: function() { 53 | return $http.get('/spop/shuffle').then(function(response) { 54 | return new StatusModel(response.data); 55 | }); 56 | }, 57 | addTrack: function(track) { 58 | return $http.get('/spop/uadd ' + track.uri).then(function(response) { 59 | $rootScope.$emit('queue:change'); 60 | return new StatusModel(response.data); 61 | }); 62 | }, 63 | addTracks: function(tracks) { 64 | var promises = []; 65 | 66 | angular.forEach(tracks, function(track) { 67 | promises.push($http.get('/spop/uadd ' + track.uri)); 68 | }, this); 69 | return $q.all(promises).then(function(result) { 70 | $rootScope.$emit('queue:change'); 71 | return result; 72 | }); 73 | }, 74 | playTrack: function(track) { 75 | var _this = this; 76 | return this.get().then(function(queue) { 77 | var queueTrack = queue.tracks.filter(function(t) { 78 | return t.uri === track.uri; 79 | }).pop(); 80 | if (queueTrack) { 81 | return _this.goto(queueTrack).then(function(queueStatus) { 82 | if (!queueStatus.isPlaying()) { 83 | return _this.toggle(); 84 | } 85 | }); 86 | } else { 87 | // Track is not in the queue. We add and play it 88 | _this.addTrack(track).then(function() { 89 | _this.playTrack(track); 90 | }); 91 | } 92 | }); 93 | }, 94 | removeTrack: function(track) { 95 | return this.get().then(function(queue) { 96 | var queueTrack = queue.tracks.filter(function(t) { 97 | return t.uri === track.uri; 98 | }).pop(); 99 | if (queueTrack) { 100 | return $http.get('/spop/qrm ' + queueTrack.index).then(function(response) { 101 | $rootScope.$emit('queue:change'); 102 | return response; 103 | }); 104 | } 105 | }); 106 | }, 107 | moveTrackNext: function(track) { 108 | return this.get().then(function(queue) { 109 | var queueTrack = queue.tracks.filter(function(t) { 110 | return t.uri === track.uri; 111 | }).pop(); 112 | if (queueTrack) { 113 | return StatusService.status().then(function(status) { 114 | var next = status.current_track || 1; 115 | return $http.get('/spop/qrm ' + queueTrack.index + ' ' + (next + 1)).then(function(response) { 116 | $rootScope.$emit('queue:change'); 117 | return response; 118 | }); 119 | }); 120 | } 121 | }); 122 | }, 123 | addAlbum: function(album) { 124 | return $http.get('/spop/uadd ' + album.uri).then(function(response) { 125 | $rootScope.$emit('queue:change'); 126 | return response; 127 | }); 128 | }, 129 | playAlbum: function(album) { 130 | return $http.get('/spop/uplay ' + album.uri).then(function(response) { 131 | $rootScope.$emit('queue:change'); 132 | return response; 133 | }); 134 | } 135 | }; 136 | } 137 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/queue/view.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |

Queue

7 |

{{ status.title || '' }}

8 |

{{ status.artist }} - {{ status.album }}

9 |
10 |
11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 |
25 |
26 | 27 |
28 | 29 |

Queue list is empty

30 | 31 |
-------------------------------------------------------------------------------- /public/scripts/modules/quickPlayer/queueCtrl.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .controller('QuickPlayerCtrl', [ 6 | '$rootScope', 7 | '$scope', 8 | '$aside', 9 | 'QueueService', 10 | 'StatusService', 11 | function($rootScope, $scope, $aside, QueueService, StatusService) { 12 | $scope.status = {}; 13 | QueueService.get().then(function(queue) { 14 | $scope.queue = queue; 15 | }); 16 | StatusService.status().then(function(status) { 17 | setStatus(status); 18 | }); 19 | 20 | $scope.openMenu = function() { 21 | $aside({template: 'scripts/modules/menu/view.html', placement: 'top-left'}); 22 | }; 23 | 24 | $scope.prev = QueueService.prev; 25 | $scope.toggle = QueueService.toggle; 26 | 27 | $scope.next = QueueService.next; 28 | $scope.clear = function() { 29 | QueueService.clear(); 30 | }; 31 | 32 | function setStatus(status) { 33 | $scope.status = status; 34 | $scope.currentTrack = status.current_track; 35 | } 36 | 37 | $scope.$on('$destroy', $rootScope.$on('status:change', function(event, data) { 38 | setStatus(data); 39 | })); 40 | $scope.$on('$destroy', $rootScope.$on('queue:change', function() { 41 | QueueService.get().then(function(queue) { 42 | $scope.queue = queue; 43 | }); 44 | })); 45 | 46 | } 47 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/quickPlayer/view.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/scripts/modules/search/searchCtrl.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .controller('SearchCtrl', [ 6 | '$scope', 7 | '$location', 8 | 'QueueService', 9 | 'search', 10 | function($scope, $location, QueueService, search) { 11 | $scope.search = search; 12 | $scope.show = 'tracks'; 13 | 14 | if ($location.search().q) { 15 | $scope.term = $location.search().q; 16 | } 17 | $scope.doSearch = function() { 18 | $location.search({q: $scope.term}); 19 | //$location.path('/search'); //?q=' + $scope.term); 20 | }; 21 | $scope.keypress = function($event) { 22 | if ($event.which === 13) { 23 | $scope.doSearch(); 24 | } 25 | }; 26 | 27 | $scope.trackMenu = [ 28 | { icon: 'fa-play', action: 'play', text: 'Play' }, 29 | { icon: 'fa-plus', action: 'append', text: 'Append' }, 30 | { icon: 'fa-music', action: 'title', text: 'Search title' }, 31 | { icon: 'fa-user', action: 'artist', text: 'Search artist' }, 32 | { icon: 'fa-play-circle', action: 'album', text: 'Search album' } 33 | ]; 34 | $scope.trackMenuClick = function(track, item) { 35 | if (item.action === 'play') { 36 | QueueService.addTrack(track).then(function() { 37 | QueueService.playTrack(track); 38 | }); 39 | } else if (item.action === 'append') { 40 | QueueService.addTrack(track); 41 | } else if (item.action === 'title') { 42 | $location.search({q: 'track:\'' + track.title + '\''}); 43 | } else if (item.action === 'artist') { 44 | $location.search({q: 'artist:\'' + track.artist.replace(/,.*/, '') + '\''}); 45 | } else if (item.action === 'album') { 46 | $location.search({q: 'album:\'' + track.album + '\''}); 47 | } 48 | }; 49 | 50 | $scope.playTracks = function(tracks) { 51 | if (!tracks.length) { 52 | return; 53 | } 54 | QueueService.addTracks(tracks).then(function() { 55 | QueueService.playTrack(tracks[0]); 56 | }); 57 | }; 58 | } 59 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/search/searchModel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('SearchModel', [ 6 | 'TrackModel', 7 | function (TrackModel) { 8 | function SearchModel(data) { 9 | data = data || {}; 10 | angular.forEach(data, function(value, key) { 11 | if (key === 'tracks') { 12 | value = TrackModel.createList(value); 13 | } 14 | this[key] = value; 15 | }, this); 16 | 17 | // Fill default values 18 | this.tracks = this.tracks || []; 19 | this.albums = this.albums || []; 20 | this.playlists = this.playlists || []; 21 | } 22 | 23 | SearchModel.prototype.isEmpty = function() { 24 | return this.tracks.length === 0; 25 | }; 26 | 27 | return SearchModel; 28 | } 29 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/search/searchService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('SearchService', [ 6 | '$q', 7 | '$http', 8 | 'SearchModel', 9 | function($q, $http, SearchModel) { 10 | return { 11 | search: function(term) { 12 | if (!angular.isString(term) || !term.length) { 13 | return $q.when(new SearchModel()); 14 | } 15 | return $http.get('/spop/search "' + term + '"').then(function(response) { 16 | return new SearchModel(response.data); 17 | }); 18 | } 19 | }; 20 | } 21 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/search/view.html: -------------------------------------------------------------------------------- 1 |

Search

2 | 3 |
4 | 5 |
6 | 7 | 8 |
9 |
10 | 11 |

12 |

Nothing found

13 | 14 |
15 |
16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 | 26 |
27 |

Show {{search.tracks.length}} of {{search.total_tracks}} tracks. Play all tracks

28 | 29 |
30 |
31 | 32 |
33 |

Show {{search.albums.length}} of {{search.total_albums}} albums.

34 | 35 |
36 |
37 | 38 |
39 |

Show {{search.artists.length}} of {{search.total_artists}} artists.

40 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /public/scripts/modules/status/statusModel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('StatusModel', [ 6 | function () { 7 | function StatusModel(data) { 8 | angular.forEach(data, function(value, key) { 9 | this[key] = value; 10 | }, this); 11 | // duration is in ms, while position is in sec. Set duration to sec, too 12 | this.duration = (this.duration || 0) / 1000; 13 | this.created = +new Date(); 14 | } 15 | 16 | StatusModel.prototype.isPlaying = function() { 17 | return this.status === 'playing'; 18 | }; 19 | 20 | StatusModel.prototype.isStopped = function() { 21 | return this.status === 'stopped'; 22 | }; 23 | 24 | StatusModel.prototype.getTime = function() { 25 | var now = +new Date(), 26 | diff = now - this.created; 27 | 28 | if (!this.isPlaying()) { 29 | return this.position; 30 | } 31 | return Math.min(this.position + (diff / 1000), this.duration); 32 | }; 33 | 34 | StatusModel.prototype.getRemainingTime = function() { 35 | return this.duration - this.getTime(); 36 | }; 37 | 38 | StatusModel.prototype.isShuffle = function() { 39 | return this.shuffle; 40 | }; 41 | 42 | StatusModel.prototype.isRepeat = function() { 43 | return this.repeat; 44 | }; 45 | 46 | return StatusModel; 47 | } 48 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/status/statusService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('StatusService', [ 6 | '$rootScope', 7 | '$http', 8 | 'StatusModel', 9 | function($rootScope, $http, StatusModel) { 10 | var isRunning = false; 11 | 12 | function doPoll() { 13 | return $http.get('/spop/idle').then(function(response) { 14 | $rootScope.$emit('status:change', new StatusModel(response.data)); 15 | if (isRunning) { 16 | return doPoll(); 17 | } 18 | }); 19 | } 20 | 21 | $rootScope.$on('visibility:change', function(event, isHidden) { 22 | if (isHidden) { 23 | service.stop(); 24 | } else { 25 | service.start(); 26 | } 27 | }); 28 | 29 | var service = { 30 | start: function() { 31 | if (isRunning) { 32 | return; 33 | } 34 | isRunning = true; 35 | doPoll().catch(function() { 36 | isRunning = false; 37 | }); 38 | }, 39 | stop: function() { 40 | if (!isRunning) { 41 | return; 42 | } 43 | isRunning = false; 44 | $http.get('/spop/notify'); 45 | }, 46 | status: function() { 47 | return $http.get('/spop/status').then(function(response) { 48 | return new StatusModel(response.data); 49 | }); 50 | } 51 | }; 52 | 53 | return service; 54 | } 55 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/track/trackList.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 10 | 13 | 14 |
4 | 5 | 7 |

{{ track.title }} - {{ track.duration | timeFormat }}
8 | {{ track.artist }} - {{ track.album }}

9 |
11 | 12 |
-------------------------------------------------------------------------------- /public/scripts/modules/track/trackList.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .directive('trackList', [ 6 | '$rootScope', 7 | '$location', 8 | 'StatusService', 9 | 'QueueService', 10 | function($rootScope, $location, StatusService, QueueService) { 11 | return { 12 | scope: { 13 | trackList: '=' 14 | }, 15 | templateUrl: 'scripts/modules/track/trackList.html', 16 | link: function(scope) { 17 | 18 | scope.items = [ 19 | { icon: 'fa-play', action: 'play', text: 'Play' }, 20 | { icon: 'fa-plus', action: 'add', text: 'Add track' }, 21 | { icon: 'fa-music', action: 'title', text: 'Search title' }, 22 | { icon: 'fa-user', action: 'artist', text: 'Search artist' }, 23 | { icon: 'fa-play-circle', action: 'album', text: 'Search album' } 24 | ]; 25 | scope.itemClick = function(track, item) { 26 | if (item.action === 'play') { 27 | QueueService.goto(track); 28 | } else if (item.action === 'add') { 29 | QueueService.addTrack(track); 30 | } else if (item.action === 'remove') { 31 | QueueService.removeTrack(track); 32 | } else if (item.action === 'title') { 33 | $location.search({q: 'track:\'' + track.title + '\''}); 34 | $location.path('/search'); 35 | } else if (item.action === 'artist') { 36 | $location.search({q: 'artist:\'' + track.artist.replace(/,.*/, '') + '\''}); 37 | $location.path('/search'); 38 | } else if (item.action === 'album') { 39 | $location.search({q: 'album:\'' + track.album + '\''}); 40 | $location.path('/search'); 41 | } 42 | }; 43 | scope.trackClass = function(track) { 44 | if (scope.status && scope.status.isPlaying() && scope.status.uri === track.uri) { 45 | return {active: true}; 46 | } 47 | return {}; 48 | }; 49 | 50 | scope.play = function(track) { 51 | QueueService.playTrack(track); 52 | }; 53 | 54 | scope.$on('$destroy', $rootScope.$on('status:change', function(event, status) { 55 | scope.status = status; 56 | })); 57 | StatusService.status().then(function(status) { 58 | scope.status = status; 59 | }); 60 | } 61 | }; 62 | } 63 | ]); -------------------------------------------------------------------------------- /public/scripts/modules/track/trackModel.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('TrackModel', [ 6 | function () { 7 | function TrackModel(data) { 8 | angular.forEach(data, function(value, key) { 9 | this[key] = value; 10 | }, this); 11 | // spop serves duration in ms. We use seconds. 12 | this.duration = (this.duration || 0) / 1000; 13 | } 14 | 15 | TrackModel.createList = function(tracks) { 16 | var result = []; 17 | angular.forEach(tracks, function(track) { 18 | result.push(new TrackModel(track)); 19 | }); 20 | return result; 21 | }; 22 | 23 | return TrackModel; 24 | } 25 | ]); -------------------------------------------------------------------------------- /public/scripts/run/favicon.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .run([ 6 | '$rootScope', 7 | 'StatusService', 8 | function ($rootScope, StatusService) { 9 | var last; 10 | 11 | function update(status) { 12 | if (!status.isPlaying() || last === status.uri) { 13 | return; 14 | } 15 | last = status.uri; 16 | var $img = angular.element(''); 17 | $img.attr('src', '/spop/uimage/' + status.uri + '/1'); 18 | $img.on('load', function() { 19 | var favicon = new Favico(); 20 | favicon = favicon.image($img[0]); 21 | }); 22 | }; 23 | 24 | $rootScope.$on('status:change', function(event, status) { 25 | update(status); 26 | }); 27 | 28 | StatusService.status().then(update); 29 | } 30 | ]); 31 | -------------------------------------------------------------------------------- /public/scripts/run/scrollTop.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .run([ 6 | '$rootScope', 7 | '$anchorScroll', 8 | function($rootScope, $anchorScroll) { 9 | $rootScope.$on('$routeChangeSuccess', function() { 10 | $anchorScroll(); 11 | }); 12 | } 13 | ]); -------------------------------------------------------------------------------- /public/scripts/run/startStatusService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .run([ 6 | 'StatusService', 7 | function(StatusService) { 8 | StatusService.start(); 9 | } 10 | ]); -------------------------------------------------------------------------------- /public/scripts/run/visibilityEvents.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .run([ 6 | '$document', 7 | '$rootScope', 8 | function($document, $rootScope) { 9 | var last; 10 | 11 | function visibilitychanged() { 12 | var d = $document[0], 13 | isHidden = d.hidden || d.webkitHidden || d.mozHidden || d.msHidden; 14 | if (isHidden !== last) { 15 | $rootScope.$emit('visibility:change', isHidden); 16 | last = isHidden; 17 | } 18 | } 19 | 20 | $document.on('visibilitychange',visibilitychanged); 21 | $document.on('webkitvisibilitychange', visibilitychanged); 22 | $document.on('msvisibilitychange', visibilitychanged); 23 | } 24 | ]); -------------------------------------------------------------------------------- /public/scripts/services/debounce.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .factory('debounce', [ 4 | '$timeout', 5 | function ($timeout) { 6 | function debounce(callback, interval, context) { 7 | interval = interval || 250; 8 | context = context || this; 9 | var timer; 10 | 11 | return function() { 12 | var args = Array.prototype.splice.call(arguments, 0); 13 | $timeout.cancel(timer); 14 | timer = $timeout(function() { 15 | callback.apply(context, args); 16 | }, interval); 17 | }; 18 | } 19 | return debounce; 20 | } 21 | ]); -------------------------------------------------------------------------------- /public/scripts/services/elementVisibility.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .factory('$elementVisibility', [ 4 | '$window', 5 | '$document', 6 | '$rootScope', 7 | '$timeout', 8 | 'debounce', 9 | 'throttle', 10 | function($window, $document, $rootScope, $timeout, debounce, throttle) { 11 | var elements = [], 12 | newElements = [], 13 | visibleView = getVisibleView(), 14 | documentSize = getDocumentSize(); 15 | 16 | // from http://www.javascripter.net/faq/browserw.htm 17 | function getViewport() { 18 | var width, height; 19 | if ($document.body && $document.body.offsetWidth) { 20 | width = $document.body.offsetWidth; 21 | height = $document.body.offsetHeight; 22 | } else if ($document.compatMode === 'CSS1Compat' && $document.documentElement && $document.documentElement.offsetWidth ) { 23 | width = $document.documentElement.offsetWidth; 24 | height = $document.documentElement.offsetHeight; 25 | } else if ($window.innerWidth && $window.innerHeight) { 26 | width = $window.innerWidth; 27 | height = $window.innerHeight; 28 | } 29 | return { 30 | width: width, 31 | height: height 32 | }; 33 | }; 34 | 35 | function getScrollPosition() { 36 | var top, left; 37 | if ($window.hasOwnProperty('pageXOffset')) { 38 | left = $window.pageXOffset; 39 | top = $window.pageYOffset; 40 | } else if ($document.documentElement && $document.documentElement.scrollLeft) { 41 | left = $document.documentElement.scrollLeft; 42 | top = $document.documentElement.scrollRight; 43 | } 44 | return { 45 | left: left, 46 | top: top 47 | }; 48 | }; 49 | 50 | function getVisibleView() { 51 | var viewport = getViewport(), 52 | scroll = getScrollPosition(); 53 | return { 54 | left: scroll.left, 55 | top: scroll.top, 56 | right: scroll.left + viewport.width, 57 | bottom: scroll.top + viewport.height 58 | }; 59 | } 60 | 61 | function getDocumentSize() { 62 | var body = $document[0].body, 63 | width, height; 64 | if (body.offsetHeight) { 65 | height = body.offsetHeight; 66 | width = body.offsetWidth; 67 | } 68 | if (!documentSize || documentSize.width !== width || documentSize.height !== height) { 69 | documentSize = { 70 | height: height, 71 | width: width 72 | }; 73 | } 74 | return documentSize; 75 | } 76 | 77 | function isElementVisible(element, visibleView) { 78 | return !(element.rect.bottom < visibleView.top || 79 | element.rect.top > visibleView.bottom || 80 | element.rect.right < visibleView.left || 81 | element.rect.left > visibleView.right); 82 | } 83 | 84 | function getElementRect(element, options) { 85 | var left, top, right, bottom, rect; 86 | rect = element.getBoundingClientRect(); 87 | left = rect.left; 88 | top = rect.top; 89 | right = rect.left + rect.width; 90 | bottom = rect.top + rect.height; 91 | return { 92 | left: left - (options.left || 0), 93 | top: top - (options.top || 0), 94 | right: right + (options.right || 0), 95 | bottom: bottom + (options.bottom || 0) 96 | }; 97 | } 98 | 99 | /** 100 | * Evaluate element visibilities and call callback functions 101 | */ 102 | function evaluateVisiblity() { 103 | visibleView = getVisibleView(); 104 | angular.forEach(elements, function(e) { 105 | var isVisible = isElementVisible(e, visibleView); 106 | if (e.isVisible && !isVisible) { 107 | e.hide(e.element); 108 | e.isVisible = isVisible; 109 | } else if (!e.isVisible && isVisible) { 110 | e.show(e.element); 111 | e.isVisible = isVisible; 112 | } 113 | }); 114 | } 115 | 116 | function recalculateRects() { 117 | angular.forEach(elements, function(e) { 118 | e.rect = getElementRect(e.element, e.options); 119 | }); 120 | } 121 | 122 | function deferAdd() { 123 | angular.forEach(newElements, function(e) { 124 | e.rect = getElementRect(e.element, e.options); 125 | e.isVisible = isElementVisible(e, visibleView); 126 | if (e.isVisible) { 127 | e.show(e.element); 128 | } else { 129 | e.hide(e.element); 130 | } 131 | elements.push(e); 132 | }); 133 | newElements = []; 134 | } 135 | 136 | angular.element($window).on('scroll', throttle(function() { 137 | $rootScope.$apply(function() { 138 | evaluateVisiblity(); 139 | }); 140 | }, 250, true)).on('resize', debounce(function() { 141 | $rootScope.$apply(function() { 142 | recalculateRects(); 143 | evaluateVisiblity(); 144 | }); 145 | })); 146 | $rootScope.$watch(getDocumentSize, debounce(function() { 147 | recalculateRects(); 148 | evaluateVisiblity(); 149 | })); 150 | 151 | return { 152 | /** 153 | * Add a DOM element to visibility serivce. 154 | * 155 | * @param {DOM} element 156 | * @param {function} showCb Callback function if element becomes visible 157 | * @param {function} hideCb Callback function if element becomes hidden 158 | * @param {object} options 159 | * - top: Offset of elements top position. E.g. {top: 100} gives reduces 160 | * the top position by 100 and the showCb function is called ealier 161 | * - left: Offset of elements left position 162 | * - right: Offset of elements right position 163 | * - bottom: Offset of elements bottom position 164 | * @returns {function} Function to remove element form visibility check. 165 | * This is helpful to remove the element from visibility service on 166 | * scope's destroy function 167 | */ 168 | add: function(element, showCb, hideCb, options) { 169 | options = options || {}; 170 | 171 | // Evaluation of element rect is expensive. We defer the evaluation 172 | // on the next event loop to save time for current loop. This have 173 | // positive impact for larger ng-repeat loops 174 | if (!newElements.length) { 175 | $timeout(deferAdd); 176 | } 177 | 178 | newElements.push({ 179 | element: element, 180 | options: options, 181 | rect: false, 182 | isVisible: false, 183 | show: showCb, 184 | hide: hideCb 185 | }); 186 | 187 | return this.remove.bind(this, element); 188 | }, 189 | /** 190 | * Remove DOM element from visibility serivce 191 | * 192 | * @param {DOM} element 193 | */ 194 | remove: function(element) { 195 | elements = elements.filter(function(e) { 196 | return e.element !== element; 197 | }); 198 | } 199 | }; 200 | } 201 | ]); 202 | -------------------------------------------------------------------------------- /public/scripts/services/throttle.js: -------------------------------------------------------------------------------- 1 | angular 2 | .module('app') 3 | .factory('throttle', [ 4 | '$timeout', 5 | function ($timeout) { 6 | function throttle(callback, interval, immediate, context) { 7 | var timer, last; 8 | 9 | if (angular.isObject(immediate)) { 10 | context = immediate; 11 | immediate = false; 12 | } 13 | 14 | interval = interval || 250; 15 | context = context || this; 16 | 17 | return function() { 18 | var now = +new Date(), 19 | elapsed = now - (last || 0), 20 | args = Array.prototype.splice.call(arguments, 0); 21 | 22 | if (timer) { 23 | return; 24 | } else if (immediate || elapsed > interval) { 25 | last = now; 26 | callback.apply(context, args); 27 | } else { 28 | timer = $timeout(function() { 29 | timer = null; 30 | last = +new Date(); 31 | callback.apply(context, args); 32 | }, interval - elapsed); 33 | } 34 | }; 35 | } 36 | return throttle; 37 | } 38 | ]); 39 | -------------------------------------------------------------------------------- /public/scripts/services/volumeService.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | angular 4 | .module('app') 5 | .factory('VolumeService', [ 6 | '$http', 7 | function($http) { 8 | return { 9 | get: function() { 10 | return $http.get('/volume').then(function(response) { 11 | return response.data; 12 | }); 13 | }, 14 | set: function(percent) { 15 | percent = Math.min(100, Math.max(0, +percent)).toFixed(0); 16 | return $http.get('/volume/' + percent).then(function(response) { 17 | return response.data; 18 | }); 19 | } 20 | }; 21 | } 22 | ]); -------------------------------------------------------------------------------- /public/styles/angular-strap.less: -------------------------------------------------------------------------------- 1 | @import (inline) "@{bowerBase}/bootstrap-additions/dist/modules/aside.css"; 2 | 3 | /** 4 | * Bootstrap-addition/Angular-strap fixes 5 | */ 6 | /* Backdrop opacity */ 7 | .aside-backdrop { 8 | opacity: 0.3; 9 | } 10 | .aside { 11 | background-color: @theme-body-bg; 12 | width: 250px; 13 | .aside-dialog { 14 | .aside-header { 15 | background-color: @theme-gray-dark; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/styles/app.less: -------------------------------------------------------------------------------- 1 | @bowerBase: "../bower_components"; 2 | 3 | @import "variables"; 4 | @import "bootstrap"; 5 | @import "angular-strap"; 6 | @import "fontawesome"; 7 | 8 | @import "track-timer"; 9 | @import "volume"; 10 | @import "slider"; 11 | 12 | .gap { 13 | padding-top: @font-size-base; 14 | padding-bottom: @font-size-base; 15 | } 16 | .menu-button { 17 | position: fixed; 18 | top: 0; 19 | z-index: 10; 20 | background-color: @theme-body-bg; 21 | } 22 | 23 | .track-cover { 24 | height: 48px; 25 | width: 48px; 26 | // Disable user selection 27 | .user-select(none); 28 | } 29 | 30 | .queue-header h2 { 31 | margin-top: 0; 32 | margin-bottom: 0; 33 | } 34 | -------------------------------------------------------------------------------- /public/styles/bootstrap-variables.less: -------------------------------------------------------------------------------- 1 | // 2 | // Variables 3 | // -------------------------------------------------- 4 | 5 | 6 | //== Colors 7 | // 8 | //## Gray and brand colors for use across Bootstrap. 9 | 10 | @gray-base: #000; 11 | @gray-darker: lighten(@gray-base, 13.5%); // #222 12 | @gray-dark: lighten(@gray-base, 20%); // #333 13 | @gray: lighten(@gray-base, 33.5%); // #555 14 | @gray-light: lighten(@gray-base, 46.7%); // #777 15 | @gray-lighter: lighten(@gray-base, 93.5%); // #eee 16 | 17 | @brand-primary: #6699CC; // darken(#428bca, 6.5%); // #337ab7 18 | @brand-success: #5FB3B3; // #5cb85c; 19 | @brand-info: #99C794; // #5bc0de; 20 | @brand-warning: #F99157; // #f0ad4e; 21 | @brand-danger: #EC5f67; // #d9534f; 22 | 23 | 24 | //== Scaffolding 25 | // 26 | //## Settings for some of the most global styles. 27 | 28 | //** Background color for ``. 29 | @body-bg: @theme-body-bg; 30 | //** Global text color on ``. 31 | @text-color: @theme-text-color; 32 | 33 | //** Global textual link color. 34 | @link-color: @brand-info; 35 | //** Link hover color set via `darken()` function. 36 | @link-hover-color: darken(@link-color, 15%); 37 | //** Link hover decoration. 38 | @link-hover-decoration: underline; 39 | 40 | 41 | //== Typography 42 | // 43 | //## Font, line-height, and color for body text, headings, and more. 44 | 45 | @font-family-sans-serif: "Helvetica Neue", Helvetica, Arial, sans-serif; 46 | @font-family-serif: Georgia, "Times New Roman", Times, serif; 47 | //** Default monospace fonts for ``, ``, and `
`.
 48 | @font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
 49 | @font-family-base:        @font-family-sans-serif;
 50 | 
 51 | @font-size-base:          14px;
 52 | @font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
 53 | @font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
 54 | 
 55 | @font-size-h1:            floor((@font-size-base * 2.6)); // ~36px
 56 | @font-size-h2:            floor((@font-size-base * 2.15)); // ~30px
 57 | @font-size-h3:            ceil((@font-size-base * 1.7)); // ~24px
 58 | @font-size-h4:            ceil((@font-size-base * 1.25)); // ~18px
 59 | @font-size-h5:            @font-size-base;
 60 | @font-size-h6:            ceil((@font-size-base * 0.85)); // ~12px
 61 | 
 62 | //** Unit-less `line-height` for use in components like buttons.
 63 | @line-height-base:        1.428571429; // 20/14
 64 | //** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
 65 | @line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
 66 | 
 67 | //** By default, this inherits from the ``.
 68 | @headings-font-family:    inherit;
 69 | @headings-font-weight:    500;
 70 | @headings-line-height:    1.1;
 71 | @headings-color:          inherit;
 72 | 
 73 | 
 74 | //== Iconography
 75 | //
 76 | //## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
 77 | 
 78 | //** Load fonts from this directory.
 79 | @icon-font-path:          "../fonts/";
 80 | //** File name for all font files.
 81 | @icon-font-name:          "glyphicons-halflings-regular";
 82 | //** Element ID within SVG icon file.
 83 | @icon-font-svg-id:        "glyphicons_halflingsregular";
 84 | 
 85 | 
 86 | //== Components
 87 | //
 88 | //## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
 89 | 
 90 | @padding-base-vertical:     6px;
 91 | @padding-base-horizontal:   12px;
 92 | 
 93 | @padding-large-vertical:    10px;
 94 | @padding-large-horizontal:  16px;
 95 | 
 96 | @padding-small-vertical:    5px;
 97 | @padding-small-horizontal:  10px;
 98 | 
 99 | @padding-xs-vertical:       1px;
100 | @padding-xs-horizontal:     5px;
101 | 
102 | @line-height-large:         1.3333333; // extra decimals for Win 8.1 Chrome
103 | @line-height-small:         1.5;
104 | 
105 | @border-radius-base:        4px;
106 | @border-radius-large:       6px;
107 | @border-radius-small:       3px;
108 | 
109 | //** Global color for active items (e.g., navs or dropdowns).
110 | @component-active-color:    @theme-text-color;
111 | //** Global background color for active items (e.g., navs or dropdowns).
112 | @component-active-bg:       @brand-primary;
113 | 
114 | //** Width of the `border` for generating carets that indicator dropdowns.
115 | @caret-width-base:          4px;
116 | //** Carets increase slightly in size for larger components.
117 | @caret-width-large:         5px;
118 | 
119 | 
120 | //== Tables
121 | //
122 | //## Customizes the `.table` component with basic values, each used across all table variations.
123 | 
124 | //** Padding for ``s and ``s.
125 | @table-cell-padding:            8px;
126 | //** Padding for cells in `.table-condensed`.
127 | @table-condensed-cell-padding:  5px;
128 | 
129 | //** Default background color used for all tables.
130 | @table-bg:                      transparent;
131 | //** Background color used for `.table-striped`.
132 | @table-bg-accent:               #f9f9f9;
133 | //** Background color used for `.table-hover`.
134 | @table-bg-hover:                @theme-gray-base;
135 | @table-bg-active:               @table-bg-hover;
136 | 
137 | //** Border color for table and cell borders.
138 | @table-border-color:            @theme-gray-dark;
139 | 
140 | 
141 | //== Buttons
142 | //
143 | //## For each of Bootstrap's buttons, define text, background and border color.
144 | 
145 | @btn-font-weight:                normal;
146 | 
147 | @btn-default-color:              @theme-text-color;
148 | @btn-default-bg:                 @theme-gray-dark;
149 | @btn-default-border:             @theme-gray;
150 | 
151 | @btn-primary-color:              #fff;
152 | @btn-primary-bg:                 @brand-primary;
153 | @btn-primary-border:             darken(@btn-primary-bg, 5%);
154 | 
155 | @btn-success-color:              #fff;
156 | @btn-success-bg:                 @brand-success;
157 | @btn-success-border:             darken(@btn-success-bg, 5%);
158 | 
159 | @btn-info-color:                 #fff;
160 | @btn-info-bg:                    @brand-info;
161 | @btn-info-border:                darken(@btn-info-bg, 5%);
162 | 
163 | @btn-warning-color:              #fff;
164 | @btn-warning-bg:                 @brand-warning;
165 | @btn-warning-border:             darken(@btn-warning-bg, 5%);
166 | 
167 | @btn-danger-color:               #fff;
168 | @btn-danger-bg:                  @brand-danger;
169 | @btn-danger-border:              darken(@btn-danger-bg, 5%);
170 | 
171 | @btn-link-disabled-color:        @gray-light;
172 | 
173 | 
174 | //== Forms
175 | //
176 | //##
177 | 
178 | //** `` background color
179 | @input-bg:                       @theme-gray-dark;
180 | //** `` background color
181 | @input-bg-disabled:              @gray-lighter;
182 | 
183 | //** Text color for ``s
184 | @input-color:                    @theme-gray-lighter;
185 | //** `` border color
186 | @input-border:                   @theme-gray-base;
187 | 
188 | // TODO: Rename `@input-border-radius` to `@input-border-radius-base` in v4
189 | //** Default `.form-control` border radius
190 | // This has no effect on ``s in CSS.
191 | @input-border-radius:            @border-radius-base;
192 | //** Large `.form-control` border radius
193 | @input-border-radius-large:      @border-radius-large;
194 | //** Small `.form-control` border radius
195 | @input-border-radius-small:      @border-radius-small;
196 | 
197 | //** Border color for inputs on focus
198 | @input-border-focus:             @theme-gray;
199 | 
200 | //** Placeholder text color
201 | @input-color-placeholder:        @theme-gray-light;
202 | 
203 | //** Default `.form-control` height
204 | @input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);
205 | //** Large `.form-control` height
206 | @input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
207 | //** Small `.form-control` height
208 | @input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
209 | 
210 | //** `.form-group` margin
211 | @form-group-margin-bottom:       15px;
212 | 
213 | @legend-color:                   @gray-dark;
214 | @legend-border-color:            #e5e5e5;
215 | 
216 | //** Background color for textual input addons
217 | @input-group-addon-bg:           @gray-lighter;
218 | //** Border color for textual input addons
219 | @input-group-addon-border-color: @input-border;
220 | 
221 | //** Disabled cursor for form controls and buttons.
222 | @cursor-disabled:                not-allowed;
223 | 
224 | 
225 | //== Dropdowns
226 | //
227 | //## Dropdown menu container and contents.
228 | 
229 | //** Background for the dropdown menu.
230 | @dropdown-bg:                    @theme-body-bg;
231 | //** Dropdown menu `border-color`.
232 | @dropdown-border:                @theme-gray-dark;
233 | //** Dropdown menu `border-color` **for IE8**.
234 | @dropdown-fallback-border:       @theme-gray-dark;
235 | //** Divider color for between dropdown items.
236 | @dropdown-divider-bg:            @theme-gray-light;
237 | 
238 | //** Dropdown link text color.
239 | @dropdown-link-color:            @theme-text-color;
240 | //** Hover color for dropdown links.
241 | @dropdown-link-hover-color:      @theme-gray-light;
242 | //** Hover background for dropdown links.
243 | @dropdown-link-hover-bg:         @theme-gray-base;
244 | 
245 | //** Active dropdown menu item text color.
246 | @dropdown-link-active-color:     @component-active-color;
247 | //** Active dropdown menu item background color.
248 | @dropdown-link-active-bg:        @component-active-bg;
249 | 
250 | //** Disabled dropdown menu item background color.
251 | @dropdown-link-disabled-color:   @gray-light;
252 | 
253 | //** Text color for headers within dropdown menus.
254 | @dropdown-header-color:          @gray-light;
255 | 
256 | //** Deprecated `@dropdown-caret-color` as of v3.1.0
257 | @dropdown-caret-color:           @theme-text-color;
258 | 
259 | 
260 | //-- Z-index master list
261 | //
262 | // Warning: Avoid customizing these values. They're used for a bird's eye view
263 | // of components dependent on the z-axis and are designed to all work together.
264 | //
265 | // Note: These variables are not generated into the Customizer.
266 | 
267 | @zindex-navbar:            1000;
268 | @zindex-dropdown:          1000;
269 | @zindex-popover:           1060;
270 | @zindex-tooltip:           1070;
271 | @zindex-navbar-fixed:      1030;
272 | @zindex-modal-background:  1040;
273 | @zindex-modal:             1050;
274 | 
275 | 
276 | //== Media queries breakpoints
277 | //
278 | //## Define the breakpoints at which your layout will change, adapting to different screen sizes.
279 | 
280 | // Extra small screen / phone
281 | //** Deprecated `@screen-xs` as of v3.0.1
282 | @screen-xs:                  480px;
283 | //** Deprecated `@screen-xs-min` as of v3.2.0
284 | @screen-xs-min:              @screen-xs;
285 | //** Deprecated `@screen-phone` as of v3.0.1
286 | @screen-phone:               @screen-xs-min;
287 | 
288 | // Small screen / tablet
289 | //** Deprecated `@screen-sm` as of v3.0.1
290 | @screen-sm:                  768px;
291 | @screen-sm-min:              @screen-sm;
292 | //** Deprecated `@screen-tablet` as of v3.0.1
293 | @screen-tablet:              @screen-sm-min;
294 | 
295 | // Medium screen / desktop
296 | //** Deprecated `@screen-md` as of v3.0.1
297 | @screen-md:                  992px;
298 | @screen-md-min:              @screen-md;
299 | //** Deprecated `@screen-desktop` as of v3.0.1
300 | @screen-desktop:             @screen-md-min;
301 | 
302 | // Large screen / wide desktop
303 | //** Deprecated `@screen-lg` as of v3.0.1
304 | @screen-lg:                  1200px;
305 | @screen-lg-min:              @screen-lg;
306 | //** Deprecated `@screen-lg-desktop` as of v3.0.1
307 | @screen-lg-desktop:          @screen-lg-min;
308 | 
309 | // So media queries don't overlap when required, provide a maximum
310 | @screen-xs-max:              (@screen-sm-min - 1);
311 | @screen-sm-max:              (@screen-md-min - 1);
312 | @screen-md-max:              (@screen-lg-min - 1);
313 | 
314 | 
315 | //== Grid system
316 | //
317 | //## Define your custom responsive grid.
318 | 
319 | //** Number of columns in the grid.
320 | @grid-columns:              12;
321 | //** Padding between columns. Gets divided in half for the left and right.
322 | @grid-gutter-width:         30px;
323 | // Navbar collapse
324 | //** Point at which the navbar becomes uncollapsed.
325 | @grid-float-breakpoint:     @screen-sm-min;
326 | //** Point at which the navbar begins collapsing.
327 | @grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
328 | 
329 | 
330 | //== Container sizes
331 | //
332 | //## Define the maximum width of `.container` for different screen sizes.
333 | 
334 | // Small screen / tablet
335 | @container-tablet:             (720px + @grid-gutter-width);
336 | //** For `@screen-sm-min` and up.
337 | @container-sm:                 @container-tablet;
338 | 
339 | // Medium screen / desktop
340 | @container-desktop:            (940px + @grid-gutter-width);
341 | //** For `@screen-md-min` and up.
342 | @container-md:                 @container-desktop;
343 | 
344 | // Large screen / wide desktop
345 | @container-large-desktop:      (1140px + @grid-gutter-width);
346 | //** For `@screen-lg-min` and up.
347 | @container-lg:                 @container-large-desktop;
348 | 
349 | 
350 | //== Navbar
351 | //
352 | //##
353 | 
354 | // Basics of a navbar
355 | @navbar-height:                    50px;
356 | @navbar-margin-bottom:             @line-height-computed;
357 | @navbar-border-radius:             @border-radius-base;
358 | @navbar-padding-horizontal:        floor((@grid-gutter-width / 2));
359 | @navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);
360 | @navbar-collapse-max-height:       340px;
361 | 
362 | @navbar-default-color:             @theme-text-color;
363 | @navbar-default-bg:                @theme-gray-base;
364 | @navbar-default-border:            darken(@navbar-default-bg, 6.5%);
365 | 
366 | // Navbar links
367 | @navbar-default-link-color:                #777;
368 | @navbar-default-link-hover-color:          #333;
369 | @navbar-default-link-hover-bg:             transparent;
370 | @navbar-default-link-active-color:         #555;
371 | @navbar-default-link-active-bg:            darken(@navbar-default-bg, 6.5%);
372 | @navbar-default-link-disabled-color:       #ccc;
373 | @navbar-default-link-disabled-bg:          transparent;
374 | 
375 | // Navbar brand label
376 | @navbar-default-brand-color:               @navbar-default-link-color;
377 | @navbar-default-brand-hover-color:         darken(@navbar-default-brand-color, 10%);
378 | @navbar-default-brand-hover-bg:            transparent;
379 | 
380 | // Navbar toggle
381 | @navbar-default-toggle-hover-bg:           #ddd;
382 | @navbar-default-toggle-icon-bar-bg:        #888;
383 | @navbar-default-toggle-border-color:       #ddd;
384 | 
385 | 
386 | // Inverted navbar
387 | // Reset inverted navbar basics
388 | @navbar-inverse-color:                      lighten(@gray-light, 15%);
389 | @navbar-inverse-bg:                         #222;
390 | @navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);
391 | 
392 | // Inverted navbar links
393 | @navbar-inverse-link-color:                 lighten(@gray-light, 15%);
394 | @navbar-inverse-link-hover-color:           #fff;
395 | @navbar-inverse-link-hover-bg:              transparent;
396 | @navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;
397 | @navbar-inverse-link-active-bg:             darken(@navbar-inverse-bg, 10%);
398 | @navbar-inverse-link-disabled-color:        #444;
399 | @navbar-inverse-link-disabled-bg:           transparent;
400 | 
401 | // Inverted navbar brand label
402 | @navbar-inverse-brand-color:                @navbar-inverse-link-color;
403 | @navbar-inverse-brand-hover-color:          #fff;
404 | @navbar-inverse-brand-hover-bg:             transparent;
405 | 
406 | // Inverted navbar toggle
407 | @navbar-inverse-toggle-hover-bg:            #333;
408 | @navbar-inverse-toggle-icon-bar-bg:         #fff;
409 | @navbar-inverse-toggle-border-color:        #333;
410 | 
411 | 
412 | //== Navs
413 | //
414 | //##
415 | 
416 | //=== Shared nav styles
417 | @nav-link-padding:                          10px 15px;
418 | @nav-link-hover-bg:                         @theme-gray;
419 | 
420 | @nav-disabled-link-color:                   @gray-light;
421 | @nav-disabled-link-hover-color:             @gray-light;
422 | 
423 | //== Tabs
424 | @nav-tabs-border-color:                     #ddd;
425 | 
426 | @nav-tabs-link-hover-border-color:          @gray-lighter;
427 | 
428 | @nav-tabs-active-link-hover-bg:             @body-bg;
429 | @nav-tabs-active-link-hover-color:          @gray;
430 | @nav-tabs-active-link-hover-border-color:   #ddd;
431 | 
432 | @nav-tabs-justified-link-border-color:            #ddd;
433 | @nav-tabs-justified-active-link-border-color:     @body-bg;
434 | 
435 | //== Pills
436 | @nav-pills-border-radius:                   @border-radius-base;
437 | @nav-pills-active-link-hover-bg:            @component-active-bg;
438 | @nav-pills-active-link-hover-color:         @component-active-color;
439 | 
440 | 
441 | //== Pagination
442 | //
443 | //##
444 | 
445 | @pagination-color:                     @link-color;
446 | @pagination-bg:                        #fff;
447 | @pagination-border:                    #ddd;
448 | 
449 | @pagination-hover-color:               @link-hover-color;
450 | @pagination-hover-bg:                  @gray-lighter;
451 | @pagination-hover-border:              #ddd;
452 | 
453 | @pagination-active-color:              #fff;
454 | @pagination-active-bg:                 @brand-primary;
455 | @pagination-active-border:             @brand-primary;
456 | 
457 | @pagination-disabled-color:            @gray-light;
458 | @pagination-disabled-bg:               #fff;
459 | @pagination-disabled-border:           #ddd;
460 | 
461 | 
462 | //== Pager
463 | //
464 | //##
465 | 
466 | @pager-bg:                             @pagination-bg;
467 | @pager-border:                         @pagination-border;
468 | @pager-border-radius:                  15px;
469 | 
470 | @pager-hover-bg:                       @pagination-hover-bg;
471 | 
472 | @pager-active-bg:                      @pagination-active-bg;
473 | @pager-active-color:                   @pagination-active-color;
474 | 
475 | @pager-disabled-color:                 @pagination-disabled-color;
476 | 
477 | 
478 | //== Jumbotron
479 | //
480 | //##
481 | 
482 | @jumbotron-padding:              30px;
483 | @jumbotron-color:                inherit;
484 | @jumbotron-bg:                   @gray-lighter;
485 | @jumbotron-heading-color:        inherit;
486 | @jumbotron-font-size:            ceil((@font-size-base * 1.5));
487 | 
488 | 
489 | //== Form states and alerts
490 | //
491 | //## Define colors for form feedback states and, by default, alerts.
492 | 
493 | @state-success-text:             #3c763d;
494 | @state-success-bg:               #dff0d8;
495 | @state-success-border:           darken(spin(@state-success-bg, -10), 5%);
496 | 
497 | @state-info-text:                #31708f;
498 | @state-info-bg:                  #d9edf7;
499 | @state-info-border:              darken(spin(@state-info-bg, -10), 7%);
500 | 
501 | @state-warning-text:             #8a6d3b;
502 | @state-warning-bg:               #fcf8e3;
503 | @state-warning-border:           darken(spin(@state-warning-bg, -10), 5%);
504 | 
505 | @state-danger-text:              #a94442;
506 | @state-danger-bg:                #f2dede;
507 | @state-danger-border:            darken(spin(@state-danger-bg, -10), 5%);
508 | 
509 | 
510 | //== Tooltips
511 | //
512 | //##
513 | 
514 | //** Tooltip max width
515 | @tooltip-max-width:           200px;
516 | //** Tooltip text color
517 | @tooltip-color:               #fff;
518 | //** Tooltip background color
519 | @tooltip-bg:                  #000;
520 | @tooltip-opacity:             .9;
521 | 
522 | //** Tooltip arrow width
523 | @tooltip-arrow-width:         5px;
524 | //** Tooltip arrow color
525 | @tooltip-arrow-color:         @tooltip-bg;
526 | 
527 | 
528 | //== Popovers
529 | //
530 | //##
531 | 
532 | //** Popover body background color
533 | @popover-bg:                          #fff;
534 | //** Popover maximum width
535 | @popover-max-width:                   276px;
536 | //** Popover border color
537 | @popover-border-color:                rgba(0,0,0,.2);
538 | //** Popover fallback border color
539 | @popover-fallback-border-color:       #ccc;
540 | 
541 | //** Popover title background color
542 | @popover-title-bg:                    darken(@popover-bg, 3%);
543 | 
544 | //** Popover arrow width
545 | @popover-arrow-width:                 10px;
546 | //** Popover arrow color
547 | @popover-arrow-color:                 @popover-bg;
548 | 
549 | //** Popover outer arrow width
550 | @popover-arrow-outer-width:           (@popover-arrow-width + 1);
551 | //** Popover outer arrow color
552 | @popover-arrow-outer-color:           fadein(@popover-border-color, 5%);
553 | //** Popover outer arrow fallback color
554 | @popover-arrow-outer-fallback-color:  darken(@popover-fallback-border-color, 20%);
555 | 
556 | 
557 | //== Labels
558 | //
559 | //##
560 | 
561 | //** Default label background color
562 | @label-default-bg:            @gray-light;
563 | //** Primary label background color
564 | @label-primary-bg:            @brand-primary;
565 | //** Success label background color
566 | @label-success-bg:            @brand-success;
567 | //** Info label background color
568 | @label-info-bg:               @brand-info;
569 | //** Warning label background color
570 | @label-warning-bg:            @brand-warning;
571 | //** Danger label background color
572 | @label-danger-bg:             @brand-danger;
573 | 
574 | //** Default label text color
575 | @label-color:                 #fff;
576 | //** Default text color of a linked label
577 | @label-link-hover-color:      #fff;
578 | 
579 | 
580 | //== Modals
581 | //
582 | //##
583 | 
584 | //** Padding applied to the modal body
585 | @modal-inner-padding:         15px;
586 | 
587 | //** Padding applied to the modal title
588 | @modal-title-padding:         15px;
589 | //** Modal title line-height
590 | @modal-title-line-height:     @line-height-base;
591 | 
592 | //** Background color of modal content area
593 | @modal-content-bg:                             #fff;
594 | //** Modal content border color
595 | @modal-content-border-color:                   rgba(0,0,0,.2);
596 | //** Modal content border color **for IE8**
597 | @modal-content-fallback-border-color:          #999;
598 | 
599 | //** Modal backdrop background color
600 | @modal-backdrop-bg:           #000;
601 | //** Modal backdrop opacity
602 | @modal-backdrop-opacity:      .5;
603 | //** Modal header border color
604 | @modal-header-border-color:   #e5e5e5;
605 | //** Modal footer border color
606 | @modal-footer-border-color:   @modal-header-border-color;
607 | 
608 | @modal-lg:                    900px;
609 | @modal-md:                    600px;
610 | @modal-sm:                    300px;
611 | 
612 | 
613 | //== Alerts
614 | //
615 | //## Define alert colors, border radius, and padding.
616 | 
617 | @alert-padding:               15px;
618 | @alert-border-radius:         @border-radius-base;
619 | @alert-link-font-weight:      bold;
620 | 
621 | @alert-success-bg:            @state-success-bg;
622 | @alert-success-text:          @state-success-text;
623 | @alert-success-border:        @state-success-border;
624 | 
625 | @alert-info-bg:               @state-info-bg;
626 | @alert-info-text:             @state-info-text;
627 | @alert-info-border:           @state-info-border;
628 | 
629 | @alert-warning-bg:            @state-warning-bg;
630 | @alert-warning-text:          @state-warning-text;
631 | @alert-warning-border:        @state-warning-border;
632 | 
633 | @alert-danger-bg:             @state-danger-bg;
634 | @alert-danger-text:           @state-danger-text;
635 | @alert-danger-border:         @state-danger-border;
636 | 
637 | 
638 | //== Progress bars
639 | //
640 | //##
641 | 
642 | //** Background color of the whole progress component
643 | @progress-bg:                 #f5f5f5;
644 | //** Progress bar text color
645 | @progress-bar-color:          #fff;
646 | //** Variable for setting rounded corners on progress bar.
647 | @progress-border-radius:      @border-radius-base;
648 | 
649 | //** Default progress bar color
650 | @progress-bar-bg:             @brand-primary;
651 | //** Success progress bar color
652 | @progress-bar-success-bg:     @brand-success;
653 | //** Warning progress bar color
654 | @progress-bar-warning-bg:     @brand-warning;
655 | //** Danger progress bar color
656 | @progress-bar-danger-bg:      @brand-danger;
657 | //** Info progress bar color
658 | @progress-bar-info-bg:        @brand-info;
659 | 
660 | 
661 | //== List group
662 | //
663 | //##
664 | 
665 | //** Background color on `.list-group-item`
666 | @list-group-bg:                 @theme-body-bg;
667 | //** `.list-group-item` border color
668 | @list-group-border:             @theme-gray-base;
669 | //** List group border radius
670 | @list-group-border-radius:      @border-radius-base;
671 | 
672 | //** Background color of single list items on hover
673 | @list-group-hover-bg:           @theme-gray-base;
674 | //** Text color of active list items
675 | @list-group-active-color:       @component-active-color;
676 | //** Background color of active list items
677 | @list-group-active-bg:          @component-active-bg;
678 | //** Border color of active list elements
679 | @list-group-active-border:      @list-group-active-bg;
680 | //** Text color for content within active list items
681 | @list-group-active-text-color:  lighten(@list-group-active-bg, 40%);
682 | 
683 | //** Text color of disabled list items
684 | @list-group-disabled-color:      @gray-light;
685 | //** Background color of disabled list items
686 | @list-group-disabled-bg:         @gray-lighter;
687 | //** Text color for content within disabled list items
688 | @list-group-disabled-text-color: @list-group-disabled-color;
689 | 
690 | @list-group-link-color:         @theme-text-color;
691 | @list-group-link-hover-color:   @list-group-link-color;
692 | @list-group-link-heading-color: #333;
693 | 
694 | 
695 | //== Panels
696 | //
697 | //##
698 | 
699 | @panel-bg:                    #fff;
700 | @panel-body-padding:          15px;
701 | @panel-heading-padding:       10px 15px;
702 | @panel-footer-padding:        @panel-heading-padding;
703 | @panel-border-radius:         @border-radius-base;
704 | 
705 | //** Border color for elements within panels
706 | @panel-inner-border:          #ddd;
707 | @panel-footer-bg:             #f5f5f5;
708 | 
709 | @panel-default-text:          @gray-dark;
710 | @panel-default-border:        #ddd;
711 | @panel-default-heading-bg:    #f5f5f5;
712 | 
713 | @panel-primary-text:          #fff;
714 | @panel-primary-border:        @brand-primary;
715 | @panel-primary-heading-bg:    @brand-primary;
716 | 
717 | @panel-success-text:          @state-success-text;
718 | @panel-success-border:        @state-success-border;
719 | @panel-success-heading-bg:    @state-success-bg;
720 | 
721 | @panel-info-text:             @state-info-text;
722 | @panel-info-border:           @state-info-border;
723 | @panel-info-heading-bg:       @state-info-bg;
724 | 
725 | @panel-warning-text:          @state-warning-text;
726 | @panel-warning-border:        @state-warning-border;
727 | @panel-warning-heading-bg:    @state-warning-bg;
728 | 
729 | @panel-danger-text:           @state-danger-text;
730 | @panel-danger-border:         @state-danger-border;
731 | @panel-danger-heading-bg:     @state-danger-bg;
732 | 
733 | 
734 | //== Thumbnails
735 | //
736 | //##
737 | 
738 | //** Padding around the thumbnail image
739 | @thumbnail-padding:           4px;
740 | //** Thumbnail background color
741 | @thumbnail-bg:                @body-bg;
742 | //** Thumbnail border color
743 | @thumbnail-border:            #ddd;
744 | //** Thumbnail border radius
745 | @thumbnail-border-radius:     @border-radius-base;
746 | 
747 | //** Custom text color for thumbnail captions
748 | @thumbnail-caption-color:     @text-color;
749 | //** Padding around the thumbnail caption
750 | @thumbnail-caption-padding:   9px;
751 | 
752 | 
753 | //== Wells
754 | //
755 | //##
756 | 
757 | @well-bg:                     #f5f5f5;
758 | @well-border:                 darken(@well-bg, 7%);
759 | 
760 | 
761 | //== Badges
762 | //
763 | //##
764 | 
765 | @badge-color:                 #fff;
766 | //** Linked badge text color on hover
767 | @badge-link-hover-color:      #fff;
768 | @badge-bg:                    @gray-light;
769 | 
770 | //** Badge text color in active nav link
771 | @badge-active-color:          @link-color;
772 | //** Badge background color in active nav link
773 | @badge-active-bg:             #fff;
774 | 
775 | @badge-font-weight:           bold;
776 | @badge-line-height:           1;
777 | @badge-border-radius:         10px;
778 | 
779 | 
780 | //== Breadcrumbs
781 | //
782 | //##
783 | 
784 | @breadcrumb-padding-vertical:   8px;
785 | @breadcrumb-padding-horizontal: 15px;
786 | //** Breadcrumb background color
787 | @breadcrumb-bg:                 @theme-gray-dark;
788 | //** Breadcrumb text color
789 | @breadcrumb-color:              @theme-text-color;
790 | //** Text color of current page in the breadcrumb
791 | @breadcrumb-active-color:       @theme-gray-light;
792 | //** Textual separator for between breadcrumb elements
793 | @breadcrumb-separator:          "/";
794 | 
795 | 
796 | //== Carousel
797 | //
798 | //##
799 | 
800 | @carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
801 | 
802 | @carousel-control-color:                      #fff;
803 | @carousel-control-width:                      15%;
804 | @carousel-control-opacity:                    .5;
805 | @carousel-control-font-size:                  20px;
806 | 
807 | @carousel-indicator-active-bg:                #fff;
808 | @carousel-indicator-border-color:             #fff;
809 | 
810 | @carousel-caption-color:                      #fff;
811 | 
812 | 
813 | //== Close
814 | //
815 | //##
816 | 
817 | @close-font-weight:           bold;
818 | @close-color:                 #000;
819 | @close-text-shadow:           0 1px 0 #fff;
820 | 
821 | 
822 | //== Code
823 | //
824 | //##
825 | 
826 | @code-color:                  #c7254e;
827 | @code-bg:                     #f9f2f4;
828 | 
829 | @kbd-color:                   #fff;
830 | @kbd-bg:                      #333;
831 | 
832 | @pre-bg:                      #f5f5f5;
833 | @pre-color:                   @gray-dark;
834 | @pre-border-color:            #ccc;
835 | @pre-scrollable-max-height:   340px;
836 | 
837 | 
838 | //== Type
839 | //
840 | //##
841 | 
842 | //** Horizontal offset for forms and lists.
843 | @component-offset-horizontal: 180px;
844 | //** Text muted color
845 | @text-muted:                  @gray-light;
846 | //** Abbreviations and acronyms border color
847 | @abbr-border-color:           @gray-light;
848 | //** Headings small color
849 | @headings-small-color:        @gray-light;
850 | //** Blockquote small color
851 | @blockquote-small-color:      @gray-light;
852 | //** Blockquote font size
853 | @blockquote-font-size:        (@font-size-base * 1.25);
854 | //** Blockquote border color
855 | @blockquote-border-color:     @gray-lighter;
856 | //** Page header border color
857 | @page-header-border-color:    @gray-lighter;
858 | //** Width of horizontal description list titles
859 | @dl-horizontal-offset:        @component-offset-horizontal;
860 | //** Horizontal line color.
861 | @hr-border:                   @gray-lighter;
862 | 


--------------------------------------------------------------------------------
/public/styles/bootstrap.less:
--------------------------------------------------------------------------------
 1 | @bootstrapBase: "@{bowerBase}/bootstrap/less";
 2 | 
 3 | // Core variables and mixins
 4 | @import "@{bootstrapBase}/variables.less";
 5 | // Overwrite custom variables
 6 | @import "bootstrap-variables.less";
 7 | @import "@{bootstrapBase}/mixins.less";
 8 | 
 9 | // Reset and dependencies
10 | @import "@{bootstrapBase}/normalize.less";
11 | @import "@{bootstrapBase}/print.less";
12 | @import "@{bootstrapBase}/glyphicons.less";
13 | 
14 | // Core CSS
15 | @import "@{bootstrapBase}/scaffolding.less";
16 | @import "@{bootstrapBase}/type.less";
17 | //@import "@{bootstrapBase}/code.less";
18 | @import "@{bootstrapBase}/grid.less";
19 | @import "@{bootstrapBase}/tables.less";
20 | @import "@{bootstrapBase}/forms.less";
21 | @import "@{bootstrapBase}/buttons.less";
22 | 
23 | // Components
24 | @import "@{bootstrapBase}/component-animations.less";
25 | @import "@{bootstrapBase}/dropdowns.less";
26 | @import "@{bootstrapBase}/button-groups.less";
27 | @import "@{bootstrapBase}/input-groups.less";
28 | @import "@{bootstrapBase}/navs.less";
29 | @import "@{bootstrapBase}/navbar.less";
30 | @import "@{bootstrapBase}/breadcrumbs.less";
31 | //@import "@{bootstrapBase}/pagination.less";
32 | //@import "@{bootstrapBase}/pager.less";
33 | //@import "@{bootstrapBase}/labels.less";
34 | //@import "@{bootstrapBase}/badges.less";
35 | //@import "@{bootstrapBase}/jumbotron.less";
36 | //@import "@{bootstrapBase}/thumbnails.less";
37 | //@import "@{bootstrapBase}/alerts.less";
38 | //@import "@{bootstrapBase}/progress-bars.less";
39 | //@import "@{bootstrapBase}/media.less";
40 | @import "@{bootstrapBase}/list-group.less";
41 | //@import "@{bootstrapBase}/panels.less";
42 | //@import "@{bootstrapBase}/responsive-embed.less";
43 | //@import "@{bootstrapBase}/wells.less";
44 | @import "@{bootstrapBase}/close.less";
45 | 
46 | // Components w/ JavaScript
47 | //@import "@{bootstrapBase}/modals.less";
48 | //@import "@{bootstrapBase}/tooltip.less";
49 | //@import "@{bootstrapBase}/popovers.less";
50 | //@import "@{bootstrapBase}/carousel.less";
51 | 
52 | // Utility classes
53 | @import "@{bootstrapBase}/utilities.less";
54 | @import "@{bootstrapBase}/responsive-utilities.less";
55 | 
56 | 
57 | /**
58 |  * Bootstrap fixes
59 |  */
60 | 
61 | /* fixed bottom navbar */
62 | body {
63 |   padding-top: 70px;
64 | }
65 | /* Override table cell top-alignment */
66 | .playlists > tbody > tr > td {
67 |   vertical-align: middle;
68 | }


--------------------------------------------------------------------------------
/public/styles/fontawesome.less:
--------------------------------------------------------------------------------
 1 | @fontawesomeBase: "@{bowerBase}/fontawesome/less";
 2 | /*!
 3 |  *  Font Awesome 4.3.0 by @davegandy - http://fontawesome.io - @fontawesome
 4 |  *  License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License)
 5 |  */
 6 | 
 7 | @import "@{fontawesomeBase}/variables.less";
 8 | @import "@{fontawesomeBase}/mixins.less";
 9 | @import "@{fontawesomeBase}/path.less";
10 | @import "@{fontawesomeBase}/core.less";
11 | @import "@{fontawesomeBase}/larger.less";
12 | @import "@{fontawesomeBase}/fixed-width.less";
13 | @import "@{fontawesomeBase}/list.less";
14 | @import "@{fontawesomeBase}/bordered-pulled.less";
15 | @import "@{fontawesomeBase}/animated.less";
16 | @import "@{fontawesomeBase}/rotated-flipped.less";
17 | @import "@{fontawesomeBase}/stacked.less";
18 | @import "@{fontawesomeBase}/icons.less";
19 | 


--------------------------------------------------------------------------------
/public/styles/slider.less:
--------------------------------------------------------------------------------
 1 | .slider {
 2 |   position: relative;
 3 |   margin-left: 7px;
 4 |   margin-right: 7px;
 5 |   height: 15px;
 6 |   .bg,
 7 |   .current {
 8 |     position: absolute;
 9 |     top: 8px;
10 |     height: 4px;
11 |   }
12 |   .bg {
13 |     background-color: @theme-gray-dark;
14 |     width: 100%;
15 |   }
16 |   .current {
17 |     background-color: @theme-gray-light;
18 |   }
19 |   .icon {
20 |     position: absolute;
21 |     margin-left: -9px;
22 |   }
23 | }
24 | 


--------------------------------------------------------------------------------
/public/styles/track-timer.less:
--------------------------------------------------------------------------------
 1 | .track-timer {
 2 |   width: 100%;
 3 |   margin-bottom: 0.5 * @line-height-computed;
 4 |   .current {
 5 |     display: table-cell;
 6 |     padding-right: 2px;
 7 |   }
 8 |   .track-slider {
 9 |     display: table-cell;
10 |     width: 100%;
11 |   }
12 |   .total {
13 |     display: table-cell;
14 |     padding-left: 2px;
15 |   }
16 | }


--------------------------------------------------------------------------------
/public/styles/variables.less:
--------------------------------------------------------------------------------
 1 | // Colors from http://labs.voronianski.com/oceanic-next-color-scheme
 2 | @theme-gray-base:              #1B2B34; // #000;
 3 | @theme-gray-darker:            #343D46; // lighten(@gray-base, 13.5%); // #222
 4 | @theme-gray-dark:              #4F5B66; // lighten(@gray-base, 20%);   // #333
 5 | @theme-gray:                   #65737E; // lighten(@gray-base, 33.5%); // #555
 6 | @theme-gray-light:             #A7ADBA; // lighten(@gray-base, 46.7%); // #777
 7 | @theme-gray-lighter:           #C0C5CE; // lighten(@gray-base, 93.5%); // #eee
 8 | 
 9 | @theme-body-bg: #343D46;
10 | @theme-text-color: #d8dee9;


--------------------------------------------------------------------------------
/public/styles/volume.less:
--------------------------------------------------------------------------------
 1 | .volume {
 2 |   margin-top: 15px;
 3 |   width: 100%;
 4 |   padding-left: 15px;
 5 |   padding-right: 15px;
 6 |   .icon {
 7 |     display: table-cell;
 8 |     padding-right: 2px;
 9 |   }
10 |   .volume-slider {
11 |     display: table-cell;
12 |     width: 100%;
13 |   }
14 | }


--------------------------------------------------------------------------------