├── .dockerignore ├── .editorconfig ├── .gitignore ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── actions ├── devicesActions.js ├── playbackActions.js ├── queueActions.js ├── searchActions.js ├── sessionActions.js ├── usersActions.js └── voteActions.js ├── components ├── AddToQueue.js ├── ButtonDarkStyle.js ├── ButtonStyle.js ├── Devices.js ├── Header.js ├── MyLayout.js ├── NowPlaying.js ├── PageWithIntl.js ├── Queue.js ├── QueueItem.js └── Users.js ├── config ├── app.js └── auth.js ├── constants └── ActionTypes.js ├── lang ├── ca.json ├── en.json └── es.json ├── middlewares ├── devicesMiddleware.js ├── loggerMiddleware.js ├── playbackMiddleware.js ├── searchMiddleware.js ├── sessionMiddleware.js └── socketMiddleware.js ├── next.config.js ├── package.json ├── pages ├── _document.js ├── about.js └── index.js ├── reducers ├── devicesReducer.js ├── index.js ├── playbackReducer.js ├── queueReducer.js ├── searchReducer.js ├── sessionReducer.js └── usersReducer.js ├── server ├── api.js ├── auth.js ├── models │ ├── Bot.js │ ├── QueueItem.js │ └── QueueManager.js ├── server.js └── views │ └── pages │ └── callback.ejs ├── static ├── c-icon-128.png ├── c-icon.png ├── robot-icon.png └── user-icon.png └── store └── store.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .env 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # See editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [**.js] 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [**.json] 16 | indent_style = space 17 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | queue.json 4 | package-lock.json 5 | .env 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13 2 | 3 | COPY package*.json ./ 4 | RUN npm ci --only=production 5 | 6 | COPY . . 7 | RUN npm run build 8 | 9 | EXPOSE 3000 10 | 11 | CMD [ "npm", "run", "start" ] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 José Manuel Pérez 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run build && npm run start -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # C - A collaborative listening room using Spotify 2 | 3 | This project is a site where multiple users can propose songs and vote for them, having them played in a synchronised way through Spotify. 4 | 5 | ## Setting up 6 | 7 | The server can be run locally and also deployed to Heroku. You will need to register your own Spotify app and set the credentials in a couple of config files. For that: 8 | 9 | 1. Create an application on [Spotify's Developer Site](https://developer.spotify.com/my-applications/). 10 | 11 | 2. Add as redirect uris both http://localhost:3000/auth/callback (for development) and /auth/callback (if you want to deploy your app somewhere). 12 | 13 | 3. Create a `.env` file in the root of the project with the following variables; 14 | 15 | - `HOST` 16 | - `CLIENT_ID` 17 | - `CLIENT_SECRET` 18 | 19 | Example: 20 | ``` 21 | HOST=http://localhost:3000 22 | CLIENT_ID= 23 | CLIENT_SECRET= 24 | ``` 25 | 26 | 27 | 28 | ## Dependencies 29 | 30 | Install the dependencies running `npm install`. 31 | 32 | ## Running 33 | 34 | During development, run `npm run dev`. 35 | 36 | When running on production, run `npm run build && npm run start`. 37 | 38 | 39 | ### Run with Docker 40 | 41 | To run this app in Docker use the following steps 42 | 43 | 1. Build the image run: 44 | `docker build -t c .` 45 | 46 | 2. Run the image: 47 | ``` 48 | docker run -p 3000:3000 \ 49 | -e HOST=http://localhost:3000 \ 50 | -e CLIENT_ID= \ 51 | -e CLIENT_SECRET= \ 52 | c 53 | ``` 54 | -------------------------------------------------------------------------------- /actions/devicesActions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | 3 | export const fetchAvailableDevices = () => ({ 4 | type: types.FETCH_AVAILABLE_DEVICES 5 | }); 6 | export const fetchAvailableDevicesSuccess = list => ({ 7 | type: types.FETCH_AVAILABLE_DEVICES_SUCCESS, 8 | list 9 | }); 10 | export const fetchAvailableDevicesError = error => ({ 11 | type: types.FETCH_AVAILABLE_DEVICES_ERROR, 12 | error 13 | }); 14 | 15 | export const transferPlaybackToDevice = deviceId => ({ 16 | type: types.TRANSFER_PLAYBACK_TO_DEVICE, 17 | deviceId 18 | }); 19 | export const transferPlaybackToDeviceSuccess = list => ({ 20 | type: types.TRANSFER_PLAYBACK_TO_DEVICE_SUCCESS 21 | }); 22 | export const transferPlaybackToDeviceError = list => ({ 23 | type: types.TRANSFER_PLAYBACK_TO_DEVICE_ERROR, 24 | error 25 | }); 26 | -------------------------------------------------------------------------------- /actions/playbackActions.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | import Config from '../config/app'; 4 | import * as types from '../constants/ActionTypes'; 5 | 6 | // playback 7 | export const playTrack = (track, user, position) => ({ 8 | type: types.PLAY_TRACK, 9 | track, 10 | user, 11 | position 12 | }); 13 | export const updateNowPlaying = (track, user, position) => ({ 14 | type: types.UPDATE_NOW_PLAYING, 15 | track, 16 | user, 17 | position 18 | }); 19 | export const playTrackSuccess = (track, user, position) => ({ 20 | type: types.PLAY_TRACK_SUCCESS, 21 | track, 22 | user, 23 | position 24 | }); 25 | 26 | export const mutePlayback = () => ({ type: types.MUTE_PLAYBACK }); 27 | export const unmutePlayback = () => ({ type: types.UNMUTE_PLAYBACK }); 28 | 29 | export const fetchPlayingContextSuccess = playingContext => ({ 30 | type: types.FETCH_PLAYING_CONTEXT_SUCCESS, 31 | playingContext 32 | }); 33 | 34 | export const fetchPlayingContext = () => dispatch => 35 | fetch(`${Config.HOST}/api/now-playing`) 36 | .then(res => res.json()) 37 | .then(res => dispatch(fetchPlayingContextSuccess(res))); 38 | -------------------------------------------------------------------------------- /actions/queueActions.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | import Config from '../config/app'; 4 | import * as types from '../constants/ActionTypes'; 5 | 6 | export const queueTrack = id => ({ type: types.QUEUE_TRACK, id }); 7 | export const updateQueue = queue => ({ type: types.UPDATE_QUEUE, data: queue }); 8 | export const queueEnded = () => ({ type: types.QUEUE_ENDED }); 9 | export const queueRemoveTrack = id => ({ 10 | type: types.QUEUE_REMOVE_TRACK, 11 | id 12 | }); 13 | 14 | export const fetchQueue = () => dispatch => 15 | fetch(`${Config.HOST}/api/queue`).then(res => res.json()).then(res => dispatch(updateQueue(res))); 16 | -------------------------------------------------------------------------------- /actions/searchActions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | 3 | export const searchTracks = query => ({ type: types.SEARCH_TRACKS, query }); 4 | export const searchTracksSuccess = (query, results) => ({ 5 | type: types.SEARCH_TRACKS_SUCCESS, 6 | query, 7 | results 8 | }); 9 | export const searchTracksReset = () => ({ type: types.SEARCH_TRACKS_RESET }); 10 | export const fetchTrack = id => ({ type: types.FETCH_TRACK, id }); 11 | export const fetchTrackSuccess = (id, track) => ({ 12 | type: types.FETCH_TRACK_SUCCESS, 13 | id 14 | }); 15 | -------------------------------------------------------------------------------- /actions/sessionActions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | 3 | export const load = () => ({ type: types.LOAD }); 4 | export const login = () => ({ type: types.LOGIN }); 5 | export const loginSuccess = () => ({ 6 | type: types.LOGIN_SUCCESS 7 | }); 8 | export const loginFailure = refresh_token => ({ 9 | type: types.LOGIN_FAILURE, 10 | refresh_token 11 | }); 12 | export const updateToken = refreshToken => ({ 13 | type: types.UPDATE_TOKEN, 14 | refreshToken 15 | }); 16 | export const updateTokenSuccess = access_token => ({ 17 | type: types.UPDATE_TOKEN_SUCCESS, 18 | access_token 19 | }); 20 | export const updateCurrentUser = user => ({ 21 | type: types.UPDATE_CURRENT_USER, 22 | user 23 | }); 24 | -------------------------------------------------------------------------------- /actions/usersActions.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | import Config from '../config/app'; 4 | import * as types from '../constants/ActionTypes'; 5 | 6 | export const updateUsers = users => ({ type: types.UPDATE_USERS, data: users }); 7 | 8 | export const fetchUsers = () => dispatch => 9 | fetch(`${Config.HOST}/api/users`).then(res => res.json()).then(res => dispatch(updateUsers(res))); 10 | -------------------------------------------------------------------------------- /actions/voteActions.js: -------------------------------------------------------------------------------- 1 | import * as types from '../constants/ActionTypes'; 2 | 3 | export const voteUp = id => ({ 4 | type: types.VOTE_UP, 5 | id 6 | }); 7 | 8 | export const voteUpSuccess = () => ({ 9 | type: types.VOTE_UP_SUCCESS 10 | }); 11 | -------------------------------------------------------------------------------- /components/AddToQueue.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { injectIntl } from 'react-intl'; 4 | 5 | import { searchTracks, searchTracksReset } from '../actions/searchActions'; 6 | import { queueTrack } from '../actions/queueActions'; 7 | 8 | class ResultsList extends Component { 9 | render() { 10 | const { results, focus } = this.props; 11 | return ( 12 |
    13 | 42 | {results.map((r, index) => { 43 | const isFocused = focus === index; 44 | const className = 45 | 'add-to-queue__search-results-item' + (isFocused ? ' add-to-queue__search-results-item--focused' : ''); 46 | return ( 47 |
  • this.props.onSelect(r.id)}> 48 |
    49 |
    50 | 51 |
    52 |
    53 |
    {r.name}
    54 |
    {r.artists[0].name}
    55 |
    56 |
    57 |
  • 58 | ); 59 | })} 60 |
