diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..1da0cd6 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node index.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..0567cf5 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# node-js-getting-started + +A barebones Node.js app using [Express 4](http://expressjs.com/). + +This application supports the [Getting Started with Node on Heroku](https://devcenter.heroku.com/articles/getting-started-with-nodejs) article - check it out. + +## Running Locally + +Make sure you have [Node.js](http://nodejs.org/) and the [Heroku CLI](https://cli.heroku.com/) installed. + +```sh +$ git clone git@github.com:heroku/node-js-getting-started.git # or clone your own fork +$ cd node-js-getting-started +$ npm install +$ npm start +``` + +Your app should now be running on [localhost:5000](http://localhost:5000/). + +## Deploying to Heroku + +``` +$ heroku create +$ git push heroku master +$ heroku open +``` +or + +[![Deploy to Heroku](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy) + +## Documentation + +For more information about using Node.js on Heroku, see these Dev Center articles: + +- [Getting Started with Node.js on Heroku](https://devcenter.heroku.com/articles/getting-started-with-nodejs) +- [Heroku Node.js Support](https://devcenter.heroku.com/articles/nodejs-support) +- [Node.js on Heroku](https://devcenter.heroku.com/categories/nodejs) +- [Best Practices for Node.js Development](https://devcenter.heroku.com/articles/node-best-practices) +- [Using WebSockets on Heroku with Node.js](https://devcenter.heroku.com/articles/node-websockets) diff --git a/app.js b/app.js deleted file mode 100644 index 0b0c408..0000000 --- a/app.js +++ /dev/null @@ -1,8 +0,0 @@ -var express = require('express') -var app = express() - -app.use(express.static('public')) - -app.listen(3000, function () { - console.log('Example app listening on port 3000!') -}) \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..74d9743 --- /dev/null +++ b/app.json @@ -0,0 +1,8 @@ +{ + "name": "Start on Heroku: Node.js", + "description": "A barebones Node.js app using Express 4", + "repository": "https://github.com/heroku/node-js-getting-started", + "logo": "http://node-js-sample.herokuapp.com/node.svg", + "keywords": ["node", "express", "heroku"], + "image": "heroku/nodejs" +} diff --git a/example/index.html b/example/index.html new file mode 100644 index 0000000..6f8f3c0 --- /dev/null +++ b/example/index.html @@ -0,0 +1,16 @@ + + + Now Playing on Spotify + + + +
+ + +
+ + + + diff --git a/example/script.js b/example/script.js new file mode 100644 index 0000000..c0e6d0c --- /dev/null +++ b/example/script.js @@ -0,0 +1,45 @@ +var mainContainer = document.getElementById('js-main-container'), + loginContainer = document.getElementById('js-login-container'), + loginButton = document.getElementById('js-btn-login'), + background = document.getElementById('js-background'); + +var spotifyPlayer = new SpotifyPlayer({ + exchangeHost: 'http://localhost:5000' +}); + +var template = function (data) { + return ` +
+ +
+
${data.item.name}
+
${data.item.artists[0].name}
+
${data.is_playing ? 'Playing' : 'Paused'}
+
+
+
+
+
+
+ `; +}; + +spotifyPlayer.on('update', response => { + mainContainer.innerHTML = template(response); +}); + +spotifyPlayer.on('login', user => { + if (user === null) { + loginContainer.style.display = 'block'; + mainContainer.style.display = 'none'; + } else { + loginContainer.style.display = 'none'; + mainContainer.style.display = 'block'; + } +}); + +loginButton.addEventListener('click', () => { + spotifyPlayer.login(); +}); + +spotifyPlayer.init(); diff --git a/example/style.css b/example/style.css new file mode 100644 index 0000000..3668ae8 --- /dev/null +++ b/example/style.css @@ -0,0 +1,103 @@ +* { + box-sizing: border-box; +} + +body { + background-color: #333; + color: #eee; + font-family: Helvetica, Arial; + font-size: 3vmin; +} + +.hidden { + display: none; +} + +/** Buttons **/ +.btn { + background-color: transparent; + border-radius: 2em; + border: 0.2em solid #1ecd97; + color: #1ecd97; + cursor: pointer; + font-size: 3vmin; + padding: 0.7em 1.5em; + text-transform: uppercase; + transition: all 0.25s ease; +} + +.btn:hover { + background: #1ecd97; + color: #333; +} + +.btn--login { + margin: 0 auto; +} + +/** Now Playing **/ +.now-playing__name { + font-size: 1.5em; + margin-bottom: 0.2em; +} + +.now-playing__artist { + margin-bottom: 1em; +} + +.now-playing__status { + margin-bottom: 1em; +} + +.now-playing__img { + float:left; + margin-right: 10px; + width: 45%; +} + +.now-playing__side { + margin-left: 5%; + width: 45%; +} + +/** Progress **/ +.progress { + border: 0.15em solid #eee; + height: 1em; +} + +.progress__bar { + background-color: #eee; + border: 0.1em solid transparent; + height: 0.75em; +} + +/** Background **/ +.background { + left: 0; + right: 0; + top: 0; + bottom: 0; + background-size: cover; + background-position: center center; + filter: blur(8em) opacity(0.6); + position: absolute; +} + +.main-wrapper { + align-items: center; + display: flex; + height: 100%; + margin: 0 auto; + justify-content: center; + position: relative; + width: 90%; + z-index: 1; +} + +.container { + align-items: center; + display: flex; + justify-content: center; + height: 100%; +} diff --git a/index.html b/index.html deleted file mode 100644 index a164684..0000000 --- a/index.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - -
- -
-
- - - - - - - \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..a64daec --- /dev/null +++ b/index.js @@ -0,0 +1,157 @@ +var express = require('express'); +var querystring = require('querystring'); +var cookieParser = require('cookie-parser'); +var bodyParser = require('body-parser'); +var request = require('request'); + +var app = express(); +app.use(cookieParser()); +app.use(bodyParser.json()); + +var DEV = process.env.DEV ? true : false; +var stateKey = 'spotify_auth_state'; + +var client_id = process.env.CLIENT_ID; +var client_secret = process.env.CLIENT_SECRET; +var redirect_uri = DEV ? 'http://localhost:5000/callback' : process.env.REDIRECT_URI; + +app.set('port', (process.env.PORT || 5000)); + +app.use(express.static(__dirname + '/public')); + +// views is directory for all template files +app.set('views', __dirname + '/views'); +app.set('view engine', 'ejs'); + +/** + * Generates a random string containing numbers and letters + * @param {number} length The length of the string + * @return {string} The generated string + */ +var generateRandomString = function(length) { + var text = ''; + var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (var i = 0; i < length; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +}; + +app.all('*', function(req,res,next) { + res.header("Access-Control-Allow-Origin", "*"); + res.header("Access-Control-Allow-Headers", "Cache-Control, Pragma, Origin, Authorization, Content-Type, X-Requested-With"); + res.header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS"); + next(); +}); + +app.get('/login', function(req, res) { + var state = generateRandomString(16); + res.cookie(stateKey, state); + + // your application requests authorization + var scope = 'user-read-playback-state'; + res.redirect('https://accounts.spotify.com/authorize?' + + querystring.stringify({ + response_type: 'code', + client_id: client_id, + scope: scope, + redirect_uri: redirect_uri, + state: state + })); +}); + +app.get('/callback', function(req, res) { + + // your application requests refresh and access tokens + // after checking the state parameter + + var code = req.query.code || null; + var state = req.query.state || null; + var storedState = req.cookies ? req.cookies[stateKey] : null; + + if (state === null || state !== storedState) { + console.log('state mismatch', 'state: ' + state, 'storedState ' + storedState, 'cookies ', req.cookies); + res.render('pages/callback', { + access_token: null, + expires_in: null + }); + } else { + res.clearCookie(stateKey); + var authOptions = { + url: 'https://accounts.spotify.com/api/token', + form: { + code: code, + redirect_uri: redirect_uri, + grant_type: 'authorization_code' + }, + headers: { + 'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64')) + }, + json: true + }; + + request.post(authOptions, function(error, response, body) { + if (!error && response.statusCode === 200) { + + var access_token = body.access_token, + refresh_token = body.refresh_token, + expires_in = body.expires_in; + + console.log('everything is fine'); + res.cookie('refresh_token', refresh_token, {maxAge: 30 * 24 * 3600 * 1000, domain: 'localhost'}); + + res.render('pages/callback', { + access_token: access_token, + expires_in: expires_in, + refresh_token: refresh_token + }); + } else { + console.log('wrong token'); + + res.render('pages/callback', { + access_token: null, + expires_in: null + }); + } + }); + } +}); + +app.post('/token', function(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + var refreshToken = req.body ? req.body.refresh_token : null; + if (refreshToken) { + var authOptions = { + url: 'https://accounts.spotify.com/api/token', + form: { + refresh_token: refreshToken, + grant_type: 'refresh_token' + }, + headers: { + 'Authorization': 'Basic ' + (new Buffer(client_id + ':' + client_secret).toString('base64')) + }, + json: true + }; + request.post(authOptions, function(error, response, body) { + if (!error && response.statusCode === 200) { + + var access_token = body.access_token, + expires_in = body.expires_in; + + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify({ access_token: access_token, expires_in: expires_in })); + } else { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify({ access_token: '', expires_in: '' })); + } + }); + } else { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify({ access_token: '', expires_in: '' })); + } +}); + +app.listen(app.get('port'), function() { + console.log('Node app is running on port', app.get('port')); +}); diff --git a/package.json b/package.json index 858b685..ecf2626 100644 --- a/package.json +++ b/package.json @@ -2,22 +2,25 @@ "name": "spotify-player", "version": "0.0.1", "description": "", - "main": "app.js", + "main": "index.js", "scripts": { - "start": "node app.js", - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node index.js" }, "repository": { "type": "git", "url": "git+https://github.com/JMPerez/spotify-player.git" }, "author": "", - "license": "ISC", + "license": "MIT", "bugs": { "url": "https://github.com/JMPerez/spotify-player/issues" }, "homepage": "https://github.com/JMPerez/spotify-player#readme", "dependencies": { - "express": "^4.15.2" + "body-parser": "^1.17.1", + "cookie-parser": "^1.4.3", + "ejs": "2.5.6", + "express": "^4.15.2", + "request": "^2.81.0" } } diff --git a/public/callback.html b/public/callback.html deleted file mode 100644 index 462b855..0000000 --- a/public/callback.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - \ No newline at end of file diff --git a/public/spotify-player.js b/public/spotify-player.js index e1b1ebb..7790ab3 100644 --- a/public/spotify-player.js +++ b/public/spotify-player.js @@ -1,94 +1,177 @@ class SpotifyPlayer { - constructor(options = {}) { - this.options = options; - this.listeners = {}; - this.accessToken = null; - this.loggedIn = false; - } + constructor(options = {}) { + this.options = options; + this.listeners = {}; + this.accessToken = null; + this.exchangeHost = options.exchangeHost || 'https://spotify-player.herokuapp.com'; + this.obtainingToken = false; + this.loopInterval = null; + } + + on(eventType, callback) { + this.listeners[eventType] = this.listeners[eventType] || []; + this.listeners[eventType].push(callback); + } - on(eventType, callback) { - this.listeners[eventType] = this.listeners[eventType] || []; - this.listeners[eventType].push(callback); + dispatch(topic, data) { + const listeners = this.listeners[topic]; + if (listeners) { + listeners.forEach(listener => { + listener.call(null, data); + }); } + } - init() { - const loop = () => { - this.fetchPlayer().then(data => { - this.dispatch('update', data); - }); - }; + init() { + this.fetchToken().then(r => r.json()).then(json => { + this.accessToken = json['access_token']; + this.expiresIn = json['expires_in']; + this._onNewAccessToken(); + }); + } - return this.login().then(accessToken => { - this.accessToken = accessToken; - this.loggedIn = true; - this.fetchUser().then(user => { - this.dispatch('login', user); - setInterval(loop.bind(this), 1500); - loop(); + fetchToken() { + this.obtainingToken = true; + return fetch(`${this.exchangeHost}/token`, { + method: 'POST', + body: JSON.stringify({ + refresh_token: localStorage.getItem('refreshToken') + }), + headers: new Headers({ + 'Content-Type': 'application/json' + }) + }).then(response => { + this.obtainingToken = false; + return response; + }).catch(e => { + console.error(e); + }); + } + + _onNewAccessToken() { + if (this.accessToken === '') { + console.log('Got empty access token, log out'); + this.dispatch('login', null); + this.logout(); + } else { + const loop = () => { + if (!this.obtainingToken) { + this.fetchPlayer() + .then(data => { + if (data !== null) { + this.dispatch('update', data); + } + }) + .catch(e => { + console.log('Logging user out due to error', e); + this.logout(); }); - }); + } + }; + this.fetchUser().then(user => { + this.dispatch('login', user); + this.loopInterval = setInterval(loop.bind(this), 1500); + loop(); + }); } + } - login() { - return new Promise((resolve, reject) => { - const CLIENT_ID = '37f5082d5ee1489db5cceeaaef7b9691'; - const REDIRECT_URI = 'https://jmperezperez.com/spotify-player/public/callback.html'; - const getLoginURL = (scopes) => { - return 'https://accounts.spotify.com/authorize?client_id=' + CLIENT_ID + - '&redirect_uri=' + encodeURIComponent(REDIRECT_URI) + - '&scope=' + encodeURIComponent(scopes.join(' ')) + - '&response_type=token'; - }; + logout() { + // clear loop interval + if (this.loopInterval !== null) { + clearInterval(this.loopInterval); + this.loopInterval = null; + } + this.accessToken = null; + } - const url = getLoginURL([ - 'user-read-playback-state' - ]); + login() { + return new Promise((resolve, reject) => { + const getLoginURL = scopes => { + return `${this.exchangeHost}/login?scope=${encodeURIComponent(scopes.join(' '))}`; + }; - const width = 450, - height = 730, - left = (screen.width / 2) - (width / 2), - top = (screen.height / 2) - (height / 2); + const url = getLoginURL(['user-read-playback-state']); - window.addEventListener('message', function(event) { - const hash = JSON.parse(event.data); - if (hash.type == 'access_token') { - resolve(hash.access_token); - } - }, false); + const width = 450, height = 730, left = screen.width / 2 - width / 2, top = screen.height / 2 - height / 2; - const w = window.open(url, - 'Spotify', - 'menubar=no,location=no,resizable=no,scrollbars=no,status=no, width=' + width + ', height=' + height + ', top=' + top + ', left=' + left - ); - }); - } + window.addEventListener( + 'message', + event => { + const hash = JSON.parse(event.data); + if (hash.type == 'access_token') { + this.accessToken = hash.access_token; + this.expiresIn = hash.expires_in; + this._onNewAccessToken(); + if (this.accessToken === '') { + reject(); + } else { + const refreshToken = hash.refresh_token; + localStorage.setItem('refreshToken', refreshToken); + resolve(hash.access_token); + } + } + }, + false + ); - getAccessToken() { - return this.accessToken; - } + const w = window.open( + url, + 'Spotify', + 'menubar=no,location=no,resizable=no,scrollbars=no,status=no, width=' + + width + + ', height=' + + height + + ', top=' + + top + + ', left=' + + left + ); + }); + } - fetchGeneric(url) { - return fetch(url, { - headers: { 'Authorization': 'Bearer ' + this.accessToken } - }); - } + getAccessToken() { + return this.accessToken; + } - fetchPlayer() { - return this.fetchGeneric('https://api.spotify.com/v1/me/player') - .then(data => data.json()) - } + fetchGeneric(url) { + return fetch(url, { + headers: { Authorization: 'Bearer ' + this.accessToken } + }); + } - fetchUser() { - return this.fetchGeneric('https://api.spotify.com/v1/me') - .then(data => data.json()) - } + fetchPlayer() { + return this.fetchGeneric('https://api.spotify.com/v1/me/player').then(response => { + if (response.status === 401) { + return this.fetchToken() + .then(tokenResponse => { + console.log('fetchPlayer => fetchToken with result', tokenResponse); + if (tokenResponse.status === 200) { + console.log('fetchPlayer => fetchToken returning', tokenResponse); + return tokenResponse.json(); + } else { + console.error('fetchPlayer => fetchToken with status code different than 200', tokenResponse); + throw 'Could not refresh token'; + } + }) + .then(json => { + console.log('fetchPlayer => fetchToken got json', json); + this.accessToken = json['access_token']; + this.expiresIn = json['expires_in']; + console.log('fetchPlayer => fetchToken calling fetchPlayer again', this.fetchPlayer); + return this.fetchPlayer(); + }); + } else if (response.status >= 500) { + // assume an error on Spotify's site + console.error('Got error when fetching player', response); + return null; + } else { + return response.json(); + } + }); + } - dispatch(topic, data) { - const listeners = this.listeners[topic]; - if (listeners) { - listeners.forEach(listener => { - listener.call(null, data); - }); - } - } -} \ No newline at end of file + fetchUser() { + return this.fetchGeneric('https://api.spotify.com/v1/me').then(data => data.json()); + } +} diff --git a/script.js b/script.js deleted file mode 100644 index 71bf4f6..0000000 --- a/script.js +++ /dev/null @@ -1,14 +0,0 @@ -var templateSource = document.getElementById('result-template').innerHTML, - template = Handlebars.compile(templateSource), - resultsPlaceholder = document.getElementById('result'), - loginButton = document.getElementById('btn-login'); - -const spotifyPlayer = new SpotifyPlayer(); -spotifyPlayer.on('update', response => { - resultsPlaceholder.innerHTML = template(response); -}); - -loginButton.addEventListener('click', () => { - loginButton.style.display = 'none'; - spotifyPlayer.init(); -}); \ No newline at end of file diff --git a/style.css b/style.css deleted file mode 100644 index 7008d53..0000000 --- a/style.css +++ /dev/null @@ -1,4 +0,0 @@ -.media {margin:10px;} -.media, .bd {overflow:hidden; _overflow:visible; zoom:1;} -.media .img {float:left; margin-right: 10px;} -.media .img img{display:block;} \ No newline at end of file diff --git a/views/pages/callback.ejs b/views/pages/callback.ejs new file mode 100644 index 0000000..89bf8bd --- /dev/null +++ b/views/pages/callback.ejs @@ -0,0 +1,19 @@ + + + + + + + + This page should close in a few seconds. + + +