├── .gitignore ├── Procfile ├── README.md ├── app.json ├── example ├── index.html ├── script.js └── style.css ├── index.js ├── package.json ├── public └── spotify-player.js └── views └── pages └── callback.ejs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node index.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spotify Player 2 | 3 | A Node.js server plus a light JS library to create integrations with the [Spotify Web API Connect endpoints](https://developer.spotify.com/web-api/web-api-connect-endpoint-reference/). 4 | 5 | ## Using the library 6 | 7 | Import the script `https://spotify-player.herokuapp.com/spotify-player.js`. Now you can log the user in and listen to updates on playback: 8 | 9 | ```js 10 | var spotifyPlayer = new SpotifyPlayer(); 11 | 12 | spotifyPlayer.on('update', response => { 13 | // response is a json object obtained as a response of 14 | // https://developer.spotify.com/web-api/get-information-about-the-users-current-playback/ 15 | }); 16 | 17 | spotifyPlayer.on('login', user => { 18 | if (user === null) { 19 | // there is no user logged in or the user was logged out 20 | } else { 21 | // the user is logged in 22 | // user is a json object obtained as a response of 23 | // https://developer.spotify.com/web-api/get-current-users-profile/ 24 | } 25 | }); 26 | 27 | loginButton.addEventListener('click', () => { 28 | spotifyPlayer.login(); 29 | }); 30 | 31 | logoutButton.addEventListener('click', () => { 32 | spotifyPlayer.logout(); 33 | }); 34 | 35 | spotifyPlayer.init(); 36 | ``` 37 | 38 | Have a look at http://codepen.io/jmperez/pen/MmwObE for an example of a visualization using this library. 39 | 40 | The library uses a shared server to issue the initial access token and refreshed tokens. This means your integration could reach Spotify's rate limits easily. If you want to have more control on this, deploy the code to your own server using the following instructions. 41 | 42 | ## Server 43 | 44 | The server can be run locally and also deployed to Heroku. You will need to register your own Spotify app and pass the credentials to the server. For that: 45 | 46 | 1. Create an application on [Spotify's Developer Site](https://developer.spotify.com/my-applications/). 47 | 2. Add as redirect uris both `http://localhost:5000/callback` (for development) and `/callback` (if you want to deploy your app somewhere) 48 | 3. Keep the client ID and client secret somewhere. You'll need them next. 49 | 50 | ### Running Locally 51 | 52 | Make sure you have [Node.js](http://nodejs.org/). 53 | 54 | ```sh 55 | $ npm install 56 | $ CLIENT_ID= CLIENT_SECRET= REDIRECT_URI= npm start 57 | ``` 58 | 59 | Your app should now be running on [localhost:5000](http://localhost:5000/). 60 | 61 | ## Deploying to Heroku 62 | 63 | You will need to have the [Heroku CLI](https://cli.heroku.com/) installed. 64 | 65 | ``` 66 | $ heroku create 67 | $ git push heroku master 68 | $ heroku open 69 | ``` 70 | or 71 | 72 | [![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) 73 | 74 | You will then need to set the environment variables [using `heroku config:set`](https://devcenter.heroku.com/articles/nodejs-support#environment-variables): 75 | ``` 76 | $ heroku config:set CLIENT_ID= 77 | $ heroku config:set CLIENT_SECRET= 78 | $ heroku config:set REDIRECT_URI= 79 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Start on Heroku: Node.js", 3 | "description": "A barebones Node.js app using Express 4", 4 | "repository": "https://github.com/heroku/node-js-getting-started", 5 | "logo": "http://node-js-sample.herokuapp.com/node.svg", 6 | "keywords": ["node", "express", "heroku"], 7 | "image": "heroku/nodejs" 8 | } 9 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Now Playing on Spotify 4 | 5 | 6 | 7 |
8 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /example/script.js: -------------------------------------------------------------------------------- 1 | var mainContainer = document.getElementById('js-main-container'), 2 | loginContainer = document.getElementById('js-login-container'), 3 | loginButton = document.getElementById('js-btn-login'), 4 | background = document.getElementById('js-background'); 5 | 6 | var spotifyPlayer = new SpotifyPlayer({ 7 | exchangeHost: 'http://localhost:5000' 8 | }); 9 | 10 | var template = function (data) { 11 | return ` 12 |
13 | 14 |
15 |
${data.item.name}
16 |
${data.item.artists[0].name}
17 |
${data.is_playing ? 'Playing' : 'Paused'}
18 |
19 |
20 |
21 |
22 |
23 |
24 | `; 25 | }; 26 | 27 | spotifyPlayer.on('update', response => { 28 | mainContainer.innerHTML = template(response); 29 | }); 30 | 31 | spotifyPlayer.on('login', user => { 32 | if (user === null) { 33 | loginContainer.style.display = 'block'; 34 | mainContainer.style.display = 'none'; 35 | } else { 36 | loginContainer.style.display = 'none'; 37 | mainContainer.style.display = 'block'; 38 | } 39 | }); 40 | 41 | loginButton.addEventListener('click', () => { 42 | spotifyPlayer.login(); 43 | }); 44 | 45 | spotifyPlayer.init(); 46 | -------------------------------------------------------------------------------- /example/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background-color: #333; 7 | color: #eee; 8 | font-family: Helvetica, Arial; 9 | font-size: 3vmin; 10 | } 11 | 12 | .hidden { 13 | display: none; 14 | } 15 | 16 | /** Buttons **/ 17 | .btn { 18 | background-color: transparent; 19 | border-radius: 2em; 20 | border: 0.2em solid #1ecd97; 21 | color: #1ecd97; 22 | cursor: pointer; 23 | font-size: 3vmin; 24 | padding: 0.7em 1.5em; 25 | text-transform: uppercase; 26 | transition: all 0.25s ease; 27 | } 28 | 29 | .btn:hover { 30 | background: #1ecd97; 31 | color: #333; 32 | } 33 | 34 | .btn--login { 35 | margin: 0 auto; 36 | } 37 | 38 | /** Now Playing **/ 39 | .now-playing__name { 40 | font-size: 1.5em; 41 | margin-bottom: 0.2em; 42 | } 43 | 44 | .now-playing__artist { 45 | margin-bottom: 1em; 46 | } 47 | 48 | .now-playing__status { 49 | margin-bottom: 1em; 50 | } 51 | 52 | .now-playing__img { 53 | float:left; 54 | margin-right: 10px; 55 | width: 45%; 56 | } 57 | 58 | .now-playing__side { 59 | margin-left: 5%; 60 | width: 45%; 61 | } 62 | 63 | /** Progress **/ 64 | .progress { 65 | border: 0.15em solid #eee; 66 | height: 1em; 67 | } 68 | 69 | .progress__bar { 70 | background-color: #eee; 71 | border: 0.1em solid transparent; 72 | height: 0.75em; 73 | } 74 | 75 | /** Background **/ 76 | .background { 77 | left: 0; 78 | right: 0; 79 | top: 0; 80 | bottom: 0; 81 | background-size: cover; 82 | background-position: center center; 83 | filter: blur(8em) opacity(0.6); 84 | position: absolute; 85 | } 86 | 87 | .main-wrapper { 88 | align-items: center; 89 | display: flex; 90 | height: 100%; 91 | margin: 0 auto; 92 | justify-content: center; 93 | position: relative; 94 | width: 90%; 95 | z-index: 1; 96 | } 97 | 98 | .container { 99 | align-items: center; 100 | display: flex; 101 | justify-content: center; 102 | height: 100%; 103 | } 104 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var querystring = require('querystring'); 3 | var cookieParser = require('cookie-parser'); 4 | var bodyParser = require('body-parser'); 5 | var request = require('request'); 6 | 7 | var app = express(); 8 | app.use(cookieParser()); 9 | app.use(bodyParser.json()); 10 | 11 | var DEV = process.env.DEV ? true : false; 12 | var stateKey = 'spotify_auth_state'; 13 | 14 | var client_id = process.env.CLIENT_ID; 15 | var client_secret = process.env.CLIENT_SECRET; 16 | var redirect_uri = DEV ? 'http://localhost:5000/callback' : process.env.REDIRECT_URI; 17 | 18 | app.set('port', (process.env.PORT || 5000)); 19 | 20 | app.use(express.static(__dirname + '/public')); 21 | 22 | // views is directory for all template files 23 | app.set('views', __dirname + '/views'); 24 | app.set('view engine', 'ejs'); 25 | 26 | /** 27 | * Generates a random string containing numbers and letters 28 | * @param {number} length The length of the string 29 | * @return {string} The generated string 30 | */ 31 | var generateRandomString = function(length) { 32 | var text = ''; 33 | var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 34 | 35 | for (var i = 0; i < length; i++) { 36 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 37 | } 38 | return text; 39 | }; 40 | 41 | app.all('*', function(req,res,next) { 42 | res.header("Access-Control-Allow-Origin", "*"); 43 | res.header("Access-Control-Allow-Headers", "Cache-Control, Pragma, Origin, Authorization, Content-Type, X-Requested-With"); 44 | res.header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS"); 45 | next(); 46 | }); 47 | 48 | app.get('/login', function(req, res) { 49 | var state = generateRandomString(16); 50 | res.cookie(stateKey, state); 51 | 52 | // your application requests authorization 53 | var scope = 'user-read-playback-state'; 54 | res.redirect('https://accounts.spotify.com/authorize?' + 55 | querystring.stringify({ 56 | response_type: 'code', 57 | client_id: client_id, 58 | scope: scope, 59 | redirect_uri: redirect_uri, 60 | state: state 61 | })); 62 | }); 63 | 64 | app.get('/callback', function(req, res) { 65 | 66 | // your application requests refresh and access tokens 67 | // after checking the state parameter 68 | 69 | var code = req.query.code || null; 70 | var state = req.query.state || null; 71 | var storedState = req.cookies ? req.cookies[stateKey] : null; 72 | 73 | if (state === null || state !== storedState) { 74 | console.log('state mismatch', 'state: ' + state, 'storedState ' + storedState, 'cookies ', req.cookies); 75 | res.render('pages/callback', { 76 | access_token: null, 77 | expires_in: null 78 | }); 79 | } else { 80 | res.clearCookie(stateKey); 81 | var authOptions = { 82 | url: 'https://accounts.spotify.com/api/token', 83 | form: { 84 | code: code, 85 | redirect_uri: redirect_uri, 86 | grant_type: 'authorization_code' 87 | }, 88 | headers: { 89 | 'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64')) 90 | }, 91 | json: true 92 | }; 93 | 94 | request.post(authOptions, function(error, response, body) { 95 | if (!error && response.statusCode === 200) { 96 | 97 | var access_token = body.access_token, 98 | refresh_token = body.refresh_token, 99 | expires_in = body.expires_in; 100 | 101 | console.log('everything is fine'); 102 | res.cookie('refresh_token', refresh_token, {maxAge: 30 * 24 * 3600 * 1000, domain: 'localhost'}); 103 | 104 | res.render('pages/callback', { 105 | access_token: access_token, 106 | expires_in: expires_in, 107 | refresh_token: refresh_token 108 | }); 109 | } else { 110 | console.log('wrong token'); 111 | 112 | res.render('pages/callback', { 113 | access_token: null, 114 | expires_in: null 115 | }); 116 | } 117 | }); 118 | } 119 | }); 120 | 121 | app.post('/token', function(req, res) { 122 | res.setHeader('Access-Control-Allow-Origin', '*'); 123 | var refreshToken = req.body ? req.body.refresh_token : null; 124 | if (refreshToken) { 125 | var authOptions = { 126 | url: 'https://accounts.spotify.com/api/token', 127 | form: { 128 | refresh_token: refreshToken, 129 | grant_type: 'refresh_token' 130 | }, 131 | headers: { 132 | 'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64')) 133 | }, 134 | json: true 135 | }; 136 | request.post(authOptions, function(error, response, body) { 137 | if (!error && response.statusCode === 200) { 138 | 139 | var access_token = body.access_token, 140 | expires_in = body.expires_in; 141 | 142 | res.setHeader('Content-Type', 'application/json'); 143 | res.send(JSON.stringify({ access_token: access_token, expires_in: expires_in })); 144 | } else { 145 | res.setHeader('Content-Type', 'application/json'); 146 | res.send(JSON.stringify({ access_token: '', expires_in: '' })); 147 | } 148 | }); 149 | } else { 150 | res.setHeader('Content-Type', 'application/json'); 151 | res.send(JSON.stringify({ access_token: '', expires_in: '' })); 152 | } 153 | }); 154 | 155 | app.listen(app.get('port'), function() { 156 | console.log('Node app is running on port', app.get('port')); 157 | }); 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spotify-player", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/JMPerez/spotify-player.git" 12 | }, 13 | "author": "", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/JMPerez/spotify-player/issues" 17 | }, 18 | "homepage": "https://github.com/JMPerez/spotify-player#readme", 19 | "dependencies": { 20 | "body-parser": "^1.17.1", 21 | "cookie-parser": "^1.4.3", 22 | "ejs": "2.5.6", 23 | "express": "^4.15.2", 24 | "request": "^2.81.0" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/spotify-player.js: -------------------------------------------------------------------------------- 1 | class SpotifyPlayer { 2 | constructor(options = {}) { 3 | this.options = options; 4 | this.listeners = {}; 5 | this.accessToken = null; 6 | this.exchangeHost = options.exchangeHost || 'https://spotify-player.herokuapp.com'; 7 | this.obtainingToken = false; 8 | this.loopInterval = null; 9 | } 10 | 11 | on(eventType, callback) { 12 | this.listeners[eventType] = this.listeners[eventType] || []; 13 | this.listeners[eventType].push(callback); 14 | } 15 | 16 | dispatch(topic, data) { 17 | const listeners = this.listeners[topic]; 18 | if (listeners) { 19 | listeners.forEach(listener => { 20 | listener.call(null, data); 21 | }); 22 | } 23 | } 24 | 25 | init() { 26 | this.fetchToken().then(r => r.json()).then(json => { 27 | this.accessToken = json['access_token']; 28 | this.expiresIn = json['expires_in']; 29 | this._onNewAccessToken(); 30 | }); 31 | } 32 | 33 | fetchToken() { 34 | this.obtainingToken = true; 35 | return fetch(`${this.exchangeHost}/token`, { 36 | method: 'POST', 37 | body: JSON.stringify({ 38 | refresh_token: localStorage.getItem('refreshToken') 39 | }), 40 | headers: new Headers({ 41 | 'Content-Type': 'application/json' 42 | }) 43 | }).then(response => { 44 | this.obtainingToken = false; 45 | return response; 46 | }).catch(e => { 47 | console.error(e); 48 | }); 49 | } 50 | 51 | _onNewAccessToken() { 52 | if (this.accessToken === '') { 53 | console.log('Got empty access token, log out'); 54 | this.dispatch('login', null); 55 | this.logout(); 56 | } else { 57 | const loop = () => { 58 | if (!this.obtainingToken) { 59 | this.fetchPlayer() 60 | .then(data => { 61 | if (data !== null && data.item !== null) { 62 | this.dispatch('update', data); 63 | } 64 | }) 65 | .catch(e => { 66 | console.log('Logging user out due to error', e); 67 | this.logout(); 68 | }); 69 | } 70 | }; 71 | this.fetchUser().then(user => { 72 | this.dispatch('login', user); 73 | this.loopInterval = setInterval(loop.bind(this), 1500); 74 | loop(); 75 | }); 76 | } 77 | } 78 | 79 | logout() { 80 | // clear loop interval 81 | if (this.loopInterval !== null) { 82 | clearInterval(this.loopInterval); 83 | this.loopInterval = null; 84 | } 85 | this.accessToken = null; 86 | this.dispatch('login', null); 87 | } 88 | 89 | login() { 90 | return new Promise((resolve, reject) => { 91 | const getLoginURL = scopes => { 92 | return `${this.exchangeHost}/login?scope=${encodeURIComponent(scopes.join(' '))}`; 93 | }; 94 | 95 | const url = getLoginURL(['user-read-playback-state']); 96 | 97 | const width = 450, height = 730, left = screen.width / 2 - width / 2, top = screen.height / 2 - height / 2; 98 | 99 | window.addEventListener( 100 | 'message', 101 | event => { 102 | const hash = JSON.parse(event.data); 103 | if (hash.type == 'access_token') { 104 | this.accessToken = hash.access_token; 105 | this.expiresIn = hash.expires_in; 106 | this._onNewAccessToken(); 107 | if (this.accessToken === '') { 108 | reject(); 109 | } else { 110 | const refreshToken = hash.refresh_token; 111 | localStorage.setItem('refreshToken', refreshToken); 112 | resolve(hash.access_token); 113 | } 114 | } 115 | }, 116 | false 117 | ); 118 | 119 | const w = window.open( 120 | url, 121 | 'Spotify', 122 | 'menubar=no,location=no,resizable=no,scrollbars=no,status=no, width=' + 123 | width + 124 | ', height=' + 125 | height + 126 | ', top=' + 127 | top + 128 | ', left=' + 129 | left 130 | ); 131 | }); 132 | } 133 | 134 | fetchGeneric(url) { 135 | return fetch(url, { 136 | headers: { Authorization: 'Bearer ' + this.accessToken } 137 | }); 138 | } 139 | 140 | fetchPlayer() { 141 | return this.fetchGeneric('https://api.spotify.com/v1/me/player').then(response => { 142 | if (response.status === 401) { 143 | return this.fetchToken() 144 | .then(tokenResponse => { 145 | if (tokenResponse.status === 200) { 146 | return tokenResponse.json(); 147 | } else { 148 | throw 'Could not refresh token'; 149 | } 150 | }) 151 | .then(json => { 152 | this.accessToken = json['access_token']; 153 | this.expiresIn = json['expires_in']; 154 | return this.fetchPlayer(); 155 | }); 156 | } else if (response.status >= 500) { 157 | // assume an error on Spotify's site 158 | console.error('Got error when fetching player', response); 159 | return null; 160 | } else { 161 | return response.json(); 162 | } 163 | }); 164 | } 165 | 166 | fetchUser() { 167 | return this.fetchGeneric('https://api.spotify.com/v1/me').then(data => data.json()); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /views/pages/callback.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | This page should close in a few seconds. 17 | 18 | 19 | 20 | --------------------------------------------------------------------------------