61 | ); 62 | } 63 | } 64 | 65 | class AddToQueue extends Component { 66 | state = { 67 | text: this.props.text || '', 68 | focus: -1 69 | }; 70 | 71 | handleChange = e => { 72 | const text = e.target.value; 73 | this.setState({ text: text }); 74 | if (text !== '') { 75 | this.props.searchTracks(text); 76 | } else { 77 | this.setState({ focus: -1 }); 78 | this.props.searchTracksReset(); 79 | } 80 | }; 81 | 82 | handleSelectElement = id => { 83 | this.setState({ text: '' }); 84 | this.props.queueTrack(id); 85 | this.props.searchTracksReset(); 86 | }; 87 | 88 | handleBlur = e => { 89 | // todo: this happens before the item from the list is selected, hiding the 90 | // list of results. We need to do this in a different way. 91 | /* this.setState({ focus: -1 }); 92 | this.props.searchTracksReset(); */ 93 | }; 94 | 95 | handleFocus = e => { 96 | if (e.target.value !== '') { 97 | this.props.searchTracks(e.target.value); 98 | } 99 | }; 100 | 101 | handleKeyDown = e => { 102 | switch (e.keyCode) { 103 | case 38: // up 104 | this.setState({ focus: this.state.focus - 1 }); 105 | break; 106 | case 40: // down 107 | this.setState({ focus: this.state.focus + 1 }); 108 | break; 109 | case 13: { 110 | let correct = false; 111 | if (this.state.focus !== -1) { 112 | this.props.queueTrack(this.props.search.results[this.state.focus].id); 113 | correct = true; 114 | } else { 115 | const text = e.target.value.trim(); 116 | if (text.length !== 0) { 117 | this.props.queueTrack(text); 118 | correct = true; 119 | } 120 | } 121 | if (correct) { 122 | this.setState({ text: '' }); 123 | this.props.searchTracksReset(); 124 | this.setState({ focus: -1 }); 125 | } 126 | break; 127 | } 128 | } 129 | }; 130 | 131 | render() { 132 | const placeholder = this.props.intl.formatMessage({id: 'queue.add'}); 133 | const results = this.props.search.results; 134 | return ( 135 |
136 | 142 | 150 | {results && } 151 |
152 | ); 153 | } 154 | } 155 | 156 | const mapDispatchToProps = dispatch => ({ 157 | queueTrack: text => dispatch(queueTrack(text)), 158 | searchTracks: query => dispatch(searchTracks(query)), 159 | searchTracksReset: () => dispatch(searchTracksReset()) 160 | }); 161 | 162 | const mapStateToProps = state => ({ 163 | search: state.search 164 | }); 165 | 166 | export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(AddToQueue)); 167 | -------------------------------------------------------------------------------- /components/ButtonDarkStyle.js: -------------------------------------------------------------------------------- 1 | import css from 'styled-jsx/css'; 2 | 3 | export default css`.btn--dark { 4 | background-color: #bbc8d5; 5 | border: 1px solid #bbc8d5; 6 | color: #333; 7 | }`; 8 | -------------------------------------------------------------------------------- /components/ButtonStyle.js: -------------------------------------------------------------------------------- 1 | import css from 'styled-jsx/css'; 2 | 3 | export default css`.btn { 4 | background-color: transparent; 5 | border: 1px solid #666; 6 | border-radius: 50px; 7 | color: #666; 8 | cursor: pointer; 9 | line-height: 28px; 10 | padding: 0 15px; 11 | }`; 12 | -------------------------------------------------------------------------------- /components/Devices.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { FormattedMessage } from 'react-intl'; 4 | 5 | import ButtonStyle from './ButtonStyle'; 6 | import ButtonDarkStyle from './ButtonDarkStyle'; 7 | import { fetchAvailableDevices, transferPlaybackToDevice } from '../actions/devicesActions'; 8 | import { getIsFetchingDevices } from '../reducers'; 9 | import { getDevices } from '../reducers'; 10 | class Devices extends React.PureComponent { 11 | render() { 12 | const { devices, isFetching, fetchAvailableDevices, transferPlaybackToDevice } = this.props; 13 | return ( 14 |
15 |

16 | 19 | 22 | 31 | {devices.length === 0 32 | ?

33 | : 34 | 35 | {devices.map(device => ( 36 | 37 | 48 | 51 | 54 | 57 | 58 | ))} 59 | 60 |
38 | {device.is_active 39 | ? Active -> 40 | : } 47 | 49 | {device.name} 50 | 52 | {device.type} 53 | 55 | {device.volume} 56 |
} 61 |
62 | ); 63 | } 64 | } 65 | 66 | const mapDispatchToProps = dispatch => ({ 67 | fetchAvailableDevices: index => dispatch(fetchAvailableDevices(index)), 68 | transferPlaybackToDevice: deviceId => dispatch(transferPlaybackToDevice(deviceId)) 69 | }); 70 | 71 | const mapStateToProps = state => ({ 72 | isFetching: getIsFetchingDevices(state), 73 | devices: getDevices(state) 74 | }); 75 | 76 | export default connect(mapStateToProps, mapDispatchToProps)(Devices); 77 | -------------------------------------------------------------------------------- /components/Header.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { connect } from 'react-redux'; 3 | import { FormattedMessage } from 'react-intl'; 4 | import { login } from '../actions/sessionActions'; 5 | import { mutePlayback, unmutePlayback } from '../actions/playbackActions'; 6 | import ButtonStyle from './ButtonStyle'; 7 | import ButtonDarkStyle from './ButtonDarkStyle'; 8 | 9 | const linkStyle = { 10 | lineHeight: '30px', 11 | marginRight: 15 12 | }; 13 | 14 | const mainLinkStyle = { 15 | float: 'left', 16 | marginRight: '10px' 17 | }; 18 | 19 | const headerStyle = { 20 | backgroundColor: '#e3ebf4', 21 | padding: '20px 40px' 22 | }; 23 | 24 | const getNameFromUser = user => { 25 | return user.display_name || user.id; 26 | }; 27 | 28 | const Header = ({ session, muted, mutePlayback, unmutePlayback, login }) => ( 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {session.user 39 | ?
40 | 62 |
63 | {getNameFromUser(session.user)} 73 |
74 |
75 | {getNameFromUser(session.user)} 76 |
77 |
78 | : } 83 | {session.user 84 | ?
85 | 88 | 91 | 97 | 105 |
106 | : null} 107 |
108 | ); 109 | 110 | const mapDispatchToProps = dispatch => ({ 111 | login: () => dispatch(login()), 112 | mutePlayback: () => dispatch(mutePlayback()), 113 | unmutePlayback: () => dispatch(unmutePlayback()) 114 | }); 115 | 116 | const mapStateToProps = state => ({ 117 | session: state.session, 118 | muted: state.playback.muted 119 | }); 120 | 121 | export default connect(mapStateToProps, mapDispatchToProps)(Header); 122 | -------------------------------------------------------------------------------- /components/MyLayout.js: -------------------------------------------------------------------------------- 1 | import Header from './Header'; 2 | import Head from 'next/head'; 3 | 4 | const Layout = props => ( 5 |
6 | 13 |
14 |
15 | {props.children} 16 |
17 |
18 | ); 19 | 20 | export default Layout; 21 | -------------------------------------------------------------------------------- /components/NowPlaying.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class NowPlaying extends React.PureComponent { 4 | constructor() { 5 | super(); 6 | this.state = { 7 | start: Date.now(), 8 | currentPosition: 0 9 | }; 10 | this.timer = null; 11 | this.tick = () => { 12 | this.setState({ 13 | currentPosition: Date.now() - this.state.start + (this.props.position || 0) 14 | }); 15 | }; 16 | } 17 | componentWillReceiveProps(props) { 18 | if (this.props.position !== props.position || this.props.track !== props.track) { 19 | this.setState({ 20 | start: Date.now(), 21 | currentPosition: 0 22 | }); 23 | } 24 | } 25 | componentDidMount() { 26 | this.timer = setInterval(this.tick, 300); 27 | } 28 | componentWillUnmount() { 29 | clearInterval(this.timer); 30 | } 31 | render() { 32 | const percentage = +(this.state.currentPosition * 100 / this.props.track.duration_ms).toFixed(2) + '%'; 33 | const userName = this.props.user.display_name || this.props.user.id; 34 | return ( 35 |
36 | 86 |
87 |
88 | 89 |
90 |
91 |
92 | {this.props.track.name} 93 |
94 |
95 | {this.props.track.artists.map(a => a.name).join(', ')} 96 |
97 |
98 | {userName} 109 |
110 |
111 | {userName} 112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 | ); 120 | } 121 | } 122 | 123 | export default NowPlaying; 124 | -------------------------------------------------------------------------------- /components/PageWithIntl.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { IntlProvider, addLocaleData, injectIntl } from 'react-intl'; 3 | 4 | // Register React Intl's locale data for the user's locale in the browser. This 5 | // locale data was added to the page by `pages/_document.js`. This only happens 6 | // once, on initial page load in the browser. 7 | if (typeof window !== 'undefined' && window.ReactIntlLocaleData) { 8 | Object.keys(window.ReactIntlLocaleData).forEach(lang => { 9 | addLocaleData(window.ReactIntlLocaleData[lang]); 10 | }); 11 | } 12 | 13 | export default Page => { 14 | const IntlPage = injectIntl(Page); 15 | return class PageWithIntl extends Component { 16 | static async getInitialProps(context) { 17 | let props; 18 | if (typeof Page.getInitialProps === 'function') { 19 | props = await Page.getInitialProps(context); 20 | } 21 | 22 | // Get the `locale` and `messages` from the request object on the server. 23 | // In the browser, use the same values that the server serialized. 24 | const { req } = context; 25 | // todo: for some reason it is not props.initialProps, but props in the example 26 | const { locale, messages } = req || window.__NEXT_DATA__.props.initialProps; 27 | 28 | // Always update the current time on page load/transition because the 29 | // will be a new instance even with pushState routing. 30 | const now = Date.now(); 31 | 32 | return { ...props, locale, messages, now }; 33 | } 34 | 35 | render() { 36 | const { locale, messages, now, ...props } = this.props; 37 | return ( 38 | 39 | 40 | 41 | ); 42 | } 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /components/Queue.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { FormattedMessage } from 'react-intl'; 4 | 5 | import QueueItem from './QueueItem'; 6 | import { queueRemoveTrack } from '../actions/queueActions'; 7 | import { voteUp } from '../actions/voteActions'; 8 | 9 | class Queue extends React.PureComponent { 10 | render() { 11 | const { items, session } = this.props; 12 | return ( 13 |
14 |

