├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile.docker ├── README.md ├── connect.py ├── connect_ffi.py ├── console_callbacks.py ├── lastfm.py ├── lastfm_credentials.json.dist ├── main.py ├── requirements.txt ├── run-with-docker ├── spotify.h ├── spotify.processed.h ├── static ├── css │ └── bootstrap-slider.css └── js │ ├── bootstrap-slider.min.js │ └── main.js ├── templates └── index.html └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | Makefile.docker 3 | spotify-connect-web.tar.gz 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | spotify_appkey.key 4 | libspotify_embedded_shared.so 5 | venv 6 | credentials.json 7 | spotify-connect-web.tar.gz 8 | lastfm_credentials.json 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM armelbuild/debian:jessie 2 | ENV DEBIAN_FRONTEND noninteractive 3 | RUN apt-get update 4 | 5 | RUN apt-get -y install python-pip python-dev libffi-dev libasound2-dev python-alsaaudio python-gevent libssl-dev 6 | RUN apt-get -y install alsa-utils 7 | 8 | RUN wget -q -O /usr/lib/libspotify_embedded_shared.so https://github.com/sashahilton00/spotify-connect-resources/raw/master/Rocki%20Firmware/dlna_upnp/spotify/lib/libspotify_embedded_shared.so 9 | 10 | ADD requirements.txt /usr/src/app/requirements.txt 11 | WORKDIR /usr/src/app 12 | RUN pip install -r requirements.txt 13 | 14 | ADD . /usr/src/app 15 | 16 | ENTRYPOINT ["python", "main.py"] 17 | EXPOSE 4000 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Fornoth 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile.docker: -------------------------------------------------------------------------------- 1 | IMAGE = spotify-connect-web 2 | BUCKET = ${IMAGE} 3 | 4 | spotify-connect-web.tar.gz: docker-image 5 | cid=$$(docker create $(IMAGE)) ;\ 6 | docker export $$cid | gzip -c >$@ ;\ 7 | status=$$? ;\ 8 | docker rm $$cid;\ 9 | exit $$status 10 | 11 | docker-image: 12 | docker build -t ${IMAGE} . 13 | 14 | upload: spotify-connect-web.tar.gz 15 | s3cmd --access_key=${ACCESS_KEY} --secret_key=${SECRET_KEY} put spotify-connect-web.tar.gz s3://${BUCKET} 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify Connect Web 2 | 3 | This is based off of the example code from https://github.com/plietar/spotify-connect 4 | 5 | ## Quickstart using a packaged release 6 | This is a version of spotify-connect-web with all dependencies bundled (about 7MB compressed, 13MB extracted) 7 | For armv7+ (Rpi 2, Rpi 3, etc, but not Rpi 1/Rpi Zero) devices only for now 8 | 9 | Grab the latest release from [Releases](https://github.com/Fornoth/spotify-connect-web/releases) 10 | ### Installation instructions (example): 11 | ``` 12 | wget https://github.com/Fornoth/spotify-connect-web/releases/download/0.0.4-alpha/spotify-connect-web_0.0.4-alpha.tar.gz 13 | tar zxvf spotify-connect-web_0.0.4-alpha.tar.gz 14 | ``` 15 | A `spotify-connect-web` directory will be created, and you'll need to put your `spotify_appkey.key` in that directory 16 | 17 | ### Running: 18 | Just run `./spotify-connect-web` in the extracted directory 19 | Supports the same options as the regular version 20 | 21 | 22 | ## Quickstart using a pre-built chroot 23 | Grab the latest release from [Releases](https://github.com/Fornoth/spotify-connect-web/releases) 24 | 25 | If you just want to get running, you can use a pre-built chroot with the latest version installed. 26 | ### Installation instructions (example): 27 | 28 | curl -O curl -OL https://github.com/Fornoth/spotify-connect-web/releases/download/0.0.4-alpha/spotify-connect-web.sh 29 | chmod u+x spotify-connect-web.sh 30 | # Download the current chroot (~ 180 MB) 31 | ./spotify-connect-web.sh install 32 | # Copy your `spotify_appkey.key` into the app directory. (See below for information on how to get that file.) 33 | sudo cp spotify_appkey.key spotify-connect-web-chroot/usr/src/app/ 34 | # Run using normal cmdline options 35 | ./spotify-connect-web.sh --username 12345678 --password xyz123 --bitrate 320 36 | 37 | (~~Btw, the chroot is built nightly from master using Docker on a C1.~~ Manually built for now. See the [Makefile](Makefile.docker) for details.) 38 | 39 | ## Quickstart with Docker 40 | (You will have to use `sudo` if not logged in as root.) 41 | 42 | * Get Docker running on your machine. (See [this preliminary documentation](https://github.com/aetherical/docker/blob/master/docs/sources/installation/raspberrypi.md) for advice.) 43 | * Get your `spotify_appkey.key` and put it into the base directory. (See below for details.) 44 | * Build the container via `docker build -t spotify-connect-web .` 45 | * Run it via `~/run-with-docker`. 46 | 47 | ## Installation from source 48 | Requires development packages for `Python`, `FFI`, and `Alsa` 49 | - For Debian/Ubuntu: `apt-get install python-dev libffi-dev libasound2-dev` 50 | 51 | To install the other requirements: `pip install -r requirements.txt` 52 | 53 | ## Usage 54 | ``` 55 | usage: main.py [-h] [--device DEVICE | --playback_device PLAYBACK_DEVICE] 56 | [--mixer_device_index MIXER_DEVICE_INDEX] [--mixer MIXER] 57 | [--dbrange DBRANGE] [--cors CORS] [--debug] [--key KEY] 58 | [--username USERNAME] [--password PASSWORD] [--name NAME] 59 | [--bitrate {90,160,320}] [--credentials CREDENTIALS] 60 | 61 | Web interface for Spotify Connect 62 | 63 | optional arguments: 64 | -h, --help show this help message and exit 65 | --device DEVICE, -D DEVICE 66 | alsa output device (deprecated, use --playback_device) 67 | --playback_device PLAYBACK_DEVICE, -o PLAYBACK_DEVICE 68 | alsa output device (get name from aplay -L) 69 | --mixer_device_index MIXER_DEVICE_INDEX 70 | alsa card index of the mixer device 71 | --mixer MIXER, -m MIXER 72 | alsa mixer name for volume control 73 | --dbrange DBRANGE, -r DBRANGE 74 | alsa mixer volume range in Db 75 | --lastfm_username LASTFM_USERNAME 76 | your Last.fm username 77 | --lastfm_password LASTFM_PASSWORD 78 | your Last.fm password 79 | --lastfm_api_key LASTFM_API_KEY 80 | your Last.fm API key 81 | --lastfm_api_secret LASTFM_API_SECRET 82 | your Last.fm API secret 83 | --lastfm_credentials LASTFM_CREDENTIALS 84 | file to load Last.fm credentials from 85 | --cors CORS enable CORS support for this host (for the web api). 86 | Must be in the format ://:. 87 | Port can be excluded if its 80 (http) or 443 (https). 88 | Can be specified multiple times 89 | --debug, -d enable libspotify_embedded/flask debug output 90 | --key KEY, -k KEY path to spotify_appkey.key (can be obtained from 91 | https://developer.spotify.com/my-account/keys ) 92 | --username USERNAME, -u USERNAME 93 | your spotify username 94 | --password PASSWORD, -p PASSWORD 95 | your spotify password 96 | --name NAME, -n NAME name that shows up in the spotify client 97 | --bitrate {90,160,320}, -b {90,160,320} 98 | Sets bitrate of audio stream (may not actually work) 99 | --credentials CREDENTIALS, -c CREDENTIALS 100 | File to load and save credentials from/to 101 | 102 | ``` 103 | 104 | `libspotify_embedded_shared.so` must be in the same directory as the python scripts. 105 | Also requires a spotify premium account, and the `spotify_appkey.key` (the binary version) file can be be obtained from https://developer.spotify.com/technologies/libspotify/application-keys/. Fill the 'App-key Request Form' in, send it and wait until you get the key sent via email (it can take a few weeks...). 106 | 107 | After receiving it, you need to place it in the python scripts directory, or have the path specified with the `-k` parameter 108 | 109 | ### Launching from source 110 | - Running without debug output `LD_LIBRARY_PATH=$PWD python main.py` 111 | - Running with debug output `LD_LIBRARY_PATH=$PWD python main.py -d` 112 | - Run with only flask debug output (flask debug output allows you to see the python exceptions that are thrown) `DEBUG=true LD_LIBRARY_PATH=$PWD python main.py` 113 | - Can also be run without the web server (Requires username and password to be passed in as parameters) `LD_LIBRARY_PATH=$PWD python connect.py -u username -p password` 114 | 115 | ### Headers 116 | Generated with `cpp spotify.h > spotify.processed.h && sed -i 's/__extension__//g' spotify.processed.h` 117 | `spotify.h` was taken from from https://github.com/plietar/spotify-connect 118 | 119 | ## Web server 120 | Server runs on port `4000` 121 | 122 | ## Logging in 123 | After logging in successfully, a blob is sent by Spotify and saved to disk (to `credentials.json` by default), and is use to login automatically on next startup. 124 | 125 | ### Username/Password 126 | There's a login button on the webpage to enter a username and password, or you can pass the `--username` and `--password` arguments 127 | 128 | ### Last.fm 129 | If you want to enable Last.fm scrobbling, you should first obtain API key at http://www.last.fm/api/account/create. You can pass your `--lastfm_username`, `--lastfm_password`, `--lastfm_api_key` and `--lastfm_api_secret` on the command line. You can also use `lastfm_credentials.json` and pass `--lastfm_credentials lastfm_credentials.json` to the command line. You can find an example of the file format in `lastfm_credentials.json.dist`. You need to explicitly pass the credentials file, otherwise the Last.fm module will not launch. 130 | 131 | ### Passwordless/Multiuser (Zeroconf/Avahi) 132 | Zeroconf (Avahi) login can be used after executing the command `avahi-publish-service TestConnect _spotify-connect._tcp 4000 VERSION=1.0 CPath=/login/_zeroconf` (`avahi-publish-service` is in the `avahi-utils` package). 133 | 134 | ## Support 135 | You can [file an issue](https://github.com/Fornoth/spotify-connect-web/issues/new) or come to the [Gitter chat](https://gitter.im/sashahilton00/spotify-connect-resources) 136 | -------------------------------------------------------------------------------- /connect.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import argparse 4 | import signal 5 | import sys 6 | import json 7 | import uuid 8 | from connect_ffi import ffi, lib, C 9 | from console_callbacks import audio_arg_parser, mixer, error_callback, connection_callbacks, debug_callbacks, playback_callbacks, playback_setup 10 | from lastfm import lastfm_arg_parser 11 | from utils import print_zeroconf_vars 12 | 13 | class Connect: 14 | def __init__(self, error_cb = error_callback, web_arg_parser = None): 15 | arg_parsers = [audio_arg_parser, lastfm_arg_parser] 16 | if web_arg_parser: 17 | arg_parsers.append(web_arg_parser) 18 | arg_parser = argparse.ArgumentParser(description='Web interface for Spotify Connect', parents=arg_parsers) 19 | arg_parser.add_argument('--debug', '-d', help='enable libspotify_embedded/flask debug output', action="store_true") 20 | arg_parser.add_argument('--key', '-k', help='path to spotify_appkey.key (can be obtained from https://developer.spotify.com/my-account/keys )', default='spotify_appkey.key') 21 | arg_parser.add_argument('--username', '-u', help='your spotify username') 22 | arg_parser.add_argument('--password', '-p', help='your spotify password') 23 | arg_parser.add_argument('--name', '-n', help='name that shows up in the spotify client', default='TestConnect') 24 | arg_parser.add_argument('--bitrate', '-b', help='Sets bitrate of audio stream (may not actually work)', choices=[90, 160, 320], type=int, default=160) 25 | arg_parser.add_argument('--credentials', '-c', help='File to load and save credentials from/to', default='credentials.json') 26 | self.args = arg_parser.parse_args() 27 | 28 | print "Using libspotify_embedded version: {}".format(ffi.string(lib.SpGetLibraryVersion())) 29 | 30 | try: 31 | with open(self.args.key) as f: 32 | app_key = ffi.new('uint8_t *') 33 | f.readinto(ffi.buffer(app_key)) 34 | app_key_size = len(f.read()) + 1 35 | except IOError as e: 36 | print "Error opening app key: {}.".format(e) 37 | print "If you don't have one, it can be obtained from https://developer.spotify.com/my-account/keys" 38 | sys.exit(1) 39 | 40 | 41 | self.credentials = dict({ 42 | 'device-id': str(uuid.uuid4()), 43 | 'username': None, 44 | 'blob': None 45 | }) 46 | 47 | try: 48 | with open(self.args.credentials) as f: 49 | self.credentials.update( 50 | { k: v.encode('utf-8') if isinstance(v, unicode) else v 51 | for (k,v) 52 | in json.loads(f.read()).iteritems() }) 53 | except IOError: 54 | pass 55 | 56 | if self.args.username: 57 | self.credentials['username'] = self.args.username 58 | 59 | userdata = ffi.new_handle(self) 60 | 61 | if self.args.debug: 62 | lib.SpRegisterDebugCallbacks(debug_callbacks, userdata) 63 | 64 | self.config = { 65 | 'version': 4, 66 | 'buffer': C.malloc(0x100000), 67 | 'buffer_size': 0x100000, 68 | 'app_key': app_key, 69 | 'app_key_size': app_key_size, 70 | 'deviceId': ffi.new('char[]', self.credentials['device-id']), 71 | 'remoteName': ffi.new('char[]', self.args.name), 72 | 'brandName': ffi.new('char[]', 'DummyBrand'), 73 | 'modelName': ffi.new('char[]', 'DummyModel'), 74 | 'client_id': ffi.new('char[]', '0'), 75 | 'deviceType': lib.kSpDeviceTypeAudioDongle, 76 | 'error_callback': error_cb, 77 | 'userdata': userdata, 78 | } 79 | 80 | init = ffi.new('SpConfig *' , self.config) 81 | init_status = lib.SpInit(init) 82 | print "SpInit: {}".format(init_status) 83 | if init_status != 0: 84 | print "SpInit failed, exiting" 85 | sys.exit(1) 86 | 87 | lib.SpRegisterConnectionCallbacks(connection_callbacks, userdata) 88 | lib.SpRegisterPlaybackCallbacks(playback_callbacks, userdata) 89 | 90 | mixer_volume = int(mixer.getvolume()[0] * 655.35) 91 | lib.SpPlaybackUpdateVolume(mixer_volume) 92 | 93 | bitrates = { 94 | 90: lib.kSpBitrate90k, 95 | 160: lib.kSpBitrate160k, 96 | 320: lib.kSpBitrate320k 97 | } 98 | 99 | lib.SpPlaybackSetBitrate(bitrates[self.args.bitrate]) 100 | 101 | playback_setup() 102 | 103 | print_zeroconf_vars() 104 | 105 | if self.credentials['username'] and self.args.password: 106 | self.login(password=self.args.password) 107 | elif self.credentials['username'] and self.credentials['blob']: 108 | self.login(blob=self.credentials['blob']) 109 | else: 110 | if __name__ == '__main__': 111 | raise ValueError("No username given, and none stored") 112 | 113 | def login(self, username=None, password=None, blob=None, zeroconf=None): 114 | if username is not None: 115 | self.credentials['username'] = username 116 | elif self.credentials['username']: 117 | username = self.credentials['username'] 118 | else: 119 | raise ValueError("No username given, and none stored") 120 | 121 | if password is not None: 122 | lib.SpConnectionLoginPassword(username, password) 123 | elif blob is not None: 124 | lib.SpConnectionLoginBlob(username, blob) 125 | elif zeroconf is not None: 126 | lib.SpConnectionLoginZeroConf(username, *zeroconf) 127 | else: 128 | raise ValueError("Must specify a login method (password, blob or zeroconf)") 129 | 130 | def signal_handler(signal, frame): 131 | lib.SpConnectionLogout() 132 | lib.SpFree() 133 | sys.exit(0) 134 | 135 | signal.signal(signal.SIGINT, signal_handler) 136 | signal.signal(signal.SIGTERM, signal_handler) 137 | 138 | #Only run if script is run directly and not by an import 139 | if __name__ == "__main__": 140 | @ffi.callback('void(SpError err, void *userdata)') 141 | def console_error_callback(error, userdata): 142 | if error == lib.kSpErrorLoginBadCredentials: 143 | print 'Invalid username or password' 144 | #sys.exit() doesn't work inside of a ffi callback 145 | C.exit(1) 146 | else: 147 | error_callback(msg) 148 | connect = Connect(console_error_callback) 149 | 150 | while 1: 151 | lib.SpPumpEvents() 152 | -------------------------------------------------------------------------------- /connect_ffi.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from cffi import FFI 4 | ffi = FFI() 5 | 6 | print "Loading Spotify library..." 7 | #TODO: Use absolute paths for open() and stuff 8 | #Header generated with cpp spotify.h > spotify.processed.h && sed -i 's/__extension__//g' spotify.processed.h 9 | with open(os.path.join(sys.path[0], "spotify.processed.h")) as file: 10 | header = file.read() 11 | 12 | ffi.cdef(header) 13 | ffi.cdef(""" 14 | void *malloc(size_t size); 15 | void exit(int status); 16 | """) 17 | 18 | C = ffi.dlopen(None) 19 | lib = ffi.verify(""" 20 | #include "spotify.h" 21 | """, include_dirs=['./'], 22 | library_dirs=['./'], 23 | libraries=[str('spotify_embedded_shared')]) 24 | -------------------------------------------------------------------------------- /console_callbacks.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | import argparse 3 | import alsaaudio as alsa 4 | import json 5 | import Queue 6 | from threading import Thread 7 | import threading 8 | from connect_ffi import ffi, lib 9 | from lastfm import lastfm 10 | 11 | 12 | RATE = 44100 13 | CHANNELS = 2 14 | PERIODSIZE = int(44100 / 4) # 0.25s 15 | SAMPLESIZE = 2 # 16 bit integer 16 | MAXPERIODS = int(0.5 * RATE / PERIODSIZE) # 0.5s Buffer 17 | 18 | audio_arg_parser = argparse.ArgumentParser(add_help=False) 19 | 20 | playback_device_group = audio_arg_parser.add_mutually_exclusive_group() 21 | playback_device_group.add_argument('--device', '-D', help='alsa output device (deprecated, use --playback_device)', default='default') 22 | playback_device_group.add_argument('--playback_device', '-o', help='alsa output device (get name from aplay -L)', default='default') 23 | 24 | audio_arg_parser.add_argument('--mixer_device_index', help='alsa card index of the mixer device', type=int) 25 | audio_arg_parser.add_argument('--mixer', '-m', help='alsa mixer name for volume control', default=alsa.mixers()[0]) 26 | audio_arg_parser.add_argument('--dbrange', '-r', help='alsa mixer volume range in Db', default=0) 27 | args = audio_arg_parser.parse_known_args()[0] 28 | 29 | class PlaybackSession: 30 | 31 | def __init__(self): 32 | self._active = False 33 | 34 | def is_active(self): 35 | return self._active 36 | 37 | def activate(self): 38 | self._active = True 39 | 40 | def deactivate(self): 41 | self._active = False 42 | 43 | class AlsaSink: 44 | 45 | def __init__(self, session, args): 46 | self._lock = threading.Lock() 47 | self._args = args 48 | self._session = session 49 | self._device = None 50 | 51 | def acquire(self): 52 | if self._session.is_active(): 53 | try: 54 | pcm_args = { 55 | 'type': alsa.PCM_PLAYBACK, 56 | 'mode': alsa.PCM_NORMAL, 57 | } 58 | if self._args.playback_device != 'default': 59 | pcm_args['device'] = self._args.playback_device 60 | else: 61 | pcm_args['card'] = self._args.device 62 | pcm = alsa.PCM(**pcm_args) 63 | 64 | pcm.setchannels(CHANNELS) 65 | pcm.setrate(RATE) 66 | pcm.setperiodsize(PERIODSIZE) 67 | pcm.setformat(alsa.PCM_FORMAT_S16_LE) 68 | 69 | self._device = pcm 70 | print "AlsaSink: device acquired" 71 | except alsa.ALSAAudioError as error: 72 | print "Unable to acquire device: ", error 73 | self.release() 74 | 75 | 76 | def release(self): 77 | if self._session.is_active() and self._device is not None: 78 | self._lock.acquire() 79 | try: 80 | if self._device is not None: 81 | self._device.close() 82 | self._device = None 83 | print "AlsaSink: device released" 84 | finally: 85 | self._lock.release() 86 | 87 | def write(self, data): 88 | if self._session.is_active() and self._device is not None: 89 | # write is asynchronous, so, we are in race with releasing the device 90 | self._lock.acquire() 91 | try: 92 | if self._device is not None: 93 | self._device.write(data) 94 | except alsa.ALSAAudioError as error: 95 | print "Ups! Some badness happened: ", error 96 | finally: 97 | self._lock.release() 98 | 99 | session = PlaybackSession() 100 | device = AlsaSink(session, args) 101 | mixer_card_arg = {} 102 | if args.mixer_device_index: 103 | mixer_card_arg['cardindex'] = args.mixer_device_index 104 | 105 | mixer = alsa.Mixer(args.mixer, **mixer_card_arg) 106 | 107 | try: 108 | mixer.getmute() 109 | mute_available = True 110 | except alsa.ALSAAudioError: 111 | mute_available = False 112 | print "Device has no native mute" 113 | 114 | #Gets mimimum volume Db for the mixer 115 | volume_range = (mixer.getrange()[1]-mixer.getrange()[0]) 116 | selected_volume_range = int(args.dbrange) 117 | if selected_volume_range > volume_range or selected_volume_range == 0: 118 | selected_volume_range = volume_range 119 | min_volume_range = int((1 - float(selected_volume_range) / volume_range) * 100) 120 | print "min_volume_range: {}".format(min_volume_range) 121 | 122 | def userdata_wrapper(f): 123 | def inner(*args): 124 | assert len(args) > 0 125 | self = ffi.from_handle(args[-1]) 126 | return f(self, *args[:-1]) 127 | return inner 128 | 129 | #Error callbacks 130 | @ffi.callback('void(SpError error, void *userdata)') 131 | def error_callback(error, userdata): 132 | print "error_callback: {}".format(error) 133 | 134 | #Connection callbacks 135 | @ffi.callback('void(SpConnectionNotify type, void *userdata)') 136 | @userdata_wrapper 137 | def connection_notify(self, type): 138 | if type == lib.kSpConnectionNotifyLoggedIn: 139 | print "kSpConnectionNotifyLoggedIn" 140 | elif type == lib.kSpConnectionNotifyLoggedOut: 141 | print "kSpConnectionNotifyLoggedOut" 142 | elif type == lib.kSpConnectionNotifyTemporaryError: 143 | print "kSpConnectionNotifyTemporaryError" 144 | else: 145 | print "UNKNOWN ConnectionNotify {}".format(type) 146 | 147 | @ffi.callback('void(const char *blob, void *userdata)') 148 | @userdata_wrapper 149 | def connection_new_credentials(self, blob): 150 | print ffi.string(blob) 151 | self.credentials['blob'] = ffi.string(blob) 152 | 153 | with open(self.args.credentials, 'w') as f: 154 | f.write(json.dumps(self.credentials)) 155 | 156 | #Debug callbacks 157 | @ffi.callback('void(const char *msg, void *userdata)') 158 | @userdata_wrapper 159 | def debug_message(self, msg): 160 | print ffi.string(msg) 161 | 162 | #Playback callbacks 163 | @ffi.callback('void(SpPlaybackNotify type, void *userdata)') 164 | @userdata_wrapper 165 | def playback_notify(self, type): 166 | if type == lib.kSpPlaybackNotifyPlay: 167 | print "kSpPlaybackNotifyPlay" 168 | device.acquire() 169 | lastfm.play() 170 | elif type == lib.kSpPlaybackNotifyPause: 171 | print "kSpPlaybackNotifyPause" 172 | lastfm.pause() 173 | device.release() 174 | elif type == lib.kSpPlaybackNotifyTrackChanged: 175 | print "kSpPlaybackNotifyTrackChanged" 176 | lastfm.track_changed() 177 | elif type == lib.kSpPlaybackNotifyNext: 178 | print "kSpPlaybackNotifyNext" 179 | elif type == lib.kSpPlaybackNotifyPrev: 180 | print "kSpPlaybackNotifyPrev" 181 | elif type == lib.kSpPlaybackNotifyShuffleEnabled: 182 | print "kSpPlaybackNotifyShuffleEnabled" 183 | elif type == lib.kSpPlaybackNotifyShuffleDisabled: 184 | print "kSpPlaybackNotifyShuffleDisabled" 185 | elif type == lib.kSpPlaybackNotifyRepeatEnabled: 186 | print "kSpPlaybackNotifyRepeatEnabled" 187 | elif type == lib.kSpPlaybackNotifyRepeatDisabled: 188 | print "kSpPlaybackNotifyRepeatDisabled" 189 | elif type == lib.kSpPlaybackNotifyBecameActive: 190 | print "kSpPlaybackNotifyBecameActive" 191 | session.activate() 192 | elif type == lib.kSpPlaybackNotifyBecameInactive: 193 | print "kSpPlaybackNotifyBecameInactive" 194 | device.release() 195 | session.deactivate() 196 | elif type == lib.kSpPlaybackNotifyPlayTokenLost: 197 | print "kSpPlaybackNotifyPlayTokenLost" 198 | elif type == lib.kSpPlaybackEventAudioFlush: 199 | print "kSpPlaybackEventAudioFlush" 200 | #audio_flush(); 201 | else: 202 | print "UNKNOWN PlaybackNotify {}".format(type) 203 | 204 | def playback_thread(q): 205 | while True: 206 | data = q.get() 207 | device.write(data) 208 | q.task_done() 209 | 210 | audio_queue = Queue.Queue(maxsize=MAXPERIODS) 211 | pending_data = str() 212 | 213 | def playback_setup(): 214 | t = Thread(args=(audio_queue,), target=playback_thread) 215 | t.daemon = True 216 | t.start() 217 | 218 | @ffi.callback('uint32_t(const void *data, uint32_t num_samples, SpSampleFormat *format, uint32_t *pending, void *userdata)') 219 | @userdata_wrapper 220 | def playback_data(self, data, num_samples, format, pending): 221 | global pending_data 222 | 223 | # Make sure we don't pass incomplete frames to alsa 224 | num_samples -= num_samples % CHANNELS 225 | 226 | buf = pending_data + ffi.buffer(data, num_samples * SAMPLESIZE)[:] 227 | 228 | try: 229 | total = 0 230 | while len(buf) >= PERIODSIZE * CHANNELS * SAMPLESIZE: 231 | audio_queue.put(buf[:PERIODSIZE * CHANNELS * SAMPLESIZE], block=False) 232 | buf = buf[PERIODSIZE * CHANNELS * SAMPLESIZE:] 233 | total += PERIODSIZE * CHANNELS 234 | 235 | pending_data = buf 236 | return num_samples 237 | except Queue.Full: 238 | return total 239 | finally: 240 | pending[0] = audio_queue.qsize() * PERIODSIZE * CHANNELS 241 | 242 | @ffi.callback('void(uint32_t millis, void *userdata)') 243 | @userdata_wrapper 244 | def playback_seek(self, millis): 245 | print "playback_seek: {}".format(millis) 246 | 247 | @ffi.callback('void(uint16_t volume, void *userdata)') 248 | @userdata_wrapper 249 | def playback_volume(self, volume): 250 | print "playback_volume: {}".format(volume) 251 | if volume == 0: 252 | if mute_available: 253 | mixer.setmute(1) 254 | print "Mute activated" 255 | else: 256 | if mute_available and mixer.getmute()[0] == 1: 257 | mixer.setmute(0) 258 | print "Mute deactivated" 259 | corected_playback_volume = int(min_volume_range + ((volume / 655.35) * (100 - min_volume_range) / 100)) 260 | print "corected_playback_volume: {}".format(corected_playback_volume) 261 | mixer.setvolume(corected_playback_volume) 262 | 263 | connection_callbacks = ffi.new('SpConnectionCallbacks *', [ 264 | connection_notify, 265 | connection_new_credentials 266 | ]) 267 | 268 | debug_callbacks = ffi.new('SpDebugCallbacks *', [ 269 | debug_message 270 | ]) 271 | 272 | playback_callbacks = ffi.new('SpPlaybackCallbacks *', [ 273 | playback_notify, 274 | playback_data, 275 | playback_seek, 276 | playback_volume 277 | ]) 278 | -------------------------------------------------------------------------------- /lastfm.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | import pylast 4 | import time 5 | from connect_ffi import lib 6 | from utils import get_metadata 7 | 8 | lastfm_arg_parser = argparse.ArgumentParser(add_help=False) 9 | 10 | lastfm_arg_parser.add_argument('--lastfm_username', help='your Last.fm username', default=None) 11 | lastfm_arg_parser.add_argument('--lastfm_password', help='your Last.fm password', default=None) 12 | lastfm_arg_parser.add_argument('--lastfm_api_key', help='your Last.fm API key', default=None) 13 | lastfm_arg_parser.add_argument('--lastfm_api_secret', help='your Last.fm API secret', default=None) 14 | lastfm_arg_parser.add_argument('--lastfm_credentials', help='file to load Last.fm credentials from', default=None) 15 | 16 | args = lastfm_arg_parser.parse_known_args()[0] 17 | 18 | class LastFM: 19 | def __init__(self): 20 | self.credentials = dict({ 21 | 'username': None, 22 | 'password': None, 23 | 'api_key': None, 24 | 'api_secret': None 25 | }) 26 | 27 | if args.lastfm_credentials: 28 | with open(args.lastfm_credentials) as f: 29 | self.credentials.update( 30 | {k: v.encode('utf-8') if isinstance(v, unicode) else v 31 | for (k, v) 32 | in json.loads(f.read()).iteritems()}) 33 | 34 | if args.lastfm_username: 35 | self.credentials['username'] = args.lastfm_username 36 | if args.lastfm_password: 37 | self.credentials['password'] = args.lastfm_password 38 | if args.lastfm_api_key: 39 | self.credentials['api_key'] = args.lastfm_api_key 40 | if args.lastfm_api_secret: 41 | self.credentials['api_secret'] = args.lastfm_api_secret 42 | 43 | if not (self.credentials['username'] and 44 | self.credentials['password'] and 45 | self.credentials['api_key'] and 46 | self.credentials['api_secret']): 47 | self.on = False 48 | print 'Last.fm: incomplete credentials, not launched' 49 | return 50 | 51 | self.on = True 52 | self.lastfm_network = pylast.LastFMNetwork( 53 | api_key=self.credentials['api_key'], 54 | api_secret=self.credentials['api_secret'], 55 | username=self.credentials['username'], 56 | password_hash=pylast.md5(self.credentials['password']) 57 | ) 58 | self.metadata = None 59 | self.timestamp = None 60 | self.playing = bool(lib.SpPlaybackIsPlaying()) 61 | self.play_cumul = 0 62 | self.play_beg = time.time() 63 | 64 | # This two functions are used to count the playing time of each song 65 | def pause(self): 66 | if not self.on: 67 | return 68 | if self.playing: 69 | print "LastFM: add " + str(time.time() - self.play_beg) + " to total played time" 70 | self.play_cumul += time.time() - self.play_beg 71 | print "LastFM: total play time is " + str(self.play_cumul) 72 | self.playing = False 73 | 74 | def play(self): 75 | if not self.on: 76 | return 77 | if not self.playing: 78 | self.play_beg = time.time() 79 | self.playing = True 80 | 81 | def track_changed(self): 82 | if not self.on: 83 | return 84 | if not bool(lib.SpPlaybackIsActiveDevice()): 85 | return 86 | self.pause() 87 | # Scrobble last song only if the song has been played more than half 88 | # of its duration or during more than 4 minutes 89 | if self.metadata and self.play_cumul > min(self.metadata["duration"] / 2000, 240): 90 | self.lastfm_network.scrobble(artist=self.metadata["artist_name"], 91 | title=self.metadata["track_name"], 92 | timestamp=int(self.metadata["time_on"]), 93 | album=self.metadata["album_name"], 94 | duration=(self.metadata["duration"] / 1000)) 95 | print "LastFM: scrobbled track " + self.metadata["track_name"] + " - " + self.metadata["artist_name"] 96 | 97 | # Update now playing song 98 | self.play_cumul = 0 99 | self.play() 100 | self.metadata = get_metadata() 101 | self.metadata["time_on"] = time.time() 102 | self.lastfm_network.update_now_playing(artist=self.metadata["artist_name"], 103 | title=self.metadata["track_name"], album=self.metadata["album_name"], 104 | duration=int(self.metadata["duration"] / 1000)) 105 | 106 | lastfm = LastFM() 107 | -------------------------------------------------------------------------------- /lastfm_credentials.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "api_key": "Put your API Key here", 3 | "api_secret": "You can get your API key and secret on http://www.last.fm/api/account/create", 4 | "username": "username", 5 | "password": "password" 6 | } 7 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | #First run the command avahi-publish-service TestConnect _spotify-connect._tcp 4000 VERSION=1.0 CPath=/login/_zeroconf 3 | #TODO: Add error checking 4 | #TODO: Show when request fails on webpage 5 | import os 6 | import sys 7 | import argparse 8 | import re 9 | from flask import Flask, request, abort, jsonify, render_template, redirect, flash, url_for 10 | from flask_bootstrap import Bootstrap 11 | from flask_cors import CORS 12 | from gevent.wsgi import WSGIServer 13 | from gevent import spawn_later, sleep 14 | from connect_ffi import ffi, lib 15 | from connect import Connect 16 | from utils import get_zeroconf_vars, get_metadata, get_image_url 17 | 18 | web_arg_parser = argparse.ArgumentParser(add_help=False) 19 | 20 | #Not a tuple, evaluates the same as "" + "" 21 | cors_help = ( 22 | "enable CORS support for this host (for the web api). " 23 | "Must be in the format ://:. " 24 | "Port can be excluded if its 80 (http) or 443 (https). " 25 | "Can be specified multiple times" 26 | ) 27 | 28 | 29 | def validate_cors_host(host): 30 | host_regex = re.compile(r'^(http|https)://[a-zA-Z0-9][a-zA-Z0-9-.]+(:[0-9]{1,5})?$') 31 | result = re.match(host_regex, host) 32 | if result is None: 33 | raise argparse.ArgumentTypeError('%s is not in the format ://:. Protocol must be http or https' % host) 34 | return host 35 | 36 | web_arg_parser.add_argument('--cors', help=cors_help, action='append', type=validate_cors_host) 37 | args = web_arg_parser.parse_known_args()[0] 38 | 39 | app = Flask(__name__, root_path=sys.path[0]) 40 | Bootstrap(app) 41 | #Add CORS headers to API requests for specified hosts 42 | CORS(app, resources={r"/api/*": {"origins": args.cors}}) 43 | 44 | #Serve bootstrap files locally instead of from a CDN 45 | app.config['BOOTSTRAP_SERVE_LOCAL'] = True 46 | app.secret_key = os.urandom(24) 47 | 48 | #Used by the error callback to determine login status 49 | invalid_login = False 50 | 51 | @ffi.callback('void(SpError error, void *userdata)') 52 | def web_error_callback(error, userdata): 53 | global invalid_login 54 | if error == lib.kSpErrorLoginBadCredentials: 55 | invalid_login = True 56 | 57 | connect_app = Connect(web_error_callback, web_arg_parser) 58 | 59 | if os.environ.get('DEBUG') or connect_app.args.debug: 60 | app.debug = True 61 | 62 | ##Routes 63 | 64 | #Home page 65 | @app.route('/') 66 | def index(): 67 | return render_template('index.html') 68 | 69 | ##API routes 70 | 71 | #Playback routes 72 | @app.route('/api/playback/play') 73 | def playback_play(): 74 | lib.SpPlaybackPlay() 75 | return '', 204 76 | 77 | @app.route('/api/playback/pause') 78 | def playback_pause(): 79 | lib.SpPlaybackPause() 80 | return '', 204 81 | 82 | @app.route('/api/playback/prev') 83 | def playback_prev(): 84 | lib.SpPlaybackSkipToPrev() 85 | return '', 204 86 | 87 | @app.route('/api/playback/next') 88 | def playback_next(): 89 | lib.SpPlaybackSkipToNext() 90 | return '', 204 91 | 92 | 93 | #TODO: Add ability to disable shuffle/repeat 94 | @app.route('/api/playback/shuffle') 95 | def playback_shuffle(): 96 | lib.SpPlaybackEnableShuffle(True) 97 | return '', 204 98 | 99 | @app.route('/api/playback/shuffle/', endpoint='shuffle_toggle') 100 | def playback_shuffle(status): 101 | if status == 'enable': 102 | lib.SpPlaybackEnableShuffle(True) 103 | elif status == 'disable': 104 | lib.SpPlaybackEnableShuffle(False) 105 | return '', 204 106 | 107 | 108 | @app.route('/api/playback/repeat') 109 | def playback_repeat(): 110 | lib.SpPlaybackEnableRepeat(True) 111 | return '', 204 112 | 113 | @app.route('/api/playback/repeat/', endpoint='repeat_toggle') 114 | def playback_repeat(status): 115 | if status == 'enable': 116 | lib.SpPlaybackEnableRepeat(True) 117 | elif status == 'disable': 118 | lib.SpPlaybackEnableRepeat(False) 119 | return '', 204 120 | 121 | 122 | @app.route('/api/playback/volume', methods=['GET']) 123 | def playback_volume(): 124 | return jsonify({ 125 | 'volume': lib.SpPlaybackGetVolume() 126 | }) 127 | 128 | @app.route('/api/playback/volume', methods=['POST'], endpoint='playback_volume-post') 129 | def playback_volume(): 130 | volume = request.form.get('value') 131 | if volume is None: 132 | return jsonify({ 133 | 'error': 'value must be set' 134 | }), 400 135 | lib.SpPlaybackUpdateVolume(int(volume)) 136 | return '', 204 137 | 138 | 139 | #Info routes 140 | @app.route('/api/info/metadata') 141 | def info_metadata(): 142 | res = get_metadata() 143 | res['volume'] = lib.SpPlaybackGetVolume() 144 | return jsonify(res) 145 | 146 | @app.route('/api/info/status') 147 | def info_status(): 148 | return jsonify({ 149 | 'active': bool(lib.SpPlaybackIsActiveDevice()), 150 | 'playing': bool(lib.SpPlaybackIsPlaying()), 151 | 'shuffle': bool(lib.SpPlaybackIsShuffled()), 152 | 'repeat': bool(lib.SpPlaybackIsRepeated()), 153 | 'logged_in': bool(lib.SpConnectionIsLoggedIn()) 154 | }) 155 | 156 | @app.route('/api/info/image_url/') 157 | def info_image_url(image_uri): 158 | return redirect(get_image_url(str(image_uri))) 159 | 160 | @app.route('/api/info/display_name', methods=['GET']) 161 | def info_display_name(): 162 | return jsonify({ 163 | 'remoteName': get_zeroconf_vars()['remoteName'] 164 | }) 165 | 166 | @app.route('/api/info/display_name', methods=['POST'], endpoint='display_name-post') 167 | def info_display_name(): 168 | display_name = str(request.form.get('displayName')) 169 | if not display_name: 170 | return jsonify({ 171 | 'error': 'displayName must be set' 172 | }), 400 173 | lib.SpSetDisplayName(display_name) 174 | return '', 204 175 | 176 | #Login routes 177 | @app.route('/login/logout') 178 | def login_logout(): 179 | lib.SpConnectionLogout() 180 | return redirect(url_for('index')) 181 | 182 | @app.route('/login/password', methods=['POST']) 183 | def login_password(): 184 | global invalid_login 185 | invalid_login = False 186 | username = str(request.form.get('username')) 187 | password = str(request.form.get('password')) 188 | 189 | if not username or not password: 190 | flash('Username or password not specified', 'danger') 191 | else: 192 | flash('Waiting for spotify', 'info') 193 | connect_app.login(username, password=password) 194 | 195 | return redirect(url_for('index')) 196 | 197 | @app.route('/login/check_login') 198 | def check_login(): 199 | res = { 200 | 'finished': False, 201 | 'success': False 202 | } 203 | 204 | if invalid_login: 205 | res['finished'] = True 206 | elif bool(lib.SpConnectionIsLoggedIn()): 207 | res['finished'] = True 208 | res['success'] = True 209 | 210 | return jsonify(res) 211 | 212 | @app.route('/login/_zeroconf', methods=['GET', 'POST']) 213 | def login_zeroconf(): 214 | action = request.args.get('action') or request.form.get('action') 215 | if not action: 216 | return jsonify({ 217 | 'status': 301, 218 | 'spotifyError': 0, 219 | 'statusString': 'ERROR-MISSING-ACTION'}) 220 | if action == 'getInfo' and request.method == 'GET': 221 | return get_info() 222 | elif action == 'addUser' and request.method == 'POST': 223 | return add_user() 224 | else: 225 | return jsonify({ 226 | 'status': 301, 227 | 'spotifyError': 0, 228 | 'statusString': 'ERROR-INVALID-ACTION'}) 229 | 230 | def get_info(): 231 | zeroconf_vars = get_zeroconf_vars() 232 | 233 | return jsonify({ 234 | 'status': 101, 235 | 'spotifyError': 0, 236 | 'activeUser': zeroconf_vars['activeUser'], 237 | 'brandDisplayName': ffi.string(connect_app.config['brandName']), 238 | 'accountReq': zeroconf_vars['accountReq'], 239 | #Doesn't have any specific format (I think) 240 | 'deviceID': zeroconf_vars['deviceId'], 241 | #Generated from SpZeroConfGetVars() 242 | #Used to encrypt the blob used for login 243 | 'publicKey': zeroconf_vars['publicKey'], 244 | 'version': '2.0.1', 245 | #Valid types are UNKNOWN, COMPUTER, TABLET, SMARTPHONE, SPEAKER, TV, AVR, STB and AUDIODONGLE 246 | 'deviceType': zeroconf_vars['deviceType'], 247 | 'modelDisplayName': ffi.string(connect_app.config['modelName']), 248 | #Status codes are ERROR-OK (not actually an error), ERROR-MISSING-ACTION, ERROR-INVALID-ACTION, ERROR-SPOTIFY-ERROR, ERROR-INVALID-ARGUMENTS, ERROR-UNKNOWN, and ERROR_LOG_FILE 249 | 'statusString': 'ERROR-OK', 250 | #Name that shows up in the Spotify client 251 | 'remoteName': zeroconf_vars['remoteName'] 252 | }) 253 | 254 | def add_user(): 255 | args = request.form 256 | #TODO: Add parameter verification 257 | username = str(args.get('userName')) 258 | blob = str(args.get('blob')) 259 | clientKey = str(args.get('clientKey')) 260 | 261 | connect_app.login(username, zeroconf=(blob,clientKey)) 262 | 263 | return jsonify({ 264 | 'status': 101, 265 | 'spotifyError': 0, 266 | 'statusString': 'ERROR-OK' 267 | }) 268 | 269 | #Loop to pump events 270 | def pump_events(): 271 | lib.SpPumpEvents() 272 | spawn_later(0.1, pump_events) 273 | 274 | pump_events() 275 | 276 | #Only run if script is run directly and not by an import 277 | if __name__ == "__main__": 278 | #Can be run on any port as long as it matches the one used in avahi-publish-service 279 | http_server = WSGIServer(('', 4000), app) 280 | http_server.serve_forever() 281 | 282 | #TODO: Add signal catcher 283 | lib.SpFree() 284 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cffi>=0.9.2 2 | Flask==0.11.1 3 | Flask-Bootstrap>=3.3.2.1,<4.0 4 | Flask-Cors==2.1.2 5 | pycparser>=2.10 6 | pyalsaaudio>=0.8 7 | gevent>=1.0.1 8 | pylast>=1.6.0 9 | -------------------------------------------------------------------------------- /run-with-docker: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for DEV in $(find /dev/snd -type c); do 4 | DEVICE_ARGS="$DEVICE_ARGS --device=$DEV:$DEV" 5 | done 6 | 7 | exec docker run -it --rm -p 4000:4000 $DEVICE_ARGS spotify-connect-web $* 8 | -------------------------------------------------------------------------------- /spotify.h: -------------------------------------------------------------------------------- 1 | #ifndef SPOTIFY_H 2 | #define SPOTIFY_H 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | typedef enum { 9 | kSpErrorOk = 0, 10 | kSpErrorFailed = 1, 11 | kSpErrorInitFailed = 2, 12 | kSpErrorWrongAPIVersion = 3, 13 | kSpErrorNullArgument = 4, 14 | kSpErrorInvalidArgument = 5, 15 | kSpErrorUninitialized = 6, 16 | kSpErrorAlreadyInitialized = 7, 17 | kSpErrorLoginBadCredentials = 8, 18 | kSpErrorNeedsPremium = 9, 19 | kSpErrorTravelRestriction = 10, 20 | kSpErrorApplicationBanned = 11, 21 | kSpErrorGeneralLoginError = 12, 22 | kSpErrorUnsupported = 13, 23 | kSpErrorNotActiveDevice = 14, 24 | kSpErrorPlaybackErrorStart = 1000, 25 | kSpErrorGeneralPlaybackError = 1001, 26 | kSpErrorPlaybackRateLimited = 1002, 27 | kSpErrorUnknown = 1003, 28 | } SpError; 29 | 30 | typedef enum { 31 | kSpConnectionNotifyLoggedIn = 0, 32 | kSpConnectionNotifyLoggedOut = 1, 33 | kSpConnectionNotifyTemporaryError = 2, 34 | } SpConnectionNotify; 35 | 36 | typedef enum { 37 | kSpPlaybackNotifyPlay = 0, 38 | kSpPlaybackNotifyPause = 1, 39 | kSpPlaybackNotifyTrackChanged = 2, 40 | kSpPlaybackNotifyNext = 3, 41 | kSpPlaybackNotifyPrev = 4, 42 | kSpPlaybackNotifyShuffleEnabled = 5, 43 | kSpPlaybackNotifyShuffleDisabled = 6, 44 | kSpPlaybackNotifyRepeatEnabled = 7, 45 | kSpPlaybackNotifyRepeatDisabled = 8, 46 | kSpPlaybackNotifyBecameActive = 9, 47 | kSpPlaybackNotifyBecameInactive = 10, 48 | kSpPlaybackNotifyPlayTokenLost = 11, 49 | kSpPlaybackEventAudioFlush = 12, 50 | } SpPlaybackNotify; 51 | 52 | typedef enum { 53 | kSpDeviceTypeUnknown = 0, 54 | kSpDeviceTypeComputer = 1, 55 | kSpDeviceTypeTablet = 2, 56 | kSpDeviceTypeSmartphone = 3, 57 | kSpDeviceTypeSpeaker = 4, 58 | kSpDeviceTypeTV = 5, 59 | kSpDeviceTypeAVR = 6, 60 | kSpDeviceTypeSTB = 7, 61 | kSpDeviceTypeAudioDongle = 8, 62 | } SpDeviceType; 63 | 64 | typedef enum { 65 | kSpSampleTypeS16NativeEndian, 66 | } SpSampleType; 67 | 68 | typedef enum { 69 | kSpBitrate90k = 0, 70 | kSpBitrate160k = 1, 71 | kSpBitrate320k = 2, 72 | } SpBitrate; 73 | 74 | typedef enum { 75 | kSpImageSizeSmall = 0, 76 | kSpImageSizeNormal = 1, 77 | kSpImageSizeLarge = 2, 78 | } SpImageSize; 79 | 80 | typedef struct { 81 | uint16_t channels; 82 | uint16_t sample_type; // SpSampleType 83 | uint32_t sample_rate; 84 | } SpSampleFormat; 85 | 86 | typedef struct { 87 | uint32_t version; 88 | uint8_t *buffer; 89 | uint32_t buffer_size; // 0x100000 90 | uint8_t *app_key; 91 | uint32_t app_key_size; 92 | const char *deviceId; 93 | const char *remoteName; 94 | const char *brandName; 95 | const char *modelName; 96 | char *client_id; 97 | char *client_secret; 98 | uint32_t deviceType; 99 | void (*error_callback)(SpError error, void *userdata); 100 | void *userdata; 101 | } SpConfig; 102 | 103 | typedef struct { 104 | char publicKey[0x96]; 105 | char deviceId[0x41]; 106 | char activeUser[0x41]; 107 | char remoteName[0x41]; 108 | char accountReq[0x10]; 109 | char deviceType[0x10]; 110 | char libraryVersion[0x1f]; 111 | } SpZeroConfVars; 112 | 113 | typedef struct { 114 | char data0[0x100]; 115 | char context_uri[0x80]; 116 | char track_name[0x100]; 117 | char track_uri[0x80]; 118 | char artist_name[0x100]; 119 | char artist_uri[0x80]; 120 | char album_name[0x100]; 121 | char album_uri[0x80]; 122 | char cover_uri[0x80]; 123 | uint32_t duration; 124 | } SpMetadata; 125 | 126 | typedef struct { 127 | uint8_t data[0x84]; 128 | } SpPreset; 129 | 130 | typedef struct { 131 | void (*notify)(SpConnectionNotify notification, void *userdata); 132 | void (*new_credentials)(const char *blob, void *userdata); 133 | } SpConnectionCallbacks; 134 | 135 | typedef struct { 136 | void (*notify)(SpPlaybackNotify notification, void *userdata); 137 | uint32_t (*audio_data)(const void *samples, uint32_t num_samples, 138 | SpSampleFormat *format, uint32_t *pending, 139 | void *userdata); 140 | void (*seek)(uint32_t millis, void *userdata); 141 | void (*apply_volume)(uint16_t volume, void *userdata); 142 | } SpPlaybackCallbacks; 143 | 144 | typedef struct { 145 | void (*message)(const char *msg, void *userdata); 146 | } SpDebugCallbacks; 147 | 148 | 149 | SpError SpInit(const SpConfig *config); 150 | void SpFree(void); 151 | 152 | SpError SpPumpEvents(void); 153 | 154 | SpError SpGetMetadataValidRange(int *start, int *end); 155 | SpError SpGetMetadata(SpMetadata *, int offset); 156 | SpError SpGetMetadataImageURL(const char *uri, SpImageSize imageSize, 157 | char *url, size_t size); 158 | 159 | SpError SpGetPreset(SpPreset *preset, size_t *size); 160 | SpError SpPlayPreset(const SpPreset *preset, size_t size); 161 | 162 | SpError SpSetDisplayName(const char *name); 163 | const char *SpGetLibraryVersion(void); 164 | 165 | SpError SpZeroConfGetVars(SpZeroConfVars *vars); 166 | 167 | SpError SpPlaybackPlay(void); 168 | SpError SpPlaybackPause(void); 169 | SpError SpPlaybackSkipToNext(void); 170 | SpError SpPlaybackSkipToPrev(void); 171 | SpError SpPlaybackSeek(uint32_t millis); 172 | SpError SpPlaybackUpdateVolume(uint16_t volume); 173 | SpError SpPlaybackEnableShuffle(bool enable); 174 | SpError SpPlaybackEnableRepeat(bool enable); 175 | SpError SpPlaybackSetBitrate(SpBitrate bitrate); 176 | 177 | uint16_t SpPlaybackGetVolume(void); 178 | bool SpPlaybackIsPlaying(void); 179 | bool SpPlaybackIsShuffled(void); 180 | bool SpPlaybackIsRepeated(void); 181 | bool SpPlaybackIsActiveDevice(void); 182 | 183 | SpError SpConnectionLoginBlob(const char *username, const char *blob); 184 | SpError SpConnectionLoginPassword(const char *login, const char *password); 185 | SpError SpConnectionLoginZeroConf(const char *username, const char *blob, 186 | const char *clientKey); 187 | SpError SpConnectionLoginOauthToken(const char *token); 188 | 189 | bool SpConnectionIsLoggedIn(void); 190 | SpError SpConnectionLogout(void); 191 | 192 | SpError SpRegisterConnectionCallbacks( 193 | const SpConnectionCallbacks *callbacks, void *userdata); 194 | SpError SpRegisterPlaybackCallbacks( 195 | const SpPlaybackCallbacks *callbacks, void *userdata); 196 | SpError SpRegisterDebugCallbacks( 197 | const SpDebugCallbacks *callbacks, void *userdata); 198 | 199 | #endif 200 | -------------------------------------------------------------------------------- /spotify.processed.h: -------------------------------------------------------------------------------- 1 | # 1 "spotify.h" 2 | # 1 "" 3 | # 1 "" 4 | # 1 "/usr/include/stdc-predef.h" 1 3 4 5 | # 1 "" 2 6 | # 1 "spotify.h" 7 | 8 | 9 | 10 | # 1 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stddef.h" 1 3 4 11 | # 147 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stddef.h" 3 4 12 | typedef int ptrdiff_t; 13 | # 212 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stddef.h" 3 4 14 | typedef unsigned int size_t; 15 | # 324 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stddef.h" 3 4 16 | typedef unsigned int wchar_t; 17 | # 5 "spotify.h" 2 18 | # 1 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stdint.h" 1 3 4 19 | # 9 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stdint.h" 3 4 20 | # 1 "/usr/include/stdint.h" 1 3 4 21 | # 25 "/usr/include/stdint.h" 3 4 22 | # 1 "/usr/include/features.h" 1 3 4 23 | # 374 "/usr/include/features.h" 3 4 24 | # 1 "/usr/include/arm-linux-gnueabi/sys/cdefs.h" 1 3 4 25 | # 385 "/usr/include/arm-linux-gnueabi/sys/cdefs.h" 3 4 26 | # 1 "/usr/include/arm-linux-gnueabi/bits/wordsize.h" 1 3 4 27 | # 386 "/usr/include/arm-linux-gnueabi/sys/cdefs.h" 2 3 4 28 | # 375 "/usr/include/features.h" 2 3 4 29 | # 398 "/usr/include/features.h" 3 4 30 | # 1 "/usr/include/arm-linux-gnueabi/gnu/stubs.h" 1 3 4 31 | 32 | 33 | 34 | 35 | 36 | 37 | # 1 "/usr/include/arm-linux-gnueabi/gnu/stubs-soft.h" 1 3 4 38 | # 8 "/usr/include/arm-linux-gnueabi/gnu/stubs.h" 2 3 4 39 | # 399 "/usr/include/features.h" 2 3 4 40 | # 26 "/usr/include/stdint.h" 2 3 4 41 | # 1 "/usr/include/arm-linux-gnueabi/bits/wchar.h" 1 3 4 42 | # 27 "/usr/include/stdint.h" 2 3 4 43 | # 1 "/usr/include/arm-linux-gnueabi/bits/wordsize.h" 1 3 4 44 | # 28 "/usr/include/stdint.h" 2 3 4 45 | # 36 "/usr/include/stdint.h" 3 4 46 | typedef signed char int8_t; 47 | typedef short int int16_t; 48 | typedef int int32_t; 49 | 50 | 51 | 52 | 53 | typedef long long int int64_t; 54 | 55 | 56 | 57 | 58 | typedef unsigned char uint8_t; 59 | typedef unsigned short int uint16_t; 60 | 61 | typedef unsigned int uint32_t; 62 | 63 | 64 | 65 | 66 | 67 | 68 | typedef unsigned long long int uint64_t; 69 | 70 | 71 | 72 | 73 | 74 | 75 | typedef signed char int_least8_t; 76 | typedef short int int_least16_t; 77 | typedef int int_least32_t; 78 | 79 | 80 | 81 | 82 | typedef long long int int_least64_t; 83 | 84 | 85 | 86 | typedef unsigned char uint_least8_t; 87 | typedef unsigned short int uint_least16_t; 88 | typedef unsigned int uint_least32_t; 89 | 90 | 91 | 92 | 93 | typedef unsigned long long int uint_least64_t; 94 | 95 | 96 | 97 | 98 | 99 | 100 | typedef signed char int_fast8_t; 101 | 102 | 103 | 104 | 105 | 106 | typedef int int_fast16_t; 107 | typedef int int_fast32_t; 108 | 109 | typedef long long int int_fast64_t; 110 | 111 | 112 | 113 | typedef unsigned char uint_fast8_t; 114 | 115 | 116 | 117 | 118 | 119 | typedef unsigned int uint_fast16_t; 120 | typedef unsigned int uint_fast32_t; 121 | 122 | typedef unsigned long long int uint_fast64_t; 123 | # 125 "/usr/include/stdint.h" 3 4 124 | typedef int intptr_t; 125 | 126 | 127 | typedef unsigned int uintptr_t; 128 | # 137 "/usr/include/stdint.h" 3 4 129 | 130 | typedef long long int intmax_t; 131 | 132 | typedef unsigned long long int uintmax_t; 133 | # 10 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stdint.h" 2 3 4 134 | # 6 "spotify.h" 2 135 | # 1 "/usr/lib/gcc/arm-linux-gnueabi/4.9/include/stdbool.h" 1 3 4 136 | # 7 "spotify.h" 2 137 | 138 | typedef enum { 139 | kSpErrorOk = 0, 140 | kSpErrorFailed = 1, 141 | kSpErrorInitFailed = 2, 142 | kSpErrorWrongAPIVersion = 3, 143 | kSpErrorNullArgument = 4, 144 | kSpErrorInvalidArgument = 5, 145 | kSpErrorUninitialized = 6, 146 | kSpErrorAlreadyInitialized = 7, 147 | kSpErrorLoginBadCredentials = 8, 148 | kSpErrorNeedsPremium = 9, 149 | kSpErrorTravelRestriction = 10, 150 | kSpErrorApplicationBanned = 11, 151 | kSpErrorGeneralLoginError = 12, 152 | kSpErrorUnsupported = 13, 153 | kSpErrorNotActiveDevice = 14, 154 | kSpErrorPlaybackErrorStart = 1000, 155 | kSpErrorGeneralPlaybackError = 1001, 156 | kSpErrorPlaybackRateLimited = 1002, 157 | kSpErrorUnknown = 1003, 158 | } SpError; 159 | 160 | typedef enum { 161 | kSpConnectionNotifyLoggedIn = 0, 162 | kSpConnectionNotifyLoggedOut = 1, 163 | kSpConnectionNotifyTemporaryError = 2, 164 | } SpConnectionNotify; 165 | 166 | typedef enum { 167 | kSpPlaybackNotifyPlay = 0, 168 | kSpPlaybackNotifyPause = 1, 169 | kSpPlaybackNotifyTrackChanged = 2, 170 | kSpPlaybackNotifyNext = 3, 171 | kSpPlaybackNotifyPrev = 4, 172 | kSpPlaybackNotifyShuffleEnabled = 5, 173 | kSpPlaybackNotifyShuffleDisabled = 6, 174 | kSpPlaybackNotifyRepeatEnabled = 7, 175 | kSpPlaybackNotifyRepeatDisabled = 8, 176 | kSpPlaybackNotifyBecameActive = 9, 177 | kSpPlaybackNotifyBecameInactive = 10, 178 | kSpPlaybackNotifyPlayTokenLost = 11, 179 | kSpPlaybackEventAudioFlush = 12, 180 | } SpPlaybackNotify; 181 | 182 | typedef enum { 183 | kSpDeviceTypeUnknown = 0, 184 | kSpDeviceTypeComputer = 1, 185 | kSpDeviceTypeTablet = 2, 186 | kSpDeviceTypeSmartphone = 3, 187 | kSpDeviceTypeSpeaker = 4, 188 | kSpDeviceTypeTV = 5, 189 | kSpDeviceTypeAVR = 6, 190 | kSpDeviceTypeSTB = 7, 191 | kSpDeviceTypeAudioDongle = 8, 192 | } SpDeviceType; 193 | 194 | typedef enum { 195 | kSpSampleTypeS16NativeEndian, 196 | } SpSampleType; 197 | 198 | typedef enum { 199 | kSpBitrate90k = 0, 200 | kSpBitrate160k = 1, 201 | kSpBitrate320k = 2, 202 | } SpBitrate; 203 | 204 | typedef enum { 205 | kSpImageSizeSmall = 0, 206 | kSpImageSizeNormal = 1, 207 | kSpImageSizeLarge = 2, 208 | } SpImageSize; 209 | 210 | typedef struct { 211 | uint16_t channels; 212 | uint16_t sample_type; 213 | uint32_t sample_rate; 214 | } SpSampleFormat; 215 | 216 | typedef struct { 217 | uint32_t version; 218 | uint8_t *buffer; 219 | uint32_t buffer_size; 220 | uint8_t *app_key; 221 | uint32_t app_key_size; 222 | const char *deviceId; 223 | const char *remoteName; 224 | const char *brandName; 225 | const char *modelName; 226 | char *client_id; 227 | char *client_secret; 228 | uint32_t deviceType; 229 | void (*error_callback)(SpError error, void *userdata); 230 | void *userdata; 231 | } SpConfig; 232 | 233 | typedef struct { 234 | char publicKey[0x96]; 235 | char deviceId[0x41]; 236 | char activeUser[0x41]; 237 | char remoteName[0x41]; 238 | char accountReq[0x10]; 239 | char deviceType[0x10]; 240 | char libraryVersion[0x1f]; 241 | } SpZeroConfVars; 242 | 243 | typedef struct { 244 | char data0[0x100]; 245 | char context_uri[0x80]; 246 | char track_name[0x100]; 247 | char track_uri[0x80]; 248 | char artist_name[0x100]; 249 | char artist_uri[0x80]; 250 | char album_name[0x100]; 251 | char album_uri[0x80]; 252 | char cover_uri[0x80]; 253 | uint32_t duration; 254 | } SpMetadata; 255 | 256 | typedef struct { 257 | uint8_t data[0x84]; 258 | } SpPreset; 259 | 260 | typedef struct { 261 | void (*notify)(SpConnectionNotify notification, void *userdata); 262 | void (*new_credentials)(const char *blob, void *userdata); 263 | } SpConnectionCallbacks; 264 | 265 | typedef struct { 266 | void (*notify)(SpPlaybackNotify notification, void *userdata); 267 | uint32_t (*audio_data)(const void *samples, uint32_t num_samples, 268 | SpSampleFormat *format, uint32_t *pending, 269 | void *userdata); 270 | void (*seek)(uint32_t millis, void *userdata); 271 | void (*apply_volume)(uint16_t volume, void *userdata); 272 | } SpPlaybackCallbacks; 273 | 274 | typedef struct { 275 | void (*message)(const char *msg, void *userdata); 276 | } SpDebugCallbacks; 277 | 278 | 279 | SpError SpInit(const SpConfig *config); 280 | void SpFree(void); 281 | 282 | SpError SpPumpEvents(void); 283 | 284 | SpError SpGetMetadataValidRange(int *start, int *end); 285 | SpError SpGetMetadata(SpMetadata *, int offset); 286 | SpError SpGetMetadataImageURL(const char *uri, SpImageSize imageSize, 287 | char *url, size_t size); 288 | 289 | SpError SpGetPreset(SpPreset *preset, size_t *size); 290 | SpError SpPlayPreset(const SpPreset *preset, size_t size); 291 | 292 | SpError SpSetDisplayName(const char *name); 293 | const char *SpGetLibraryVersion(void); 294 | 295 | SpError SpZeroConfGetVars(SpZeroConfVars *vars); 296 | 297 | SpError SpPlaybackPlay(void); 298 | SpError SpPlaybackPause(void); 299 | SpError SpPlaybackSkipToNext(void); 300 | SpError SpPlaybackSkipToPrev(void); 301 | SpError SpPlaybackSeek(uint32_t millis); 302 | SpError SpPlaybackUpdateVolume(uint16_t volume); 303 | SpError SpPlaybackEnableShuffle(_Bool enable); 304 | SpError SpPlaybackEnableRepeat(_Bool enable); 305 | SpError SpPlaybackSetBitrate(SpBitrate bitrate); 306 | 307 | uint16_t SpPlaybackGetVolume(void); 308 | _Bool SpPlaybackIsPlaying(void); 309 | _Bool SpPlaybackIsShuffled(void); 310 | _Bool SpPlaybackIsRepeated(void); 311 | _Bool SpPlaybackIsActiveDevice(void); 312 | 313 | SpError SpConnectionLoginBlob(const char *username, const char *blob); 314 | SpError SpConnectionLoginPassword(const char *login, const char *password); 315 | SpError SpConnectionLoginZeroConf(const char *username, const char *blob, 316 | const char *clientKey); 317 | SpError SpConnectionLoginOauthToken(const char *token); 318 | 319 | _Bool SpConnectionIsLoggedIn(void); 320 | SpError SpConnectionLogout(void); 321 | 322 | SpError SpRegisterConnectionCallbacks( 323 | const SpConnectionCallbacks *callbacks, void *userdata); 324 | SpError SpRegisterPlaybackCallbacks( 325 | const SpPlaybackCallbacks *callbacks, void *userdata); 326 | SpError SpRegisterDebugCallbacks( 327 | const SpDebugCallbacks *callbacks, void *userdata); 328 | -------------------------------------------------------------------------------- /static/css/bootstrap-slider.css: -------------------------------------------------------------------------------- 1 | /*! ======================================================= 2 | VERSION 4.5.6 3 | ========================================================= */ 4 | /*! ========================================================= 5 | * bootstrap-slider.js 6 | * 7 | * Maintainers: 8 | * Kyle Kemp 9 | * - Twitter: @seiyria 10 | * - Github: seiyria 11 | * Rohit Kalkur 12 | * - Twitter: @Rovolutionary 13 | * - Github: rovolution 14 | * 15 | * ========================================================= 16 | * 17 | * Licensed under the Apache License, Version 2.0 (the "License"); 18 | * you may not use this file except in compliance with the License. 19 | * You may obtain a copy of the License at 20 | * 21 | * http://www.apache.org/licenses/LICENSE-2.0 22 | * 23 | * Unless required by applicable law or agreed to in writing, software 24 | * distributed under the License is distributed on an "AS IS" BASIS, 25 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 26 | * See the License for the specific language governing permissions and 27 | * limitations under the License. 28 | * ========================================================= */ 29 | .slider { 30 | display: inline-block; 31 | vertical-align: middle; 32 | position: relative; 33 | } 34 | .slider.slider-horizontal { 35 | width: 210px; 36 | height: 20px; 37 | } 38 | .slider.slider-horizontal .slider-track { 39 | height: 10px; 40 | width: 100%; 41 | margin-top: -5px; 42 | top: 50%; 43 | left: 0; 44 | } 45 | .slider.slider-horizontal .slider-selection, 46 | .slider.slider-horizontal .slider-track-low, 47 | .slider.slider-horizontal .slider-track-high { 48 | height: 100%; 49 | top: 0; 50 | bottom: 0; 51 | } 52 | .slider.slider-horizontal .slider-tick, 53 | .slider.slider-horizontal .slider-handle { 54 | margin-left: -10px; 55 | margin-top: -5px; 56 | } 57 | .slider.slider-horizontal .slider-tick.triangle, 58 | .slider.slider-horizontal .slider-handle.triangle { 59 | border-width: 0 10px 10px 10px; 60 | width: 0; 61 | height: 0; 62 | border-bottom-color: #0480be; 63 | margin-top: 0; 64 | } 65 | .slider.slider-horizontal .slider-tick-label-container { 66 | white-space: nowrap; 67 | } 68 | .slider.slider-horizontal .slider-tick-label-container .slider-tick-label { 69 | margin-top: 24px; 70 | display: inline-block; 71 | text-align: center; 72 | } 73 | .slider.slider-vertical { 74 | height: 210px; 75 | width: 20px; 76 | } 77 | .slider.slider-vertical .slider-track { 78 | width: 10px; 79 | height: 100%; 80 | margin-left: -5px; 81 | left: 50%; 82 | top: 0; 83 | } 84 | .slider.slider-vertical .slider-selection { 85 | width: 100%; 86 | left: 0; 87 | top: 0; 88 | bottom: 0; 89 | } 90 | .slider.slider-vertical .slider-track-low, 91 | .slider.slider-vertical .slider-track-high { 92 | width: 100%; 93 | left: 0; 94 | right: 0; 95 | } 96 | .slider.slider-vertical .slider-tick, 97 | .slider.slider-vertical .slider-handle { 98 | margin-left: -5px; 99 | margin-top: -10px; 100 | } 101 | .slider.slider-vertical .slider-tick.triangle, 102 | .slider.slider-vertical .slider-handle.triangle { 103 | border-width: 10px 0 10px 10px; 104 | width: 1px; 105 | height: 1px; 106 | border-left-color: #0480be; 107 | margin-left: 0; 108 | } 109 | .slider.slider-disabled .slider-handle { 110 | background-image: -webkit-linear-gradient(top, #dfdfdf 0%, #bebebe 100%); 111 | background-image: -o-linear-gradient(top, #dfdfdf 0%, #bebebe 100%); 112 | background-image: linear-gradient(to bottom, #dfdfdf 0%, #bebebe 100%); 113 | background-repeat: repeat-x; 114 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdfdfdf', endColorstr='#ffbebebe', GradientType=0); 115 | } 116 | .slider.slider-disabled .slider-track { 117 | background-image: -webkit-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%); 118 | background-image: -o-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%); 119 | background-image: linear-gradient(to bottom, #e5e5e5 0%, #e9e9e9 100%); 120 | background-repeat: repeat-x; 121 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe5e5e5', endColorstr='#ffe9e9e9', GradientType=0); 122 | cursor: not-allowed; 123 | } 124 | .slider input { 125 | display: none; 126 | } 127 | .slider .tooltip.top { 128 | margin-top: -36px; 129 | } 130 | .slider .tooltip-inner { 131 | white-space: nowrap; 132 | } 133 | .slider .hide { 134 | display: none; 135 | } 136 | .slider-track { 137 | position: absolute; 138 | cursor: pointer; 139 | background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%); 140 | background-image: -o-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%); 141 | background-image: linear-gradient(to bottom, #f5f5f5 0%, #f9f9f9 100%); 142 | background-repeat: repeat-x; 143 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff9f9f9', GradientType=0); 144 | -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); 145 | box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); 146 | border-radius: 4px; 147 | } 148 | .slider-selection { 149 | position: absolute; 150 | background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%); 151 | background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%); 152 | background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%); 153 | background-repeat: repeat-x; 154 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0); 155 | -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); 156 | box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); 157 | -webkit-box-sizing: border-box; 158 | -moz-box-sizing: border-box; 159 | box-sizing: border-box; 160 | border-radius: 4px; 161 | } 162 | .slider-selection.tick-slider-selection { 163 | background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%); 164 | background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%); 165 | background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%); 166 | background-repeat: repeat-x; 167 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0); 168 | } 169 | .slider-track-low, 170 | .slider-track-high { 171 | position: absolute; 172 | background: transparent; 173 | -webkit-box-sizing: border-box; 174 | -moz-box-sizing: border-box; 175 | box-sizing: border-box; 176 | border-radius: 4px; 177 | } 178 | .slider-handle { 179 | position: absolute; 180 | width: 20px; 181 | height: 20px; 182 | background-color: #337ab7; 183 | background-image: -webkit-linear-gradient(top, #149bdf 0%, #0480be 100%); 184 | background-image: -o-linear-gradient(top, #149bdf 0%, #0480be 100%); 185 | background-image: linear-gradient(to bottom, #149bdf 0%, #0480be 100%); 186 | background-repeat: repeat-x; 187 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff149bdf', endColorstr='#ff0480be', GradientType=0); 188 | filter: none; 189 | -webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05); 190 | box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05); 191 | border: 0px solid transparent; 192 | } 193 | .slider-handle.round { 194 | border-radius: 50%; 195 | } 196 | .slider-handle.triangle { 197 | background: transparent none; 198 | } 199 | .slider-handle.custom { 200 | background: transparent none; 201 | } 202 | .slider-handle.custom::before { 203 | line-height: 20px; 204 | font-size: 20px; 205 | content: '\2605'; 206 | color: #726204; 207 | } 208 | .slider-tick { 209 | position: absolute; 210 | width: 20px; 211 | height: 20px; 212 | background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%); 213 | background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%); 214 | background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%); 215 | background-repeat: repeat-x; 216 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0); 217 | -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); 218 | box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15); 219 | -webkit-box-sizing: border-box; 220 | -moz-box-sizing: border-box; 221 | box-sizing: border-box; 222 | filter: none; 223 | opacity: 0.8; 224 | border: 0px solid transparent; 225 | } 226 | .slider-tick.round { 227 | border-radius: 50%; 228 | } 229 | .slider-tick.triangle { 230 | background: transparent none; 231 | } 232 | .slider-tick.custom { 233 | background: transparent none; 234 | } 235 | .slider-tick.custom::before { 236 | line-height: 20px; 237 | font-size: 20px; 238 | content: '\2605'; 239 | color: #726204; 240 | } 241 | .slider-tick.in-selection { 242 | background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%); 243 | background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%); 244 | background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%); 245 | background-repeat: repeat-x; 246 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0); 247 | opacity: 1; 248 | } 249 | -------------------------------------------------------------------------------- /static/js/bootstrap-slider.min.js: -------------------------------------------------------------------------------- 1 | /*! ======================================================= 2 | VERSION 4.5.6 3 | ========================================================= */ 4 | /*! ========================================================= 5 | * bootstrap-slider.js 6 | * 7 | * Maintainers: 8 | * Kyle Kemp 9 | * - Twitter: @seiyria 10 | * - Github: seiyria 11 | * Rohit Kalkur 12 | * - Twitter: @Rovolutionary 13 | * - Github: rovolution 14 | * 15 | * ========================================================= 16 | * 17 | * Licensed under the Apache License, Version 2.0 (the "License"); 18 | * you may not use this file except in compliance with the License. 19 | * You may obtain a copy of the License at 20 | * 21 | * http://www.apache.org/licenses/LICENSE-2.0 22 | * 23 | * Unless required by applicable law or agreed to in writing, software 24 | * distributed under the License is distributed on an "AS IS" BASIS, 25 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 26 | * See the License for the specific language governing permissions and 27 | * limitations under the License. 28 | * ========================================================= */ 29 | !function(a,b){if("function"==typeof define&&define.amd)define(["jquery"],b);else if("object"==typeof module&&module.exports){var c;try{c=require("jquery")}catch(d){c=null}module.exports=b(c)}else a.Slider=b(a.jQuery)}(this,function(a){var b;return function(a){"use strict";function b(){}function c(a){function c(b){b.prototype.option||(b.prototype.option=function(b){a.isPlainObject(b)&&(this.options=a.extend(!0,this.options,b))})}function e(b,c){a.fn[b]=function(e){if("string"==typeof e){for(var g=d.call(arguments,1),h=0,i=this.length;i>h;h++){var j=this[h],k=a.data(j,b);if(k)if(a.isFunction(k[e])&&"_"!==e.charAt(0)){var l=k[e].apply(k,g);if(void 0!==l&&l!==k)return l}else f("no such method '"+e+"' for "+b+" instance");else f("cannot call methods on "+b+" prior to initialization; attempted to call '"+e+"'")}return this}var m=this.map(function(){var d=a.data(this,b);return d?(d.option(e),d._init()):(d=new c(this,e),a.data(this,b,d)),a(this)});return!m||m.length>1?m:m[0]}}if(a){var f="undefined"==typeof console?b:function(a){console.error(a)};return a.bridget=function(a,b){c(b),e(a,b)},a.bridget}}var d=Array.prototype.slice;c(a)}(a),function(a){function c(b,c){function d(a,b){var c="data-slider-"+b.replace(/_/g,"-"),d=a.getAttribute(c);try{return JSON.parse(d)}catch(e){return d}}"string"==typeof b?this.element=document.querySelector(b):b instanceof HTMLElement&&(this.element=b),c=c?c:{};for(var f=Object.keys(this.defaultOptions),g=0;g0){for(g=0;g0)for(this.tickLabelContainer=document.createElement("div"),this.tickLabelContainer.className="slider-tick-label-container",g=0;g0&&(this.options.max=Math.max.apply(Math,this.options.ticks),this.options.min=Math.min.apply(Math,this.options.ticks)),Array.isArray(this.options.value)?this.options.range=!0:this.options.range&&(this.options.value=[this.options.value,this.options.max]),this.trackLow=k||this.trackLow,this.trackSelection=j||this.trackSelection,this.trackHigh=l||this.trackHigh,"none"===this.options.selection&&(this._addClass(this.trackLow,"hide"),this._addClass(this.trackSelection,"hide"),this._addClass(this.trackHigh,"hide")),this.handle1=m||this.handle1,this.handle2=n||this.handle2,p===!0)for(this._removeClass(this.handle1,"round triangle"),this._removeClass(this.handle2,"round triangle hide"),g=0;gthis.options.max?this.options.max:c},toPercentage:function(a){return this.options.max===this.options.min?0:100*(a-this.options.min)/(this.options.max-this.options.min)}},logarithmic:{toValue:function(a){var b=0===this.options.min?0:Math.log(this.options.min),c=Math.log(this.options.max);return Math.exp(b+(c-b)*a/100)},toPercentage:function(a){if(this.options.max===this.options.min)return 0;var b=Math.log(this.options.max),c=0===this.options.min?0:Math.log(this.options.min),d=0===a?0:Math.log(a);return 100*(d-c)/(b-c)}}};if(b=function(a,b){return c.call(this,a,b),this},b.prototype={_init:function(){},constructor:b,defaultOptions:{id:"",min:0,max:10,step:1,precision:0,orientation:"horizontal",value:5,range:!1,selection:"before",tooltip:"show",tooltip_split:!1,handle:"round",reversed:!1,enabled:!0,formatter:function(a){return Array.isArray(a)?a[0]+" : "+a[1]:a},natural_arrow_keys:!1,ticks:[],ticks_labels:[],ticks_snap_bounds:0,scale:"linear"},over:!1,inDrag:!1,getValue:function(){return this.options.range?this.options.value:this.options.value[0]},setValue:function(a,b){a||(a=0);var c=this.getValue();this.options.value=this._validateInputValue(a);var d=this._applyPrecision.bind(this);this.options.range?(this.options.value[0]=d(this.options.value[0]),this.options.value[1]=d(this.options.value[1]),this.options.value[0]=Math.max(this.options.min,Math.min(this.options.max,this.options.value[0])),this.options.value[1]=Math.max(this.options.min,Math.min(this.options.max,this.options.value[1]))):(this.options.value=d(this.options.value),this.options.value=[Math.max(this.options.min,Math.min(this.options.max,this.options.value))],this._addClass(this.handle2,"hide"),this.options.value[1]="after"===this.options.selection?this.options.max:this.options.min),this.percentage=this.options.max>this.options.min?[this._toPercentage(this.options.value[0]),this._toPercentage(this.options.value[1]),100*this.options.step/(this.options.max-this.options.min)]:[0,0,100],this._layout();var e=this.options.range?this.options.value:this.options.value[0];return b===!0&&this._trigger("slide",e),c!==e&&this._trigger("change",{oldValue:c,newValue:e}),this._setDataVal(e),this},destroy:function(){this._removeSliderEventHandlers(),this.sliderElem.parentNode.removeChild(this.sliderElem),this.element.style.display="",this._cleanUpEventCallbacksMap(),this.element.removeAttribute("data"),a&&(this._unbindJQueryEventHandlers(),this.$element.removeData("slider"))},disable:function(){return this.options.enabled=!1,this.handle1.removeAttribute("tabindex"),this.handle2.removeAttribute("tabindex"),this._addClass(this.sliderElem,"slider-disabled"),this._trigger("slideDisabled"),this},enable:function(){return this.options.enabled=!0,this.handle1.setAttribute("tabindex",0),this.handle2.setAttribute("tabindex",0),this._removeClass(this.sliderElem,"slider-disabled"),this._trigger("slideEnabled"),this},toggle:function(){return this.options.enabled?this.disable():this.enable(),this},isEnabled:function(){return this.options.enabled},on:function(a,b){return this._bindNonQueryEventHandler(a,b),this},getAttribute:function(a){return a?this.options[a]:this.options},setAttribute:function(a,b){return this.options[a]=b,this},refresh:function(){return this._removeSliderEventHandlers(),c.call(this,this.element,this.options),a&&a.data(this.element,"slider",this),this},relayout:function(){return this._layout(),this},_removeSliderEventHandlers:function(){this.handle1.removeEventListener("keydown",this.handle1Keydown,!1),this.handle1.removeEventListener("focus",this.showTooltip,!1),this.handle1.removeEventListener("blur",this.hideTooltip,!1),this.handle2.removeEventListener("keydown",this.handle2Keydown,!1),this.handle2.removeEventListener("focus",this.handle2Keydown,!1),this.handle2.removeEventListener("blur",this.handle2Keydown,!1),this.sliderElem.removeEventListener("mouseenter",this.showTooltip,!1),this.sliderElem.removeEventListener("mouseleave",this.hideTooltip,!1),this.sliderElem.removeEventListener("touchstart",this.mousedown,!1),this.sliderElem.removeEventListener("mousedown",this.mousedown,!1)},_bindNonQueryEventHandler:function(a,b){void 0===this.eventToCallbackMap[a]&&(this.eventToCallbackMap[a]=[]),this.eventToCallbackMap[a].push(b)},_cleanUpEventCallbacksMap:function(){for(var a=Object.keys(this.eventToCallbackMap),b=0;b0){var b=Math.max.apply(Math,this.options.ticks),c=Math.min.apply(Math,this.options.ticks),d="vertical"===this.options.orientation?"height":"width",e="vertical"===this.options.orientation?"marginTop":"marginLeft",f=this.size/(this.options.ticks.length-1);if(this.tickLabelContainer&&(this.tickLabelContainer.style[e]=-f/2+"px","horizontal"===this.options.orientation)){var g=this.tickLabelContainer.offsetHeight-this.sliderElem.offsetHeight;this.sliderElem.style.marginBottom=g+"px"}for(var h=0;h=a[0]&&i<=a[1]&&this._addClass(this.ticks[h],"in-selection"),this.tickLabels[h]&&(this.tickLabels[h].style[d]=f+"px")}}if("vertical"===this.options.orientation)this.trackLow.style.top="0",this.trackLow.style.height=Math.min(a[0],a[1])+"%",this.trackSelection.style.top=Math.min(a[0],a[1])+"%",this.trackSelection.style.height=Math.abs(a[0]-a[1])+"%",this.trackHigh.style.bottom="0",this.trackHigh.style.height=100-Math.min(a[0],a[1])-Math.abs(a[0]-a[1])+"%";else{this.trackLow.style.left="0",this.trackLow.style.width=Math.min(a[0],a[1])+"%",this.trackSelection.style.left=Math.min(a[0],a[1])+"%",this.trackSelection.style.width=Math.abs(a[0]-a[1])+"%",this.trackHigh.style.right="0",this.trackHigh.style.width=100-Math.min(a[0],a[1])-Math.abs(a[0]-a[1])+"%";var j=this.tooltip_min.getBoundingClientRect(),k=this.tooltip_max.getBoundingClientRect();j.right>k.left?(this._removeClass(this.tooltip_max,"top"),this._addClass(this.tooltip_max,"bottom"),this.tooltip_max.style.top="18px"):(this._removeClass(this.tooltip_max,"bottom"),this._addClass(this.tooltip_max,"top"),this.tooltip_max.style.top=this.tooltip_min.style.top)}var l;if(this.options.range){l=this.options.formatter(this.options.value),this._setText(this.tooltipInner,l),this.tooltip.style[this.stylePos]=(a[1]+a[0])/2+"%","vertical"===this.options.orientation?this._css(this.tooltip,"margin-top",-this.tooltip.offsetHeight/2+"px"):this._css(this.tooltip,"margin-left",-this.tooltip.offsetWidth/2+"px"),"vertical"===this.options.orientation?this._css(this.tooltip,"margin-top",-this.tooltip.offsetHeight/2+"px"):this._css(this.tooltip,"margin-left",-this.tooltip.offsetWidth/2+"px");var m=this.options.formatter(this.options.value[0]);this._setText(this.tooltipInner_min,m);var n=this.options.formatter(this.options.value[1]);this._setText(this.tooltipInner_max,n),this.tooltip_min.style[this.stylePos]=a[0]+"%","vertical"===this.options.orientation?this._css(this.tooltip_min,"margin-top",-this.tooltip_min.offsetHeight/2+"px"):this._css(this.tooltip_min,"margin-left",-this.tooltip_min.offsetWidth/2+"px"),this.tooltip_max.style[this.stylePos]=a[1]+"%","vertical"===this.options.orientation?this._css(this.tooltip_max,"margin-top",-this.tooltip_max.offsetHeight/2+"px"):this._css(this.tooltip_max,"margin-left",-this.tooltip_max.offsetWidth/2+"px")}else l=this.options.formatter(this.options.value[0]),this._setText(this.tooltipInner,l),this.tooltip.style[this.stylePos]=a[0]+"%","vertical"===this.options.orientation?this._css(this.tooltip,"margin-top",-this.tooltip.offsetHeight/2+"px"):this._css(this.tooltip,"margin-left",-this.tooltip.offsetWidth/2+"px")},_removeProperty:function(a,b){a.style.removeProperty?a.style.removeProperty(b):a.style.removeAttribute(b)},_mousedown:function(a){if(!this.options.enabled)return!1;this._triggerFocusOnHandle(),this.offset=this._offset(this.sliderElem),this.size=this.sliderElem[this.sizePos];var b=this._getPercentage(a);if(this.options.range){var c=Math.abs(this.percentage[0]-b),d=Math.abs(this.percentage[1]-b);this.dragged=d>c?0:1}else this.dragged=0;this.percentage[this.dragged]=this.options.reversed?100-b:b,this._layout(),this.touchCapable&&(document.removeEventListener("touchmove",this.mousemove,!1),document.removeEventListener("touchend",this.mouseup,!1)),this.mousemove&&document.removeEventListener("mousemove",this.mousemove,!1),this.mouseup&&document.removeEventListener("mouseup",this.mouseup,!1),this.mousemove=this._mousemove.bind(this),this.mouseup=this._mouseup.bind(this),this.touchCapable&&(document.addEventListener("touchmove",this.mousemove,!1),document.addEventListener("touchend",this.mouseup,!1)),document.addEventListener("mousemove",this.mousemove,!1),document.addEventListener("mouseup",this.mouseup,!1),this.inDrag=!0;var e=this._calculateValue();return this._trigger("slideStart",e),this._setDataVal(e),this.setValue(e),this._pauseEvent(a),!0},_triggerFocusOnHandle:function(a){0===a&&this.handle1.focus(),1===a&&this.handle2.focus()},_keydown:function(a,b){if(!this.options.enabled)return!1;var c;switch(b.keyCode){case 37:case 40:c=-1;break;case 39:case 38:c=1}if(c){if(this.options.natural_arrow_keys){var d="vertical"===this.options.orientation&&!this.options.reversed,e="horizontal"===this.options.orientation&&this.options.reversed;(d||e)&&(c=-c)}var f=this.options.value[a]+c*this.options.step;return this.options.range&&(f=[a?this.options.value[0]:f,a?f:this.options.value[1]]),this._trigger("slideStart",f),this._setDataVal(f),this.setValue(f,!0),this._trigger("slideStop",f),this._setDataVal(f),this._layout(),this._pauseEvent(b),!1}},_pauseEvent:function(a){a.stopPropagation&&a.stopPropagation(),a.preventDefault&&a.preventDefault(),a.cancelBubble=!0,a.returnValue=!1},_mousemove:function(a){if(!this.options.enabled)return!1;var b=this._getPercentage(a);this._adjustPercentageForRangeSliders(b),this.percentage[this.dragged]=this.options.reversed?100-b:b,this._layout();var c=this._calculateValue(!0);return this.setValue(c,!0),!1},_adjustPercentageForRangeSliders:function(a){this.options.range&&(0===this.dragged&&this.percentage[1]a&&(this.percentage[1]=this.percentage[0],this.dragged=0))},_mouseup:function(){if(!this.options.enabled)return!1;this.touchCapable&&(document.removeEventListener("touchmove",this.mousemove,!1),document.removeEventListener("touchend",this.mouseup,!1)),document.removeEventListener("mousemove",this.mousemove,!1),document.removeEventListener("mouseup",this.mouseup,!1),this.inDrag=!1,this.over===!1&&this._hideTooltip();var a=this._calculateValue(!0);return this._layout(),this._trigger("slideStop",a),this._setDataVal(a),!1},_calculateValue:function(a){var b;if(this.options.range?(b=[this.options.min,this.options.max],0!==this.percentage[0]&&(b[0]=this._toValue(this.percentage[0]),b[0]=this._applyPrecision(b[0])),100!==this.percentage[1]&&(b[1]=this._toValue(this.percentage[1]),b[1]=this._applyPrecision(b[1]))):(b=this._toValue(this.percentage[0]),b=parseFloat(b),b=this._applyPrecision(b)),a){for(var c=[b,1/0],d=0;d'); 11 | messageBlock.addClass('alert-' + type); 12 | messageBlock.text(message); 13 | $('#messageDiv').append(messageBlock); 14 | messageBlock.fadeOut(5000, function() { 15 | $(this).remove(); 16 | }); 17 | } 18 | 19 | function playbackControl(e) { 20 | var type = e.currentTarget.getAttribute('data-type'); 21 | var action = e.currentTarget.getAttribute('data-action'); 22 | console.log(e); 23 | var value; 24 | var ajaxSettings = { 25 | url: '/api/' + type + '/' + action, 26 | method: 'GET' 27 | } 28 | if (action === 'play') { 29 | $('[data-action=play]').hide(); 30 | $('[data-action=pause]').show(); 31 | } else if (action === 'pause') { 32 | $('[data-action=pause]').hide(); 33 | $('[data-action=play]').show(); 34 | } else if (action === 'shuffle') { 35 | var shuffle = $('[data-action=shuffle]'); 36 | if (shuffle.hasClass('active')) { 37 | ajaxSettings.url += '/disable' 38 | } else { 39 | ajaxSettings.url += '/enable' 40 | } 41 | shuffle.toggleClass('active'); 42 | } else if (action === 'repeat') { 43 | var repeat = $('[data-action=repeat]'); 44 | if (repeat.hasClass('active')) { 45 | ajaxSettings.url += '/disable' 46 | } else { 47 | ajaxSettings.url += '/enable' 48 | } 49 | repeat.toggleClass('active'); 50 | } else if (action === 'volume') { 51 | ajaxSettings.method = 'POST'; 52 | ajaxSettings.data = {value: Math.round(e.currentTarget.value * 655.35)} 53 | } 54 | $.ajax(ajaxSettings).fail(function(jqXHR, textStatus, error) { 55 | console.log("Request failed: " + error); 56 | }); 57 | } 58 | 59 | //Call api when a playback control button is clicked 60 | $('#controls button').click(playbackControl); 61 | 62 | $('#displayNameForm').submit(function() { 63 | $('#displayNameModal').modal('hide'); 64 | event.preventDefault(); 65 | 66 | $.post(event.target.action, $(event.target).serialize()).done(function(data) { 67 | flash('Sucessfully updated display name', 'info'); 68 | }).fail(function(jqXHR, textStatus, error) { 69 | flash('Updating display name failed', 'danger'); 70 | console.log("Request failed: " + error); 71 | }); 72 | }); 73 | 74 | function updateMetadata() { 75 | $.ajax('/api/info/metadata').done(function(metadata) { 76 | var track = $('#trackInfo'); 77 | var artist = $('#artistInfo'); 78 | var album = $('#albumInfo'); 79 | var albumCover = $('#albumCover'); 80 | var musicInfo = $('[data-music-info]'); 81 | var albumCoverPlaceholder = $('#albumCoverPlaceholder'); 82 | 83 | //Temporary fix until better error checking is added server side 84 | if (metadata.track_uri === '') { 85 | musicInfo.text('No music playing'); 86 | albumCover.hide(); 87 | albumCoverPlaceholder.show(); 88 | return; 89 | } 90 | 91 | albumCover.show(); 92 | albumCoverPlaceholder.hide(); 93 | 94 | track.attr('data-id', metadata.track_uri); 95 | track.text(metadata.track_name); 96 | 97 | artist.attr('data-id', metadata.artist_uri); 98 | artist.text(metadata.artist_name); 99 | 100 | album.attr('data-id', metadata.album_uri); 101 | album.text(metadata.album_name); 102 | 103 | albumCover.attr('src', '/api/info/image_url/' + metadata.cover_uri) 104 | 105 | volumeSlider.slider('setValue', metadata.volume / 655.35); 106 | }).fail(function(jqXHR, textStatus, error) { 107 | console.log("Request failed: " + error); 108 | }); 109 | } 110 | 111 | function getStatus() { 112 | $.ajax('/api/info/status').done(function(data) { 113 | var musicInfo = $('[data-music-info]'); 114 | var albumCover = $('#albumCover'); 115 | var albumCoverPlaceholder = $('#albumCoverPlaceholder'); 116 | 117 | //Display buttons depending on play state 118 | $('[data-action=play]').toggle(!data.playing); 119 | $('[data-action=pause]').toggle(data.playing); 120 | 121 | $('[data-action=shuffle]').toggleClass('active', data.shuffle); 122 | $('[data-action=repeat]').toggleClass('active', data.repeat); 123 | 124 | //$('#player').toggle(data.logged_in); 125 | 126 | $('#activeDevice').text(data.active); 127 | $('#controls button').toggleClass('disabled', !data.active); 128 | 129 | $('#loginLink').toggle(!data.logged_in); 130 | $('#logoutLink').toggle(data.logged_in); 131 | 132 | if (data.active) { 133 | volumeSlider.slider('enable'); 134 | } else { 135 | volumeSlider.slider('disable'); 136 | } 137 | 138 | 139 | if (!loggedIn && data.logged_in && !metadataSetup) { 140 | $('[data-login-required]').show(); 141 | albumCover.show(); 142 | albumCoverPlaceholder.hide(); 143 | loggedIn = true; 144 | metadataSetup = true; 145 | updateMetadata(); 146 | metadataInterval = setInterval(updateMetadata, 5000); 147 | } else if (!data.logged_in) { 148 | $('[data-login-required]').hide(); 149 | musicInfo.text('Not logged in'); 150 | albumCover.hide(); 151 | albumCoverPlaceholder.show(); 152 | loggedIn = false; 153 | metadataSetup = false; 154 | clearInterval(metadataInterval); 155 | volumeSlider.slider('disable'); 156 | } 157 | 158 | }).fail(function(jqXHR, textStatus, error) { 159 | console.log("Request failed: " + error); 160 | }); 161 | } 162 | 163 | function checkLogin() { 164 | $.ajax('/login/check_login').done(function(data) { 165 | if (data.finished) { 166 | var message = $('.container .row .col-md-12 .alert-info:contains("Waiting for spotify")') 167 | message.removeClass('alert-info'); 168 | if (data.success) { 169 | message.text('Login Successful'); 170 | message.addClass('alert-success'); 171 | getStatus(); 172 | } else { 173 | message.text('Invalid username or password'); 174 | message.addClass('alert-danger'); 175 | } 176 | message.fadeOut(5000, function() { 177 | message.remove(); 178 | }); 179 | clearInterval(checkLoginInterval); 180 | } 181 | }).fail(function(jqXHR, textStatus, error) { 182 | console.log("Request failed: " + error); 183 | }) 184 | } 185 | 186 | volumeSlider = $('#volumeSlider').slider({ 187 | formatter: function(value) { 188 | return value; 189 | } 190 | }).on('slideStop', playbackControl); 191 | 192 | getStatus(); 193 | 194 | //Check for login status (and check if selector is empty) 195 | if ($('.container .row .col-md-12 .alert-info:contains("Waiting for spotify")').length) { 196 | checkLoginInterval = setInterval(checkLogin, 1000); 197 | } 198 | 199 | //Update every 5 seconds 200 | setInterval(getStatus, 5000); 201 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "bootstrap/base.html" %} 2 | {% block title %}Spotify Connect{% endblock %} 3 | {% from "bootstrap/utils.html" import flashed_messages %} 4 | 5 | {% block navbar %} 6 | 38 | {% endblock %} 39 | 40 | {% block content %} 41 | 42 |
43 |
44 |
45 | {{flashed_messages(container=False)}} 46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | 55 | 56 |
57 | 58 |

Loading...

59 |

Loading...

60 |
Loading...
61 |
62 |

63 | 64 | 65 | 66 |

67 |
68 | 71 | 74 |
75 |
76 | 79 | 82 | 85 | 88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | 97 | 121 | 122 |