15 | {items.length === 0 16 | ?

17 | : 18 | 23 | 24 | {items.map((i, index) => ( 25 | this.props.voteUp(i.id)} 31 | onRemoveItem={() => this.props.queueRemoveTrack(i.id)} 32 | /> 33 | ))} 34 | 35 |
} 36 |
37 | ); 38 | } 39 | } 40 | 41 | const mapDispatchToProps = dispatch => ({ 42 | voteUp: id => dispatch(voteUp(id)), 43 | queueRemoveTrack: id => dispatch(queueRemoveTrack(id)) 44 | }); 45 | 46 | const mapStateToProps = state => ({ 47 | queue: state.queue 48 | }); 49 | 50 | export default connect(mapStateToProps, mapDispatchToProps)(Queue); 51 | -------------------------------------------------------------------------------- /components/QueueItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default ({ index, item, session, onRemoveItem, onVoteUp }) => { 4 | const voteUp = item.voters && session.user && item.voters.filter(v => v.id === session.user.id).length === 0 5 | ? 6 | : null; 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | {index + 1} 14 | 15 | 16 | {item.track.name} 17 | 18 | 19 | {item.track.artists.map(a => a.name).join(', ')} 20 | 21 | 22 | {item.user && (item.user.display_name || item.user.id)} 23 | 24 | 25 | {item.user && session.user && item.user.id === session.user.id 26 | ? 33 | : voteUp} 34 | 35 | 36 | {item.voters && item.voters.length > 0 37 | ? 38 | {item.voters.length === 1 ? '1 vote' : item.voters.length + ' votes'} 39 | 40 | : ''} 41 | 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /components/Users.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FormattedMessage } from 'react-intl'; 3 | 4 | export default ({ items }) => { 5 | return ( 6 |
7 | 39 |

40 |
    41 | {items.map((i, index) => { 42 | const userName = i.display_name || i.id; 43 | return ( 44 |
  • 45 |
    46 | {userName} 54 |
    55 |
    56 | {userName} 57 |
    58 |
  • 59 | ); 60 | })} 61 |
62 |
63 |
64 | ); 65 | }; 66 | -------------------------------------------------------------------------------- /config/app.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | HOST: process.env.HOST 3 | }; 4 | -------------------------------------------------------------------------------- /config/auth.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | CLIENT_ID: process.env.CLIENT_ID, 3 | CLIENT_SECRET: process.env.CLIENT_SECRET 4 | }; 5 | -------------------------------------------------------------------------------- /constants/ActionTypes.js: -------------------------------------------------------------------------------- 1 | export const QUEUE_TRACK = 'QUEUE_TRACK'; 2 | export const UPDATE_QUEUE = 'UPDATE_QUEUE'; 3 | export const QUEUE_ENDED = 'QUEUE_ENDED'; 4 | export const QUEUE_REMOVE_TRACK = 'QUEUE_REMOVE_TRACK'; 5 | 6 | export const SEARCH_TRACKS = 'SEARCH_TRACKS'; 7 | export const SEARCH_TRACKS_SUCCESS = 'SEARCH_TRACKS_SUCCESS'; 8 | export const SEARCH_TRACKS_RESET = 'SEARCH_TRACKS_RESET'; 9 | 10 | export const FETCH_TRACK = 'FETCH_TRACK'; 11 | export const FETCH_TRACK_SUCCESS = 'FETCH_TRACK_SUCCESS'; 12 | export const FETCH_PLAYING_CONTEXT_SUCCESS = 'FETCH_PLAYING_CONTEXT_SUCCESS'; 13 | 14 | export const UPDATE_USERS = 'UPDATE_USERS'; 15 | 16 | export const LOAD = 'LOAD'; 17 | export const LOGIN = 'LOGIN'; 18 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 19 | export const LOGIN_FAILURE = 'LOGIN_FAILURE'; 20 | 21 | export const UPDATE_TOKEN = 'UPDATE_TOKEN'; 22 | export const UPDATE_TOKEN_SUCCESS = 'UPDATE_TOKEN_SUCCESS'; 23 | export const UPDATE_CURRENT_USER = 'UPDATE_CURRENT_USER'; 24 | export const PLAY_TRACK = 'PLAY_TRACK'; 25 | export const UPDATE_NOW_PLAYING = 'UPDATE_NOW_PLAYING'; 26 | export const PLAY_TRACK_SUCCESS = 'PLAY_TRACK_SUCCESS'; 27 | 28 | export const MUTE_PLAYBACK = 'MUTE_PLAYBACK'; 29 | export const UNMUTE_PLAYBACK = 'UNMUTE_PLAYBACK'; 30 | 31 | export const FETCH_AVAILABLE_DEVICES = 'FETCH_AVAILABLE_DEVICES'; 32 | export const FETCH_AVAILABLE_DEVICES_SUCCESS = 'FETCH_AVAILABLE_DEVICES_SUCCESS'; 33 | export const FETCH_AVAILABLE_DEVICES_ERROR = 'FETCH_AVAILABLE_DEVICES_ERROR'; 34 | 35 | export const TRANSFER_PLAYBACK_TO_DEVICE = 'TRANSFER_PLAYBACK_TO_DEVICE'; 36 | export const TRANSFER_PLAYBACK_TO_DEVICE_SUCCESS = 'TRANSFER_PLAYBACK_TO_DEVICE_SUCCESS'; 37 | export const TRANSFER_PLAYBACK_TO_DEVICE_ERROR = 'TRANSFER_PLAYBACK_TO_DEVICE_ERROR'; 38 | 39 | export const VOTE_UP = 'VOTE_UP'; 40 | export const VOTE_UP_SUCCESS = 'VOTE_UP_SUCCESS'; 41 | -------------------------------------------------------------------------------- /lang/ca.json: -------------------------------------------------------------------------------- 1 | { 2 | "about": "Quant a", 3 | "login": "Accedir amb Spotify", 4 | "online": "Conectats", 5 | "queue.title": "Llista de reproducció", 6 | "queue.empty": "La llista està buida, afegeix una cançó.", 7 | "queue.add": "Afegir una cançó", 8 | "devices.title": "Dispositius", 9 | "devices.fetch": "Obtenir dispositius", 10 | "devices.transfer": "Transferir", 11 | "devices.empty": "La llista de dispositius està buida" 12 | } 13 | -------------------------------------------------------------------------------- /lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "about": "About", 3 | "login": "Log in with Spotify", 4 | "online": "Online", 5 | "queue.title": "Queue", 6 | "queue.empty": "The queue is empty, add a song.", 7 | "queue.add": "Add a song", 8 | "devices.title": "Devices", 9 | "devices.fetch": "Fetch devices", 10 | "devices.transfer": "Transfer", 11 | "devices.empty": "The list of devices is empty," 12 | } 13 | -------------------------------------------------------------------------------- /lang/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "about": "Acerca de", 3 | "login": "Acceder con Spotify", 4 | "online": "Conectados", 5 | "queue.title": "Lista de reproducción", 6 | "queue.empty": "La lista está vacía, añade una canción.", 7 | "queue.add": "Añadir una canción", 8 | "devices.title": "Dispositivos", 9 | "devices.fetch": "Obtener dispositivos", 10 | "devices.transfer": "Transferir", 11 | "devices.empty": "La lista de dispositivos está vacía" 12 | } 13 | -------------------------------------------------------------------------------- /middlewares/devicesMiddleware.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | import { FETCH_AVAILABLE_DEVICES, TRANSFER_PLAYBACK_TO_DEVICE } from '../constants/ActionTypes'; 4 | import { 5 | fetchAvailableDevices, 6 | fetchAvailableDevicesSuccess, 7 | fetchAvailableDevicesError, 8 | transferPlaybackToDeviceSuccess, 9 | transferPlaybackToDeviceError 10 | } from '../actions/devicesActions'; 11 | 12 | const SPOTIFY_API_BASE = 'https://api.spotify.com/v1'; 13 | 14 | export default store => next => action => { 15 | const result = next(action); 16 | switch (action.type) { 17 | case FETCH_AVAILABLE_DEVICES: { 18 | fetch(`${SPOTIFY_API_BASE}/me/player/devices`, { 19 | method: 'GET', 20 | headers: { 21 | Authorization: `Bearer ${store.getState().session.access_token}` 22 | } 23 | }) 24 | .then(r => r.json()) 25 | .then(r => { 26 | if (r.error) { 27 | store.dispatch(fetchAvailableDevicesError(r.error)); 28 | } else { 29 | store.dispatch(fetchAvailableDevicesSuccess(r.devices)); 30 | } 31 | }); 32 | break; 33 | } 34 | case TRANSFER_PLAYBACK_TO_DEVICE: { 35 | fetch(`${SPOTIFY_API_BASE}/me/player`, { 36 | method: 'PUT', 37 | headers: { 38 | Authorization: `Bearer ${store.getState().session.access_token}` 39 | }, 40 | body: JSON.stringify({ 41 | device_ids: [action.deviceId] 42 | }) 43 | }) 44 | .then(r => r.json()) 45 | .then(r => { 46 | if (r.error) { 47 | store.dispatch(transferPlaybackToDeviceError(r.error)); 48 | } else { 49 | store.dispatch(transferPlaybackToDeviceSuccess()); 50 | store.dispatch(fetchAvailableDevices()); 51 | } 52 | }); 53 | break; 54 | } 55 | 56 | default: 57 | break; 58 | } 59 | 60 | return result; 61 | }; 62 | -------------------------------------------------------------------------------- /middlewares/loggerMiddleware.js: -------------------------------------------------------------------------------- 1 | export default store => next => action => { 2 | const result = next(action); 3 | console.log(action); 4 | }; 5 | -------------------------------------------------------------------------------- /middlewares/playbackMiddleware.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | import { PLAY_TRACK, UNMUTE_PLAYBACK } from '../constants/ActionTypes'; 4 | import { playTrack, playTrackSuccess } from '../actions/playbackActions'; 5 | import { fetchAvailableDevicesError, fetchAvailableDevicesSuccess } from '../actions/devicesActions'; 6 | 7 | const SPOTIFY_API_BASE = 'https://api.spotify.com/v1'; 8 | 9 | export default store => next => action => { 10 | const result = next(action); 11 | switch (action.type) { 12 | case PLAY_TRACK: { 13 | if (process.browser && !store.getState().playback.muted) { 14 | fetch(`${SPOTIFY_API_BASE}/me/player/play`, { 15 | method: 'PUT', 16 | headers: { 17 | Authorization: `Bearer ${store.getState().session.access_token}` 18 | }, 19 | body: JSON.stringify({ 20 | uris: [`spotify:track:${action.track.id}`] 21 | }) 22 | }).then(() => { 23 | if (action.position) { 24 | fetch(`${SPOTIFY_API_BASE}/me/player/seek?position_ms=${action.position}`, { 25 | method: 'PUT', 26 | headers: { 27 | Authorization: `Bearer ${store.getState().session.access_token}` 28 | } 29 | }).then(() => { 30 | store.dispatch(playTrackSuccess(action.track, action.user, action.position)); 31 | }); 32 | } else { 33 | store.dispatch(playTrackSuccess(action.track, action.user)); 34 | } 35 | }); 36 | } 37 | break; 38 | } 39 | case UNMUTE_PLAYBACK: { 40 | const { track, user, position, startTime } = store.getState().playback; 41 | const currentPosition = Date.now() - startTime + position; 42 | store.dispatch(playTrack(track, user, currentPosition)); 43 | break; 44 | } 45 | default: 46 | break; 47 | } 48 | 49 | return result; 50 | }; 51 | -------------------------------------------------------------------------------- /middlewares/searchMiddleware.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | import { SEARCH_TRACKS } from '../constants/ActionTypes'; 4 | import { searchTracksSuccess } from '../actions/searchActions'; 5 | 6 | const SPOTIFY_API_BASE = 'https://api.spotify.com/v1'; 7 | 8 | const searchTracks = query => (dispatch, getState) => { 9 | let shouldAddWildcard = false; 10 | if (query.length > 1) { 11 | const words = query.split(' '); 12 | const lastWord = words[words.length - 1]; 13 | if (/^[a-z0-9\s]+$/i.test(lastWord) && query.lastIndexOf('*') !== query.length - 1) { 14 | shouldAddWildcard = true; 15 | } 16 | } 17 | 18 | const wildcardQuery = `${query}${shouldAddWildcard ? '*' : ''}`; // Trick to improve search results 19 | 20 | return fetch(`${SPOTIFY_API_BASE}/search?q=${encodeURIComponent(wildcardQuery)}&type=track&limit=10`, { 21 | headers: { 22 | Authorization: 'Bearer ' + getState().session.access_token 23 | } 24 | }) 25 | .then(res => res.json()) 26 | .then(res => { 27 | dispatch(searchTracksSuccess(query, res.tracks.items)); 28 | }); 29 | }; 30 | 31 | export default store => next => action => { 32 | const result = next(action); 33 | switch (action.type) { 34 | case SEARCH_TRACKS: { 35 | return store.dispatch(searchTracks(action.query)); 36 | break; 37 | } 38 | default: 39 | break; 40 | } 41 | return result; 42 | }; 43 | -------------------------------------------------------------------------------- /middlewares/sessionMiddleware.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | import { LOAD, LOGIN } from '../constants/ActionTypes'; 4 | import { loginSuccess, updateCurrentUser, updateTokenSuccess } from '../actions/sessionActions'; 5 | 6 | import * as Config from '../config/app'; 7 | 8 | const SPOTIFY_API_BASE = 'https://api.spotify.com/v1'; 9 | 10 | const getCurrentUser = () => (dispatch, getState) => 11 | fetch(`${SPOTIFY_API_BASE}/me`, { 12 | headers: { 13 | Authorization: 'Bearer ' + getState().session.access_token 14 | } 15 | }) 16 | .then(res => res.json()) 17 | .then(res => { 18 | dispatch(updateCurrentUser(res)); 19 | }); 20 | 21 | const updateToken = () => (dispatch, getState) => { 22 | return fetch(`${Config.HOST}/auth/token`, { 23 | method: 'POST', 24 | body: JSON.stringify({ 25 | refresh_token: getState().session.refresh_token 26 | }), 27 | headers: new Headers({ 28 | 'Content-Type': 'application/json' 29 | }) 30 | }) 31 | .then(res => res.json()) 32 | .then(res => { 33 | console.log(res); 34 | dispatch(updateTokenSuccess(res.access_token)); 35 | }); 36 | }; 37 | 38 | // todo: set a timer, both client-side and server-side 39 | 40 | export default store => next => action => { 41 | const result = next(action); 42 | switch (action.type) { 43 | case LOAD: { 44 | const session = store.getState().session; 45 | const expiresIn = session.expires_in; 46 | const needsToUpdate = !expiresIn || expiresIn - Date.now() < 10 * 60 * 1000; 47 | if (needsToUpdate) { 48 | console.log('sessionMiddleware > needs to update access token'); 49 | const refreshToken = session.refresh_token; 50 | if (refreshToken) { 51 | console.log('sessionMiddleware > using refresh token'); 52 | store 53 | .dispatch(updateToken()) 54 | .then(() => { 55 | return store.dispatch(getCurrentUser()); 56 | }) 57 | .then(() => { 58 | store.dispatch(loginSuccess()); 59 | }); 60 | } 61 | } else { 62 | console.log('sessionMiddleware > no need to update access token'); 63 | store.dispatch(getCurrentUser()).then(() => { 64 | store.dispatch(loginSuccess()); 65 | }); 66 | } 67 | break; 68 | } 69 | case LOGIN: { 70 | const getLoginURL = scopes => { 71 | return `${Config.HOST}/auth/login?scope=${encodeURIComponent(scopes.join(' '))}`; 72 | }; 73 | 74 | const width = 450, 75 | height = 730, 76 | left = window.screen.width / 2 - width / 2, 77 | top = window.screen.height / 2 - height / 2; 78 | 79 | const messageFn = event => { 80 | try { 81 | const hash = JSON.parse(event.data); 82 | if (hash.type === 'access_token') { 83 | window.removeEventListener('message', messageFn, false); 84 | const accessToken = hash.access_token; 85 | const expiresIn = hash.expires_in; 86 | if (accessToken === '') { 87 | // todo: implement login error 88 | } else { 89 | const refreshToken = hash.refresh_token; 90 | localStorage.setItem('refreshToken', refreshToken); 91 | localStorage.setItem('accessToken', accessToken); 92 | localStorage.setItem('expiresIn', Date.now() + expiresIn * 1000); 93 | store.dispatch(updateTokenSuccess(accessToken)); 94 | store.dispatch(getCurrentUser()).then(() => store.dispatch(loginSuccess())); 95 | } 96 | } 97 | } catch (e) { 98 | // do nothing 99 | console.error(e); 100 | } 101 | }; 102 | window.addEventListener('message', messageFn, false); 103 | 104 | const url = getLoginURL(['user-read-playback-state', 'user-modify-playback-state']); 105 | window.open( 106 | url, 107 | 'Spotify', 108 | 'menubar=no,location=no,resizable=no,scrollbars=no,status=no, width=' + 109 | width + 110 | ', height=' + 111 | height + 112 | ', top=' + 113 | top + 114 | ', left=' + 115 | left 116 | ); 117 | 118 | break; 119 | } 120 | default: 121 | break; 122 | } 123 | return result; 124 | }; 125 | -------------------------------------------------------------------------------- /middlewares/socketMiddleware.js: -------------------------------------------------------------------------------- 1 | import { VOTE_UP, LOGIN_SUCCESS, QUEUE_REMOVE_TRACK, QUEUE_TRACK } from '../constants/ActionTypes'; 2 | import { updateUsers } from '../actions/usersActions'; 3 | import { updateQueue, queueEnded } from '../actions/queueActions'; 4 | import { playTrack, updateNowPlaying } from '../actions/playbackActions'; 5 | import Config from '../config/app'; 6 | 7 | import io from 'socket.io-client'; 8 | 9 | var socket = null; 10 | 11 | const getIdFromTrackString = (trackString = '') => { 12 | let matches = trackString.match(/^https:\/\/open\.spotify\.com\/track\/(.*)/); 13 | if (matches) { 14 | return matches[1]; 15 | } 16 | 17 | matches = trackString.match(/^https:\/\/play\.spotify\.com\/track\/(.*)/); 18 | if (matches) { 19 | return matches[1]; 20 | } 21 | 22 | matches = trackString.match(/^spotify:track:(.*)/); 23 | if (matches) { 24 | return matches[1]; 25 | } 26 | 27 | return null; 28 | }; 29 | 30 | export function socketMiddleware(store) { 31 | return next => action => { 32 | const result = next(action); 33 | 34 | if (socket) { 35 | switch (action.type) { 36 | case QUEUE_TRACK: { 37 | let trackId = getIdFromTrackString(action.id); 38 | if (trackId === null) { 39 | trackId = action.id; 40 | } 41 | socket.emit('queue track', trackId); 42 | break; 43 | } 44 | case QUEUE_REMOVE_TRACK: { 45 | socket.emit('remove track', action.id); 46 | break; 47 | } 48 | case LOGIN_SUCCESS: 49 | const user = store.getState().session.user; 50 | socket.emit('user login', user); 51 | break; 52 | case VOTE_UP: 53 | socket.emit('vote up', action.id); 54 | break; 55 | default: 56 | break; 57 | } 58 | } 59 | 60 | return result; 61 | }; 62 | } 63 | export default function(store) { 64 | socket = io.connect(Config.HOST); 65 | 66 | socket.on('update queue', data => { 67 | store.dispatch(updateQueue(data)); 68 | }); 69 | 70 | socket.on('queue ended', () => { 71 | store.dispatch(queueEnded()); 72 | }); 73 | 74 | socket.on('play track', (track, user, position) => { 75 | // we should also set repeat to false! 76 | store.dispatch(playTrack(track, user, position)); 77 | }); 78 | 79 | socket.on('update users', data => { 80 | store.dispatch(updateUsers(data)); 81 | }); 82 | 83 | socket.on('update now playing', (track, user, position) => { 84 | store.dispatch(updateNowPlaying(track, user, position)); 85 | }); 86 | 87 | // todo: manage end song, end queue 88 | } 89 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 2 | const { EnvironmentPlugin } = require('webpack'); 3 | const { ANALYZE } = process.env; 4 | 5 | module.exports = { 6 | webpack: function(config, { dev }) { 7 | if (ANALYZE) { 8 | config.plugins.push( 9 | new BundleAnalyzerPlugin({ 10 | analyzerMode: 'server', 11 | analyzerPort: 8888, 12 | openAnalyzer: true 13 | }) 14 | ); 15 | } 16 | 17 | config.plugins.push(new EnvironmentPlugin(['HOST'])); 18 | 19 | // For the development version, we'll use React. 20 | // Because, it supports react hot loading and so on. 21 | if (dev) { 22 | return config; 23 | } 24 | 25 | return config; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "c-next", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "next build", 8 | "dev": "node server/server.js", 9 | "start": "NODE_ENV=production node server/server.js", 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "analyze": "cross-env ANALYZE=1 next build", 12 | "precommit": "lint-staged" 13 | }, 14 | "lint-staged": { 15 | "*.js": [ 16 | "prettier --write --single-quote --print-width 120", 17 | "git add" 18 | ] 19 | }, 20 | "keywords": [], 21 | "author": "", 22 | "license": "ISC", 23 | "dependencies": { 24 | "accepts": "^1.3.4", 25 | "body-parser": "^1.18.2", 26 | "compression": "^1.7.1", 27 | "cookie-parser": "^1.4.3", 28 | "cross-env": "^5.0.5", 29 | "dotenv": "^8.2.0", 30 | "ejs": "^2.5.7", 31 | "express": "^4.16.2", 32 | "glob": "^7.1.2", 33 | "isomorphic-unfetch": "^2.0.0", 34 | "next": "^9.3.3", 35 | "next-redux-wrapper": "^1.3.4", 36 | "promise-retry": "^1.1.1", 37 | "react": "^16.0.0", 38 | "react-dom": "^16.0.0", 39 | "react-intl": "^2.4.0", 40 | "react-redux": "^5.0.6", 41 | "redux": "^3.7.2", 42 | "redux-thunk": "^2.2.0", 43 | "request": "^2.88.0", 44 | "socket.io": "^2.0.3", 45 | "socket.io-client": "^2.0.3", 46 | "spotify-web-api-node": "^4.0.0", 47 | "uuid": "^3.1.0", 48 | "webpack-bundle-analyzer": "^2.9.0" 49 | }, 50 | "devDependencies": { 51 | "husky": "^0.14.3", 52 | "lint-staged": "^4.2.3", 53 | "prettier": "^1.7.4" 54 | }, 55 | "engines": { 56 | "node": "8.4.x" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Head, Main, NextScript } from 'next/document'; 2 | import flush from 'styled-jsx/server'; 3 | 4 | // The document (which is SSR-only) needs to be customized to expose the locale 5 | // data for the user's locale for React Intl to work in the browser. 6 | export default class IntlDocument extends Document { 7 | static async getInitialProps(context) { 8 | const props = await super.getInitialProps(context); 9 | const { req: { locale, localeDataScript } } = context; 10 | const { html, head, errorHtml, chunks } = context.renderPage(); 11 | const styles = flush(); 12 | return { html, head, errorHtml, chunks, styles, locale, localeDataScript }; 13 | } 14 | 15 | render() { 16 | return ( 17 | 18 | 19 | C - Collaborative listening on Spotify 20 | 24 | 25 | 26 | 27 | 13 | 14 | 15 | 16 | This page should close in a few seconds. 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /static/c-icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMPerez/c/55dfa65392f096c550a01d0c7a44d664ab6fbee6/static/c-icon-128.png -------------------------------------------------------------------------------- /static/c-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMPerez/c/55dfa65392f096c550a01d0c7a44d664ab6fbee6/static/c-icon.png -------------------------------------------------------------------------------- /static/robot-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMPerez/c/55dfa65392f096c550a01d0c7a44d664ab6fbee6/static/robot-icon.png -------------------------------------------------------------------------------- /static/user-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JMPerez/c/55dfa65392f096c550a01d0c7a44d664ab6fbee6/static/user-icon.png -------------------------------------------------------------------------------- /store/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, compose, applyMiddleware } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | 4 | import { reducers } from '../reducers'; 5 | 6 | import sessionMiddleware from '../middlewares/sessionMiddleware'; 7 | import playbackMiddleware from '../middlewares/playbackMiddleware'; 8 | import devicesMiddleware from '../middlewares/devicesMiddleware'; 9 | import { socketMiddleware } from '../middlewares/socketMiddleware'; 10 | import loggerMiddleware from '../middlewares/loggerMiddleware'; 11 | import socketMiddlewareDefault from '../middlewares/socketMiddleware'; 12 | import searchMiddleware from '../middlewares/searchMiddleware'; 13 | 14 | import { load } from '../actions/sessionActions'; 15 | 16 | export const initStore = (initialState = {}) => { 17 | const store = createStore( 18 | reducers(), 19 | initialState, 20 | applyMiddleware( 21 | thunk, 22 | sessionMiddleware, 23 | socketMiddleware, 24 | playbackMiddleware, 25 | devicesMiddleware, 26 | loggerMiddleware, 27 | searchMiddleware 28 | ) 29 | ); 30 | socketMiddlewareDefault(store); 31 | store.dispatch(load()); 32 | return store; 33 | }; 34 | --------------------------------------------------------------------------------