├── .gitignore
├── client
├── styles
│ ├── main.scss
│ └── custom
│ │ ├── components
│ │ ├── a.scss
│ │ ├── container.scss
│ │ ├── songs-body.scss
│ │ ├── reset.scss
│ │ ├── song.scss
│ │ ├── user.scss
│ │ ├── row.scss
│ │ ├── sidebar.scss
│ │ ├── nav-search.scss
│ │ ├── nav-user.scss
│ │ ├── stats.scss
│ │ ├── heart.scss
│ │ ├── switch.scss
│ │ ├── slider.scss
│ │ ├── artwork-play.scss
│ │ ├── user-following.scss
│ │ ├── popover.scss
│ │ ├── loader.scss
│ │ ├── nav.scss
│ │ ├── toggle-play-button.scss
│ │ ├── song-comment.scss
│ │ ├── button.scss
│ │ ├── nav-playlists.scss
│ │ ├── nav-session.scss
│ │ ├── song-main.scss
│ │ ├── user-main.scss
│ │ ├── song-list.scss
│ │ ├── waveform.scss
│ │ ├── history.scss
│ │ ├── songs-body-card.scss
│ │ ├── player.scss
│ │ └── songs-header.scss
│ │ ├── mixins.scss
│ │ ├── variables.scss
│ │ └── custom.scss
├── public
│ ├── favicon.ico
│ ├── fonts
│ │ ├── ionicons.eot
│ │ ├── ionicons.ttf
│ │ └── ionicons.woff
│ ├── api
│ │ └── callback
│ │ │ └── index.html
│ └── index.html
├── src
│ ├── constants
│ │ ├── EnvironmentConstants.js
│ │ ├── ImageConstants.js
│ │ ├── RouterConstants.js
│ │ ├── Schemas.js
│ │ ├── SongConstants.js
│ │ ├── ApiConstants.js
│ │ ├── PlaylistConstants.js
│ │ └── ActionTypes.js
│ ├── .babelrc
│ ├── .eslintrc
│ ├── actions
│ │ ├── HistoryActions.js
│ │ ├── EnvironmentActions.js
│ │ ├── RouterActions.js
│ │ ├── SongActions.js
│ │ ├── UserActions.js
│ │ ├── PlayerActions.js
│ │ └── PlaylistActions.js
│ ├── utils
│ │ ├── SongUtils.js
│ │ ├── PlayerUtils.js
│ │ ├── DomUtils.js
│ │ ├── ImageUtils.js
│ │ ├── NumberUtils.js
│ │ ├── ApiUtils.js
│ │ ├── UserUtils.js
│ │ ├── RouterUtils.js
│ │ ├── ScrollUtils.js
│ │ └── PlaylistUtils.js
│ ├── components
│ │ ├── SessionPopoverPanel.jsx
│ │ ├── LoginPopoverPanel.jsx
│ │ ├── Router.jsx
│ │ ├── HeartCount.jsx
│ │ ├── SidebarBody.jsx
│ │ ├── WaveformEvents.jsx
│ │ ├── Switch.jsx
│ │ ├── Loader.jsx
│ │ ├── UserFollowButton.jsx
│ │ ├── UserFollowings.jsx
│ │ ├── SongsBodyCardMobileEvents.jsx
│ │ ├── SongComment.jsx
│ │ ├── stickyOnScroll.jsx
│ │ ├── SongsHeaderTimes.jsx
│ │ ├── InfiniteScroll.jsx
│ │ ├── Root.jsx
│ │ ├── NavPlaylists.jsx
│ │ ├── PopoverPanel.jsx
│ │ ├── Popover.jsx
│ │ ├── NavPlaylistsItem.jsx
│ │ ├── Link.jsx
│ │ ├── ArtworkPlay.jsx
│ │ ├── NavUser.jsx
│ │ ├── SongComments.jsx
│ │ ├── Stats.jsx
│ │ ├── UserFollowing.jsx
│ │ ├── NavSearch.jsx
│ │ ├── SongList.jsx
│ │ ├── SongsHeader.jsx
│ │ ├── Heart.jsx
│ │ ├── SongsHeaderGenres.jsx
│ │ ├── NavStream.jsx
│ │ ├── HistorySong.jsx
│ │ ├── History.jsx
│ │ ├── NavSession.jsx
│ │ ├── SongsBodyRendered.jsx
│ │ ├── UserMain.jsx
│ │ ├── Waveform.jsx
│ │ ├── Slider.jsx
│ │ ├── Nav.jsx
│ │ ├── SongsBody.jsx
│ │ ├── SongMain.jsx
│ │ ├── SongsBodyCard.jsx
│ │ ├── SongListItem.jsx
│ │ ├── User.jsx
│ │ ├── Song.jsx
│ │ ├── Songs.jsx
│ │ ├── audio.jsx
│ │ └── Player.jsx
│ ├── reducers
│ │ ├── environment.js
│ │ ├── router.js
│ │ ├── entities.js
│ │ ├── index.js
│ │ ├── history.js
│ │ ├── session.js
│ │ ├── player.js
│ │ └── playlists.js
│ ├── store
│ │ └── configureStore.js
│ ├── selectors
│ │ ├── HistorySelectors.js
│ │ ├── SongsSelectors.js
│ │ ├── NavSelectors.js
│ │ ├── PlayerSelectors.js
│ │ ├── SongSelectors.js
│ │ ├── UserSelectors.js
│ │ └── CommonSelectors.js
│ ├── index.jsx
│ └── containers
│ │ ├── HistoryContainer.jsx
│ │ ├── NavContainer.jsx
│ │ ├── RootContainer.jsx
│ │ ├── SongContainer.jsx
│ │ ├── UserContainer.jsx
│ │ ├── SongsContainer.jsx
│ │ └── PlayerContainer.jsx
├── webpack.dev.config.js
└── webpack.prod.config.js
├── README.md
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | dist
3 | npm-debug.log
4 | node_modules
5 |
--------------------------------------------------------------------------------
/client/styles/main.scss:
--------------------------------------------------------------------------------
1 | @import 'custom/custom';
2 | @import 'ionicons/ionicons';
3 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/src/constants/EnvironmentConstants.js:
--------------------------------------------------------------------------------
1 | export const MOBILE_WIDTH = 320;
2 | export const TABLET_WIDTH = 736;
3 |
--------------------------------------------------------------------------------
/client/public/fonts/ionicons.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/fonts/ionicons.eot
--------------------------------------------------------------------------------
/client/public/fonts/ionicons.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/fonts/ionicons.ttf
--------------------------------------------------------------------------------
/client/public/fonts/ionicons.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/andrewngu/sound-redux/HEAD/client/public/fonts/ionicons.woff
--------------------------------------------------------------------------------
/client/src/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [["es2015", { "modules": false }], "react", "stage-2"],
3 | "plugins": ["react-hot-loader/babel"]
4 | }
5 |
--------------------------------------------------------------------------------
/client/src/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "rules": {
4 | "jsx-a11y/media-has-caption": 0,
5 | "react/no-danger": 0
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/constants/ImageConstants.js:
--------------------------------------------------------------------------------
1 | const IMAGE_SIZES = {
2 | LARGE: 't300x300',
3 | XLARGE: 't500x500',
4 | };
5 |
6 | export default IMAGE_SIZES;
7 |
--------------------------------------------------------------------------------
/client/styles/custom/components/a.scss:
--------------------------------------------------------------------------------
1 | a {
2 | color: $blue;
3 | text-decoration: none;
4 |
5 | &:hover {
6 | text-decoration: underline;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/client/styles/custom/components/container.scss:
--------------------------------------------------------------------------------
1 | .container {
2 | width: 1170px;
3 | margin: 0 auto;
4 |
5 | @include mobile {
6 | width: 100%;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/actions/HistoryActions.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 |
3 | const toggleShowHistory = () => ({
4 | type: types.TOGGLE_SHOW_HISTORY,
5 | });
6 |
7 | export default toggleShowHistory;
8 |
--------------------------------------------------------------------------------
/client/styles/custom/components/songs-body.scss:
--------------------------------------------------------------------------------
1 | .songs-body {
2 | margin: 40px 0 80px 0;
3 |
4 | @include mobile {
5 | margin: 0;
6 | }
7 | }
8 |
9 | .songs-body__padder {
10 | visibility: hidden;
11 | }
12 |
--------------------------------------------------------------------------------
/client/styles/custom/mixins.scss:
--------------------------------------------------------------------------------
1 | $mobileWidth: 320px;
2 | $tabletWidth: 736px;
3 |
4 | @mixin mobile {
5 | @media only screen and (min-width: #{$mobileWidth}) and (max-width: #{$tabletWidth - 1px}) {
6 | @content;
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/utils/SongUtils.js:
--------------------------------------------------------------------------------
1 | const formatSongTitle = (s) => {
2 | if (!s) {
3 | return '';
4 | }
5 |
6 | const arr = s.replace('–', '-').split(' - ');
7 |
8 | return arr[arr.length - 1].split(' (')[0];
9 | };
10 |
11 | export default formatSongTitle;
12 |
--------------------------------------------------------------------------------
/client/src/utils/PlayerUtils.js:
--------------------------------------------------------------------------------
1 | const volumeClassName = (volume) => {
2 | if (volume > 0.8) {
3 | return 'ion-android-volume-up';
4 | } else if (volume > 0) {
5 | return 'ion-android-volume-down';
6 | }
7 |
8 | return '';
9 | };
10 |
11 | export default volumeClassName;
12 |
--------------------------------------------------------------------------------
/client/styles/custom/components/reset.scss:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | }
5 |
6 | body {
7 | width: 100%;
8 | height: 100%;
9 | background-color: #f4f4f4;
10 | font-family: $fontFamily;
11 | }
12 |
13 | *, *:after, *:before {
14 | box-sizing: border-box;
15 | }
16 |
--------------------------------------------------------------------------------
/client/src/utils/DomUtils.js:
--------------------------------------------------------------------------------
1 | const offsetLeft = (element) => {
2 | let el = element;
3 | let x = el.offsetLeft;
4 |
5 | while (el.offsetParent) {
6 | x += el.offsetParent.offsetLeft;
7 | el = el.offsetParent;
8 | }
9 |
10 | return x;
11 | };
12 |
13 | export default offsetLeft;
14 |
--------------------------------------------------------------------------------
/client/public/api/callback/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SoundRedux
6 |
7 |
8 |
9 | This page should close soon
10 |
11 |
12 |
13 |
--------------------------------------------------------------------------------
/client/styles/custom/components/song.scss:
--------------------------------------------------------------------------------
1 | .song {
2 | display: flex;
3 | flex-direction: row;
4 | margin: 40px 0 80px 0;
5 | }
6 |
7 | .song__main {
8 | width: 800px;
9 | }
10 |
11 | .song__song-list {
12 | margin-top: 20px;
13 | }
14 |
15 | .song__sidebar {
16 | width: 350px;
17 | margin-left: 20px;
18 | }
19 |
--------------------------------------------------------------------------------
/client/styles/custom/components/user.scss:
--------------------------------------------------------------------------------
1 | .user {
2 | display: flex;
3 | flex-direction: row;
4 | margin: 40px 0 80px 0;
5 | }
6 |
7 | .user__main {
8 | width: 800px;
9 | }
10 |
11 | .user__song-list {
12 | margin-top: 20px;
13 | }
14 |
15 | .user__sidebar {
16 | width: 350px;
17 | margin-left: 20px;
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/constants/RouterConstants.js:
--------------------------------------------------------------------------------
1 | export const INDEX_PATH = '';
2 | export const LIKES_PATH = 'me/likes';
3 | export const PLAYLIST_PATH = 'playlists/:id';
4 | export const SONG_PATH = 'songs/:id';
5 | export const SONGS_PATH = 'songs';
6 | export const STREAM_PATH = 'me/stream';
7 | export const USER_PATH = 'users/:id';
8 |
9 | export const INITIAL_ROUTE = {
10 | keys: {},
11 | options: {},
12 | path: '',
13 | };
14 |
--------------------------------------------------------------------------------
/client/src/constants/Schemas.js:
--------------------------------------------------------------------------------
1 | import { schema } from 'normalizr';
2 |
3 | const song = new schema.Entity('songs');
4 | const user = new schema.Entity('users');
5 | const playlist = new schema.Entity('playlists');
6 |
7 | song.define({
8 | user,
9 | });
10 |
11 | playlist.define({
12 | tracks: [song],
13 | });
14 |
15 | export const songSchema = song;
16 | export const playlistSchema = playlist;
17 | export const userSchema = user;
18 |
--------------------------------------------------------------------------------
/client/styles/custom/components/row.scss:
--------------------------------------------------------------------------------
1 | .row {
2 | display: flex;
3 | flex-direction: row;
4 |
5 | @include mobile {
6 | display: block;
7 | };
8 |
9 | & + .row {
10 | margin-top: 20px;
11 |
12 | @include mobile {
13 | margin-top: 0;
14 | }
15 | }
16 | }
17 |
18 | .row__cell {
19 | & + .row__cell {
20 | margin-left: 20px;
21 |
22 | @include mobile {
23 | margin: 0;
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/actions/EnvironmentActions.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | import * as types from '../constants/ActionTypes';
3 |
4 | export const windowResize = (height, width) => ({
5 | type: types.WINDOW_RESIZE,
6 | height,
7 | width,
8 | });
9 |
10 | export const initEnvironment = () => (dispatch) => {
11 | dispatch(windowResize(window.innerHeight, window.innerWidth));
12 |
13 | window.onresize = () => {
14 | dispatch(windowResize(window.innerHeight, window.innerWidth));
15 | };
16 | };
17 |
--------------------------------------------------------------------------------
/client/src/components/SessionPopoverPanel.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | const propTypes = {
5 | logout: PropTypes.func.isRequired,
6 | };
7 |
8 | const SessionPopoverPanel = ({ logout }) => (
9 |
15 | Logout
16 |
17 | );
18 |
19 | SessionPopoverPanel.propTypes = propTypes;
20 |
21 | export default SessionPopoverPanel;
22 |
--------------------------------------------------------------------------------
/client/src/reducers/environment.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 |
3 | const initialState = {
4 | height: 0,
5 | width: 0,
6 | };
7 |
8 | const environment = (state = initialState, action) => {
9 | switch (action.type) {
10 | case types.WINDOW_RESIZE:
11 | return {
12 | ...state,
13 | height: action.height,
14 | width: action.width,
15 | };
16 |
17 | default:
18 | return state;
19 | }
20 | };
21 |
22 | export default environment;
23 |
--------------------------------------------------------------------------------
/client/src/reducers/router.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 | import { INITIAL_ROUTE } from '../constants/RouterConstants';
3 |
4 | const initialState = {
5 | route: { ...INITIAL_ROUTE },
6 | };
7 |
8 | const router = (state = initialState, action) => {
9 | switch (action.type) {
10 | case types.CHANGE_ROUTE:
11 | return {
12 | ...state,
13 | route: action.route,
14 | };
15 |
16 | default:
17 | return state;
18 | }
19 | };
20 |
21 | export default router;
22 |
--------------------------------------------------------------------------------
/client/src/utils/ImageUtils.js:
--------------------------------------------------------------------------------
1 | import IMAGE_SIZES from '../constants/ImageConstants';
2 |
3 | const getImageUrl = (s, size = null) => {
4 | if (!s) {
5 | return '';
6 | }
7 |
8 | const url = s.replace('http:', '');
9 |
10 | switch (size) {
11 | case IMAGE_SIZES.LARGE:
12 | return url.replace('large', IMAGE_SIZES.LARGE);
13 | case IMAGE_SIZES.XLARGE:
14 | return url.replace('large', IMAGE_SIZES.XLARGE);
15 | default:
16 | return url;
17 | }
18 | };
19 |
20 | export default getImageUrl;
21 |
--------------------------------------------------------------------------------
/client/src/components/LoginPopoverPanel.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | const propTypes = {
5 | login: PropTypes.func.isRequired,
6 | };
7 |
8 | const LoginPopoverPanel = ({ login }) => (
9 |
15 | Sign into SoundCloud
16 |
17 | );
18 |
19 | LoginPopoverPanel.propTypes = propTypes;
20 |
21 | export default LoginPopoverPanel;
22 |
--------------------------------------------------------------------------------
/client/src/utils/NumberUtils.js:
--------------------------------------------------------------------------------
1 | export const addCommas = (i) => {
2 | if (i === null || i === undefined) {
3 | return '';
4 | }
5 |
6 | return i.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
7 | };
8 |
9 | const padZero = (num, size) => {
10 | let s = String(num);
11 | while (s.length < size) {
12 | s = `0${s}`;
13 | }
14 | return s;
15 | };
16 |
17 | export const formatSeconds = (num) => {
18 | const minutes = padZero(Math.floor(num / 60), 2);
19 | const seconds = padZero(num % 60, 2);
20 | return `${minutes}:${seconds}`;
21 | };
22 |
--------------------------------------------------------------------------------
/client/src/constants/SongConstants.js:
--------------------------------------------------------------------------------
1 | export const CHANGE_TYPES = {
2 | NEXT: 'next',
3 | PLAY: 'play',
4 | PREV: 'prev',
5 | SHUFFLE: 'shuffle',
6 | };
7 |
8 | export const GENRES = [
9 | 'chill',
10 | 'deep',
11 | 'dubstep',
12 | 'house',
13 | 'progressive',
14 | 'tech',
15 | 'trance',
16 | 'tropical',
17 | ];
18 |
19 | export const GENRES_MAP = GENRES.reduce((obj, genre) =>
20 | Object.assign({}, obj, {
21 | [genre]: 1,
22 | }), {});
23 |
24 | export const IMAGE_SIZES = {
25 | LARGE: 't300x300',
26 | XLARGE: 't500x500',
27 | };
28 |
--------------------------------------------------------------------------------
/client/src/reducers/entities.js:
--------------------------------------------------------------------------------
1 | import merge from 'lodash.merge';
2 | import * as types from '../constants/ActionTypes';
3 |
4 | const initialState = {
5 | playlists: {},
6 | songs: {},
7 | users: {},
8 | };
9 |
10 | export default function entities(state = initialState, action) {
11 | if (action.entities) {
12 | return merge({}, state, action.entities);
13 | }
14 |
15 | switch (action.type) {
16 | case types.LOGOUT:
17 | return {
18 | ...state,
19 | playlists: {},
20 | };
21 |
22 | default:
23 | return state;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/store/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import thunkMiddleware from 'redux-thunk';
3 | import rootReducer from '../reducers/index';
4 |
5 | const createStoreWithMiddleware = applyMiddleware(thunkMiddleware)(createStore);
6 |
7 | export default function configureStore(initialState) {
8 | const store = createStoreWithMiddleware(rootReducer, initialState);
9 |
10 | if (module.hot) {
11 | module.hot.accept('../reducers', () => {
12 | store.replaceReducer(rootReducer);
13 | });
14 | }
15 |
16 | return store;
17 | }
18 |
--------------------------------------------------------------------------------
/client/styles/custom/components/sidebar.scss:
--------------------------------------------------------------------------------
1 | .sidebar {
2 | display: flex;
3 | flex-direction: column;
4 | width: 100%;
5 | }
6 |
7 | .sidebar--sticky {
8 | position: fixed;
9 | top: 40px;
10 | width: 350px;
11 | }
12 |
13 | .sidebar__header {
14 | display: flex;
15 | flex-direction: row;
16 | align-items: center;
17 | height: 60px;
18 | padding: 0 20px;
19 | background-color: $white;
20 | border: 1px solid $lighterGray;
21 | }
22 |
23 | .sidebar__header__left {
24 | flex: 1;
25 | }
26 |
27 | .sidebar__body {
28 | flex: 1;
29 | width: 100%;
30 | overflow: scroll;
31 | }
32 |
--------------------------------------------------------------------------------
/client/src/components/Router.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | const propTypes = {
5 | router: PropTypes.shape({
6 | route: PropTypes.shape({
7 | path: PropTypes.string,
8 | }),
9 | }).isRequired,
10 | routes: PropTypes.shape({}).isRequired,
11 | };
12 |
13 | const Router = ({ router, routes }) => {
14 | const { path } = router.route;
15 | if (path in routes) {
16 | const Component = routes[path];
17 | return ;
18 | }
19 |
20 | return null;
21 | };
22 |
23 | Router.propTypes = propTypes;
24 |
25 | export default Router;
26 |
--------------------------------------------------------------------------------
/client/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import entities from '../reducers/entities';
3 | import environment from '../reducers/environment';
4 | import history from '../reducers/history';
5 | import router from '../reducers/router';
6 | import player from '../reducers/player';
7 | import playlists from '../reducers/playlists';
8 | import session from '../reducers/session';
9 |
10 | const rootReducer = combineReducers({
11 | entities,
12 | environment,
13 | history,
14 | player,
15 | playlists,
16 | router,
17 | session,
18 | });
19 |
20 | export default rootReducer;
21 |
--------------------------------------------------------------------------------
/client/styles/custom/components/nav-search.scss:
--------------------------------------------------------------------------------
1 | .nav-search {
2 | position: relative;
3 | background-color: $darkestGray;
4 | border-radius: 3px;
5 | }
6 |
7 | .nav-search__icon {
8 | position: absolute;
9 | top: 2px;
10 | left: 10px;
11 | color: #808080;
12 | pointer-events: none;
13 | }
14 |
15 | .nav-search__input {
16 | width: 290px;
17 | padding: 6px 10px 6px 30px;
18 | color: $lighterGray;
19 | font-size: 12px;
20 | font-weight: 300;
21 | background-color: transparent;
22 | border: none;
23 | outline: 0;
24 | }
25 |
26 | .nav-search__input::placeholder {
27 | font-family: $fontFamily;
28 | }
29 |
--------------------------------------------------------------------------------
/client/styles/custom/components/nav-user.scss:
--------------------------------------------------------------------------------
1 | .nav-user {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | height: 100%;
6 | }
7 |
8 | .nav-user__trigger {
9 | display: flex;
10 | flex-direction: row;
11 | align-items: center;
12 | }
13 |
14 | .nav-user__icon {
15 | color: $white;
16 | font-size: 20px;
17 | }
18 |
19 | .nav-user__chevron {
20 | margin-left: 6px;
21 | color: $lightBlack;
22 | font-size: 10px;
23 | }
24 |
25 | .nav-user__avatar {
26 | width: 29px;
27 | height: 29px;
28 | background: no-repeat center center;
29 | background-size: cover;
30 | border-radius: 50%;
31 | }
32 |
--------------------------------------------------------------------------------
/client/src/selectors/HistorySelectors.js:
--------------------------------------------------------------------------------
1 | import { denormalize } from 'normalizr';
2 | import { createSelector } from 'reselect';
3 | import { HISTORY_PLAYLIST } from '../constants/PlaylistConstants';
4 | import { songSchema } from '../constants/Schemas';
5 | import { getEntities, getPlaylists } from '../selectors/CommonSelectors';
6 |
7 | export const getSongs = createSelector(
8 | getPlaylists,
9 | getEntities,
10 | (playlists, entities) => (HISTORY_PLAYLIST in playlists
11 | ? denormalize(playlists[HISTORY_PLAYLIST].items, [songSchema], entities)
12 | : []
13 | ),
14 | );
15 |
16 | export const getShowHistory = state => state.history.showHistory;
17 |
--------------------------------------------------------------------------------
/client/styles/custom/components/stats.scss:
--------------------------------------------------------------------------------
1 | .stats {
2 | display: flex;
3 | flex-direction: row;
4 | }
5 |
6 | .stats__stat {
7 | color: $gray;
8 | font-size: 11px;
9 |
10 | & + .stats__stat {
11 | margin-left: 20px;
12 | }
13 | }
14 |
15 | .stats__stat__text {
16 | margin-left: 10px;
17 | }
18 |
19 | .stats__stat--heart {
20 | .heart__inner {
21 | display: flex;
22 | flex-direction: row;
23 |
24 | &:hover {
25 | cursor: pointer;
26 | text-decoration: underline;
27 | }
28 | }
29 |
30 | .heart__icon {
31 | font-size: 11px;
32 | }
33 |
34 | .heart__count {
35 | margin-left: 10px;
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/client/src/components/HeartCount.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import { addCommas } from '../utils/NumberUtils';
4 |
5 | const defaultProps = {
6 | favoritingsCount: null,
7 | };
8 |
9 | const propTypes = {
10 | favoritingsCount: PropTypes.number,
11 | };
12 |
13 | const HeartCount = ({ favoritingsCount }) => {
14 | if (favoritingsCount) {
15 | return (
16 |
17 | {addCommas(favoritingsCount)}
18 |
19 | );
20 | }
21 |
22 | return null;
23 | };
24 |
25 | HeartCount.defaultProps = defaultProps;
26 | HeartCount.propTypes = propTypes;
27 |
28 | export default HeartCount;
29 |
--------------------------------------------------------------------------------
/client/src/selectors/SongsSelectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import {
3 | getEntities,
4 | getGenre,
5 | getId,
6 | getOauthToken,
7 | getPlaylists,
8 | getSearch,
9 | getShowLikes,
10 | getShowPlaylist,
11 | getShowStream,
12 | getTime,
13 | } from '../selectors/CommonSelectors';
14 | import { playlistData } from '../utils/PlaylistUtils';
15 |
16 | const getPlaylistData = createSelector(
17 | getGenre,
18 | getSearch,
19 | getShowLikes,
20 | getShowPlaylist,
21 | getShowStream,
22 | getTime,
23 | getEntities,
24 | getId,
25 | getOauthToken,
26 | getPlaylists,
27 | playlistData,
28 | );
29 |
30 | export default getPlaylistData;
31 |
--------------------------------------------------------------------------------
/client/styles/custom/components/heart.scss:
--------------------------------------------------------------------------------
1 | .heart__inner {
2 | outline: 0;
3 |
4 | &:hover {
5 | cursor: pointer;
6 |
7 | .heart__icon {
8 | color: $lighterRed;
9 | }
10 | }
11 |
12 | &:active {
13 | .heart__icon {
14 | color: $lightRed;
15 | }
16 | }
17 | }
18 |
19 | .heart__icon {
20 | color: $gray;
21 |
22 | transition: color 0.2s ease-in-out;
23 | }
24 |
25 | .heart--liked {
26 | .heart__icon {
27 | color: $red;
28 | }
29 |
30 | .heart__inner {
31 | &:hover {
32 | .heart__icon {
33 | color: $darkRed;
34 | }
35 | }
36 |
37 | &:active {
38 | .heart__icon {
39 | color: $darkerRed;
40 | }
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/client/styles/custom/components/switch.scss:
--------------------------------------------------------------------------------
1 | .switch {
2 | position: relative;
3 | display: inline-block;
4 | width: 20px;
5 | height: 6px;
6 | border-radius: 3px;
7 | border: 1px solid $lighterGray;
8 | outline: 0;
9 |
10 | &:hover {
11 | cursor: pointer;
12 | }
13 | }
14 |
15 | .switch__button {
16 | position: absolute;
17 | top: -4px;
18 | left: -2px;
19 | width: 12px;
20 | height: 12px;
21 | border-radius: 50%;
22 | background-color: $white;
23 | border: 1px solid $lighterGray;
24 | transition: transform 110ms ease-in-out;
25 | }
26 |
27 | .switch--on {
28 | border-color: $blue;
29 | background-color: $blue;
30 |
31 | .switch__button {
32 | transform: translateX(10px);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/client/styles/custom/components/slider.scss:
--------------------------------------------------------------------------------
1 | .slider {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | width: 100%;
6 | padding: 8px 0;
7 | outline: 0;
8 |
9 | &:hover {
10 | cursor: pointer;
11 | }
12 | }
13 |
14 | .slider__bar {
15 | width: 100%;
16 | height: 2px;
17 | background-color: $lightGray;
18 | outline: 0;
19 | }
20 |
21 | .slider__bar__fill {
22 | position: relative;
23 | height: 100%;
24 | background-color: $green;
25 | }
26 |
27 | .slider__handle {
28 | position: absolute;
29 | top: -5px;
30 | right: -6px;
31 | width: 12px;
32 | height: 12px;
33 | background-color: $white;
34 | border-radius: 50%;
35 | border: 1px solid $lighterGray;
36 | outline: 0;
37 | }
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SoundRedux
2 |
3 | **NOTE** It seems that SoundCloud has revoked my api client keys without any explanation or warning. Running the app locally no longer works unless you have a working SoundCloud API client id (SoundCloud has disabled registration of new apps for quite some time now). The live demo is also not working at the moment.
4 |
5 | In an effort to learn es6 and [redux](https://github.com/reactjs/redux), this is SoundRedux, a simple [Soundcloud](http://soundcloud.com) client
6 |
7 | See it in action at https://soundredux.io
8 |
9 | Uses [normalizr](https://github.com/gaearon/normalizr)
10 |
11 | 1. `npm install`
12 | 2. `npm run start`
13 | 3. visit `http://localhost:8080`
14 |
15 | Feedback, issues, etc. are more than welcome!
16 |
--------------------------------------------------------------------------------
/client/styles/custom/components/artwork-play.scss:
--------------------------------------------------------------------------------
1 | .artwork-play {
2 | position: relative;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | width: 100%;
7 | height: 100%;
8 | background-color: transparent;
9 | outline: 0;
10 | transition: background-color 0.2s ease-in-out;
11 |
12 | &:hover {
13 | background-color: rgba(0, 0, 0, 0.8);
14 | cursor: pointer;
15 |
16 | .artwork-play__icon {
17 | opacity: 1;
18 | }
19 | }
20 | }
21 |
22 | .artwork-play--active {
23 | background-color: rgba(0, 0, 0, 0.8);
24 |
25 | .artwork-play__icon {
26 | opacity: 1;
27 | }
28 | }
29 |
30 | .artwork-play__icon {
31 | color: $green;
32 | font-size: 28px;
33 | opacity: 0;
34 | transition: opacity 0.2s ease-in-out;
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/components/SidebarBody.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 |
5 | const propTypes = {
6 | children: PropTypes.node.isRequired,
7 | };
8 |
9 | const onScroll = () => { document.body.style.overflow = 'hidden'; };
10 | const onMouseLeave = () => { document.body.style.overflow = 'auto'; };
11 |
12 | class SidebarBody extends Component {
13 | componentWillUnmount() {
14 | onMouseLeave();
15 | }
16 |
17 | render() {
18 | const { children } = this.props;
19 |
20 | return (
21 |
22 |
23 | {children}
24 |
25 |
26 | );
27 | }
28 | }
29 |
30 | SidebarBody.propTypes = propTypes;
31 |
32 | export default SidebarBody;
33 |
--------------------------------------------------------------------------------
/client/styles/custom/variables.scss:
--------------------------------------------------------------------------------
1 | $fontFamily: 'Open Sans','Helvetica Neue', Helvetica, Arial, sans-serif;
2 |
3 | $black: #222;
4 | $lightBlack: #6d6d6d;
5 | $lighterBlack: #999;
6 |
7 | $blue: #3381b7;
8 | $lightBlue: lighten($blue, 5);
9 | $darkBlue: darken($blue, 5);
10 |
11 | $lightestGray: #fafafa;
12 | $lighterGray: #e3e3e3;
13 | $lightGray: #ddd;
14 | $gray: #ccc;
15 | $mediumGray: #c8c9cb;
16 | $darkGray: #adadad;
17 | $darkerGray: #3a3f41;
18 | $darkestGray: #2B2B2B;
19 |
20 | $orange: #f85d0f;
21 | $darkOrange: darken($orange, 5);
22 | $darkerOrange: darken($orange, 10);
23 |
24 | $lightGreen: #a6d2a5;
25 | $green: #7ec57c;
26 | $darkGreen: darken($green, 5);
27 | $darkerGreen: darken($green, 10);
28 |
29 | $lighterRed: #F9C5C5;
30 | $lightRed: darken($lighterRed, 5);
31 | $red: #e17d74;
32 | $darkRed: darken($red, 5);
33 | $darkerRed: darken($red, 10);
34 |
35 | $white: #fff;
36 |
--------------------------------------------------------------------------------
/client/src/components/WaveformEvents.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | const propTypes = {
5 | isActive: PropTypes.bool.isRequired,
6 | onMouseMove: PropTypes.func.isRequired,
7 | playSong: PropTypes.func.isRequired,
8 | seek: PropTypes.func.isRequired,
9 | };
10 |
11 | const WaveformEvents = ({ isActive, onMouseMove, playSong, seek }) => {
12 | if (isActive) {
13 | return (
14 |
21 | );
22 | }
23 |
24 | return (
25 |
31 | );
32 | };
33 |
34 | WaveformEvents.propTypes = propTypes;
35 |
36 | export default WaveformEvents;
37 |
--------------------------------------------------------------------------------
/client/src/utils/ApiUtils.js:
--------------------------------------------------------------------------------
1 | /* global fetch */
2 | /* global window */
3 | import camelize from 'camelize';
4 | import SC from 'soundcloud';
5 |
6 | export const callApi = (url, options) =>
7 | fetch(url, options)
8 | .then(
9 | response => (response.ok
10 | ? response.json()
11 | : Promise.reject(response.text())
12 | ),
13 | error => Promise.reject(error))
14 | .then(
15 | json => ({ json: camelize(json) }),
16 | error => ({ error }))
17 | .catch(error => ({ error }));
18 |
19 | export const loginToSoundCloud = (clientId) => {
20 | SC.initialize({
21 | client_id: clientId,
22 | redirect_uri: `${window.location.protocol}//${window.location.host}/api/callback`,
23 | });
24 |
25 | return SC.connect()
26 | .then(
27 | json => ({ json: camelize(json) }),
28 | error => ({ error }),
29 | )
30 | .catch(error => ({ error }));
31 | };
32 |
--------------------------------------------------------------------------------
/client/src/index.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 |
3 | import 'babel-polyfill';
4 | import 'isomorphic-fetch';
5 | import OfflinePluginRuntime from 'offline-plugin/runtime';
6 | import React from 'react';
7 | import ReactDOM from 'react-dom';
8 | import { AppContainer } from 'react-hot-loader';
9 | import { Provider } from 'react-redux';
10 |
11 | import '../styles/main.scss';
12 | import RootContainer from './containers/RootContainer';
13 | import configureStore from './store/configureStore';
14 |
15 | OfflinePluginRuntime.install();
16 |
17 | const render = (Component) => {
18 | ReactDOM.render(
19 |
20 |
21 |
22 |
23 | ,
24 | document.getElementById('root'),
25 | );
26 | };
27 |
28 | render(RootContainer);
29 |
30 | if (module.hot) {
31 | module.hot.accept('./containers/RootContainer', () => {
32 | render(RootContainer);
33 | });
34 | }
35 |
--------------------------------------------------------------------------------
/client/styles/custom/components/user-following.scss:
--------------------------------------------------------------------------------
1 | .user-following {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | padding: 10px;
6 | font-size: 11px;
7 | background-color: #fff;
8 | border-bottom: 1px solid $lighterGray;
9 | border-left: 1px solid $lighterGray;
10 | border-right: 1px solid $lighterGray;
11 | }
12 |
13 | .user-following__avatar {
14 | width: 30px;
15 | height: 30px;
16 | margin-right: 10px;
17 | background: no-repeat center center;
18 | background-size: cover;
19 | border-radius: 50%;
20 | }
21 |
22 | .user-following__main {
23 | flex: 1;
24 | overflow: hidden;
25 | }
26 |
27 | .user-following__location {
28 | display: flex;
29 | flex-direction: row;
30 | color: $gray;
31 | }
32 |
33 | .user-following__location__icon {
34 | margin-right: 4px;
35 | }
36 |
37 | .user-following__followers {
38 | text-align: right;
39 | }
40 |
41 | .user-following__followers__text {
42 | color: $gray;
43 | }
44 |
--------------------------------------------------------------------------------
/client/styles/custom/components/popover.scss:
--------------------------------------------------------------------------------
1 | .popover {
2 | position: relative;
3 | }
4 |
5 | .popover__trigger {
6 | outline: 0;
7 |
8 | &:hover {
9 | cursor: pointer;
10 | text-decoration: none;
11 | }
12 | }
13 |
14 | .popover__panel {
15 | position: absolute;
16 | left: 0;
17 | top: 100%;
18 | min-width: 200px;
19 | margin-top: 4px;
20 | background-color: $white;
21 | border: solid 1px $lighterBlack;
22 | box-shadow: 0 0 2px 1px rgba(0,0,0,.2);
23 | z-index: 501;
24 | }
25 |
26 | .popover__panel__item {
27 | display: block;
28 | padding: 12px 16px;
29 | font-size: 12px;
30 | transition: background-color 200ms ease-in-out;
31 |
32 | &[role="button"] {
33 | &:hover {
34 | background-color: $lightestGray;
35 | cursor: pointer;
36 | }
37 |
38 | &:active {
39 | background-color: $lighterGray;
40 | }
41 | }
42 | }
43 |
44 | .popover--right {
45 | .popover__panel {
46 | left: auto;
47 | right: 0;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/client/src/containers/HistoryContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import toggleShowHistory from '../actions/HistoryActions';
4 | import { playSong } from '../actions/PlayerActions';
5 | import { navigateTo } from '../actions/RouterActions';
6 | import History from '../components/History';
7 | import { HISTORY_PLAYLIST } from '../constants/PlaylistConstants';
8 | import { getIsPlaying, getPlayingSongId } from '../selectors/CommonSelectors';
9 | import { getShowHistory, getSongs } from '../selectors/HistorySelectors';
10 |
11 | const HistoryContainer = props => ;
12 |
13 | const mapStateToProps = state => ({
14 | isPlaying: getIsPlaying(state),
15 | playingSongId: getPlayingSongId(state),
16 | playlist: HISTORY_PLAYLIST,
17 | showHistory: getShowHistory(state),
18 | songs: getSongs(state),
19 | });
20 |
21 | export default connect(mapStateToProps, {
22 | navigateTo,
23 | playSong,
24 | toggleShowHistory,
25 | })(HistoryContainer);
26 |
--------------------------------------------------------------------------------
/client/src/components/Switch.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 |
4 | const defaultProps = {
5 | args: [],
6 | };
7 |
8 | const propTypes = {
9 | args: PropTypes.arrayOf(PropTypes.any),
10 | on: PropTypes.bool.isRequired,
11 | onClick: PropTypes.func.isRequired,
12 | };
13 |
14 | class Switch extends Component {
15 | constructor() {
16 | super();
17 | this.onClick = this.onClick.bind(this);
18 | }
19 |
20 | onClick() {
21 | const { args, onClick } = this.props;
22 | onClick(...args);
23 | }
24 |
25 | render() {
26 | const { on } = this.props;
27 | return (
28 |
36 | );
37 | }
38 | }
39 |
40 | Switch.defaultProps = defaultProps;
41 | Switch.propTypes = propTypes;
42 |
43 | export default Switch;
44 |
--------------------------------------------------------------------------------
/client/styles/custom/components/loader.scss:
--------------------------------------------------------------------------------
1 | .loader--full {
2 | position: relative;
3 | width: 100%;
4 |
5 | .loader__rects {
6 | position: absolute;
7 | top: 20px;
8 | left: 0;
9 | width: 100%;
10 | }
11 | }
12 |
13 | .loader__rects {
14 | display: flex;
15 | flex-direction: row;
16 | align-items: center;
17 | justify-content: center;
18 | }
19 |
20 | .loader__rect {
21 | width: 7px;
22 | height: 30px;
23 | background-color: rgba(166, 210, 165, 0.9);
24 | animation: stretch 1.2s infinite ease-in-out;
25 |
26 | & + .loader__rect {
27 | margin-left: 3px;
28 | }
29 | }
30 |
31 | .loader__rect--2 {
32 | animation-delay: -1.1s;
33 | }
34 |
35 | .loader__rect--3 {
36 | animation-delay: -1.0s;
37 | }
38 |
39 | .loader__rect--4 {
40 | animation-delay: -0.9s;
41 | }
42 |
43 | .loader__rect--5 {
44 | animation-delay: -0.8s;
45 | }
46 |
47 | @keyframes stretch {
48 | 0%, 40%, 100% {
49 | transform: scaleY(0.4);
50 | } 20% {
51 | transform: scaleY(1.0);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/client/styles/custom/components/nav.scss:
--------------------------------------------------------------------------------
1 | .nav {
2 | height: 50px;
3 | background-color: $darkerGray;
4 |
5 | @include mobile {
6 | position: relative;
7 | padding: 0 10px;
8 | z-index: 5;
9 | };
10 | };
11 |
12 | .nav__inner {
13 | display: flex;
14 | flex-direction: row;
15 | height: 100%;
16 | }
17 |
18 | .nav__section {
19 | display: flex;
20 | flex-direction: row;
21 | align-items: center;
22 | height: 100%;
23 |
24 | & + .nav__section {
25 | margin-left: 30px;
26 | }
27 | }
28 |
29 | .nav__section--session {
30 | flex: 1;
31 | }
32 |
33 | .nav__section--search,
34 | .nav__section--session,
35 | .nav__section--user {
36 | @include mobile {
37 | display: none;
38 | };
39 | }
40 |
41 | .nav__logo__icon {
42 | color: $white;
43 | font-size: 20px;
44 | }
45 |
46 | .nav__logo__text {
47 | margin-left: 30px;
48 | color: $white;
49 | font-size: 14px;
50 | letter-spacing: 2px;
51 | text-transform: uppercase;
52 |
53 | &:hover {
54 | text-decoration: none;
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/client/src/components/Loader.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 |
4 | const defaultProps = {
5 | children: null,
6 | className: '',
7 | };
8 |
9 | const propTypes = {
10 | children: PropTypes.node,
11 | className: PropTypes.string,
12 | isLoading: PropTypes.bool.isRequired,
13 | };
14 |
15 | const Loader = ({ children, className, isLoading }) => {
16 | if (isLoading) {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | return children;
31 | };
32 |
33 | Loader.defaultProps = defaultProps;
34 | Loader.propTypes = propTypes;
35 |
36 | export default Loader;
37 |
--------------------------------------------------------------------------------
/client/src/utils/UserUtils.js:
--------------------------------------------------------------------------------
1 | export const getLocation = (user) => {
2 | const { city, country } = user;
3 |
4 | if (city && country) {
5 | return `${city}, ${country}`;
6 | }
7 |
8 | if (city) {
9 | return city;
10 | }
11 |
12 | if (country) {
13 | return country;
14 | }
15 |
16 | return 'Earth';
17 | };
18 |
19 | export const getSocialIcon = (service) => {
20 | switch (service) {
21 | case 'facebook':
22 | return 'ion-social-facebook';
23 | case 'twitter':
24 | return 'ion-social-twitter';
25 | case 'instagram':
26 | return 'ion-social-instagram';
27 | case 'youtube':
28 | return 'ion-social-youtube';
29 | case 'hypem':
30 | return 'ion-heart';
31 | case 'google_plus':
32 | return 'ion-social-googleplus';
33 | case 'spotify':
34 | return 'ion-music-note';
35 | case 'songkick':
36 | return 'ion-music-note';
37 | case 'soundcloud':
38 | return 'ion-music-note';
39 | default:
40 | return 'ion-ios-world-outline';
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/client/src/components/UserFollowButton.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 |
4 | const propTypes = {
5 | id: PropTypes.number.isRequired,
6 | isFollowing: PropTypes.bool.isRequired,
7 | toggleFollow: PropTypes.func.isRequired,
8 | };
9 |
10 | class UserFollowButton extends Component {
11 | constructor() {
12 | super();
13 | this.onClick = this.onClick.bind(this);
14 | }
15 |
16 | onClick() {
17 | const { id, isFollowing, toggleFollow } = this.props;
18 | toggleFollow(id, !isFollowing);
19 | }
20 |
21 | render() {
22 | const { isFollowing } = this.props;
23 |
24 | return (
25 |
31 | {isFollowing ? 'Following' : 'Follow'}
32 |
33 | );
34 | }
35 | }
36 |
37 | UserFollowButton.propTypes = propTypes;
38 |
39 | export default UserFollowButton;
40 |
--------------------------------------------------------------------------------
/client/src/actions/RouterActions.js:
--------------------------------------------------------------------------------
1 | /* global history */
2 | /* global location */
3 | /* global window */
4 | import { CHANGE_ROUTE } from '../constants/ActionTypes';
5 | import { compileHash, parseRoute } from '../utils/RouterUtils';
6 |
7 | const pushState = (route) => {
8 | const hash = compileHash(route);
9 | if (location.hash !== hash) {
10 | history.pushState({ route }, '', hash);
11 | }
12 | };
13 |
14 | export const navigateTo = (route, shouldPushState = true) => {
15 | if (shouldPushState) {
16 | pushState(route);
17 | }
18 |
19 | return {
20 | type: CHANGE_ROUTE,
21 | route,
22 | };
23 | };
24 |
25 | export const navigateBack = e => (dispatch) => {
26 | const { state } = e;
27 | if (state) {
28 | const { route } = state;
29 | dispatch(navigateTo(route, false));
30 | }
31 | };
32 |
33 | export const initRouter = paths => (dispatch) => {
34 | window.onpopstate = (e) => {
35 | dispatch(navigateBack(e));
36 | };
37 |
38 | const hash = location.hash ? location.hash.slice(2) : '';
39 | const route = parseRoute(hash, paths);
40 | return dispatch(navigateTo(route));
41 | };
42 |
--------------------------------------------------------------------------------
/client/styles/custom/components/toggle-play-button.scss:
--------------------------------------------------------------------------------
1 | .toggle-play-button {
2 | position: relative;
3 | display: flex;
4 | align-items: center;
5 | justify-content: center;
6 | width: 100%;
7 | height: 100%;
8 | background-color: transparent;
9 | transition: background-color 0.2s ease-in-out;
10 |
11 | &.active {
12 | background-color: rgba(0, 0, 0, 0.8);
13 |
14 | .toggle-play-button-icon {
15 | position: relative;
16 | opacity: 1;
17 | z-index: 2;
18 | }
19 | }
20 |
21 | &.is-playing {
22 | .ion-ios-play {
23 | display: none;
24 | }
25 |
26 | .ion-radio-waves {
27 | display: inline-block;
28 | }
29 | }
30 |
31 | &:hover {
32 | background-color: rgba(0, 0, 0, 0.8);
33 |
34 | .toggle-play-button-icon {
35 | opacity: 1;
36 | }
37 | }
38 |
39 | .ion-radio-waves {
40 | display: none;
41 | }
42 | }
43 |
44 | .toggle-play-button-icon {
45 | color: #A6D2A5;
46 | font-size: 28px;
47 | opacity: 0;
48 | transition: opacity 0.2s ease-in-out;
49 | }
50 |
--------------------------------------------------------------------------------
/client/styles/custom/components/song-comment.scss:
--------------------------------------------------------------------------------
1 | .song-comment {
2 | display: flex;
3 | flex-direction: row;
4 | padding: 10px;
5 | background-color: #fff;
6 | border-bottom: 1px solid $lighterGray;
7 | border-left: 1px solid $lighterGray;
8 | border-right: 1px solid $lighterGray;
9 | font-size: 11px;
10 | overflow: hidden;
11 |
12 | &:nth-child(-n + 10) {
13 | animation-fill-mode: forwards;
14 | animation-name: slideIn;
15 | animation-duration: 360ms;
16 | animation-timing-function: ease;
17 | opacity: 0;
18 | transform: translateX(200px);
19 | }
20 | }
21 |
22 | .song-comment__image {
23 | width: 24px;
24 | height: 24px;
25 | margin-right: 20px;
26 | border-radius: 50%;
27 | background: no-repeat center center;
28 | background-size: cover;
29 | };
30 |
31 | .song-comment__main {
32 | flex: 1;
33 | margin-right: 20px;
34 | overflow: hidden;
35 | }
36 |
37 | .song-comment__username {
38 | color: $gray;
39 | }
40 |
41 | .song-comment__body {
42 | word-break: break-word;
43 | word-wrap: break-word;
44 | }
45 |
46 | @keyframes slideIn {
47 | to {
48 | transform: translateX(0px);
49 | opacity: 1;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/client/src/selectors/NavSelectors.js:
--------------------------------------------------------------------------------
1 | import { denormalize } from 'normalizr';
2 | import { createSelector } from 'reselect';
3 | import { SESSION_STREAM_PLAYLIST } from '../constants/PlaylistConstants';
4 | import { PLAYLIST_PATH } from '../constants/RouterConstants';
5 | import { playlistSchema } from '../constants/Schemas';
6 | import { getEntities, getId, getPlaylists, getOauthToken, getPath } from '../selectors/CommonSelectors';
7 |
8 | export const getNavPlaylists = createSelector(
9 | getEntities,
10 | entities => denormalize(Object.keys(entities.playlists), [playlistSchema], entities),
11 | );
12 |
13 | export const getNavPlaylist = createSelector(
14 | getPath,
15 | getId,
16 | getEntities,
17 | (path, id, entities) => (path === PLAYLIST_PATH && id
18 | ? denormalize(id, playlistSchema, entities)
19 | : null
20 | ),
21 | );
22 |
23 | export const getStreamFutureUrl = createSelector(
24 | getOauthToken,
25 | getPlaylists,
26 | (oauthToken, playlists) => (
27 | SESSION_STREAM_PLAYLIST in playlists && playlists[SESSION_STREAM_PLAYLIST].futureUrl
28 | ? `${playlists[SESSION_STREAM_PLAYLIST].futureUrl}&oauth_token=${oauthToken}`
29 | : ''
30 | ),
31 | );
32 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | SoundRedux
12 |
13 |
14 |
15 |
16 |
17 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/client/styles/custom/custom.scss:
--------------------------------------------------------------------------------
1 | @import 'mixins';
2 | @import 'variables';
3 |
4 | @import 'components/a';
5 | @import 'components/artwork-play';
6 | @import 'components/button';
7 | @import 'components/container';
8 | @import 'components/heart';
9 | @import 'components/history';
10 | @import 'components/loader';
11 | @import 'components/nav';
12 | @import 'components/nav-playlists';
13 | @import 'components/nav-search';
14 | @import 'components/nav-session';
15 | @import 'components/nav-user';
16 | @import 'components/player';
17 | @import 'components/popover';
18 | @import 'components/reset';
19 | @import 'components/row';
20 | @import 'components/sidebar';
21 | @import 'components/slider';
22 | @import 'components/song';
23 | @import 'components/song-comment';
24 | @import 'components/song-main';
25 | @import 'components/song-list';
26 | @import 'components/songs-header';
27 | @import 'components/songs-body';
28 | @import 'components/songs-body-card';
29 | @import 'components/stats';
30 | @import 'components/switch';
31 | @import 'components/toggle-play-button';
32 | @import 'components/user';
33 | @import 'components/user-following';
34 | @import 'components/user-main';
35 | @import 'components/waveform';
36 |
--------------------------------------------------------------------------------
/client/src/containers/NavContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { navigateTo } from '../actions/RouterActions';
4 | import { fetchNewStreamSongs, loadNewStreamSongs, login, logout } from '../actions/SessionActions';
5 | import Nav from '../components/Nav';
6 | import { getIsAuthenticated, getNewStreamSongs, getSessionUser, getShowLikes, getShowPlaylist, getShowStream } from '../selectors/CommonSelectors';
7 | import { getNavPlaylist, getNavPlaylists, getStreamFutureUrl } from '../selectors/NavSelectors';
8 |
9 | const NavContainer = props => ;
10 |
11 | const mapStateToProps = state => ({
12 | isAuthenticated: getIsAuthenticated(state),
13 | navPlaylist: getNavPlaylist(state),
14 | navPlaylists: getNavPlaylists(state),
15 | newStreamSongs: getNewStreamSongs(state),
16 | showLikes: getShowLikes(state),
17 | showPlaylist: getShowPlaylist(state),
18 | showStream: getShowStream(state),
19 | streamFutureUrl: getStreamFutureUrl(state),
20 | user: getSessionUser(state),
21 | });
22 |
23 | export default connect(mapStateToProps, {
24 | fetchNewStreamSongs,
25 | loadNewStreamSongs,
26 | login,
27 | logout,
28 | navigateTo,
29 | })(NavContainer);
30 |
--------------------------------------------------------------------------------
/client/src/reducers/history.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 |
3 | const initialState = {
4 | playlists: [],
5 | showHistory: false,
6 | };
7 |
8 | const playlists = (state = [], action) => {
9 | switch (action.type) {
10 | case types.PLAY_SONG:
11 | return [
12 | ...state.filter(playlist => playlist !== action.playlist),
13 | ...action.playlist === state[state.length - 1]
14 | ? [action.playlist]
15 | : [],
16 | ];
17 |
18 | default:
19 | return state;
20 | }
21 | };
22 |
23 | const history = (state = initialState, action) => {
24 | switch (action.type) {
25 | case types.CHANGE_ROUTE:
26 | return {
27 | ...state,
28 | showHistory: false,
29 | };
30 |
31 | case types.PLAY_SONG:
32 | return {
33 | ...state,
34 | playlists: playlists(state.playlists, action),
35 | };
36 |
37 | case types.TOGGLE_SHOW_HISTORY:
38 | return {
39 | ...state,
40 | showHistory: !state.showHistory,
41 | };
42 |
43 | case types.LOGOUT:
44 | return { ...initialState };
45 |
46 | default:
47 | return state;
48 | }
49 | };
50 |
51 | export default history;
52 |
--------------------------------------------------------------------------------
/client/src/components/UserFollowings.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import SidebarBody from '../components/SidebarBody';
4 | import UserFollowing from '../components/UserFollowing';
5 |
6 | const propTypes = {
7 | followings: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
8 | navigateTo: PropTypes.func.isRequired,
9 | sidebarHeight: PropTypes.number.isRequired,
10 | sticky: PropTypes.bool.isRequired,
11 | };
12 |
13 | const UserFollowings = ({ followings, navigateTo, sidebarHeight, sticky }) => (
14 |
18 |
19 |
20 | {`Following ${followings.length} User${followings.length === 1 ? '' : 's'}`}
21 |
22 |
23 |
24 | {followings.map(following => (
25 |
30 | ))}
31 |
32 |
33 | );
34 |
35 | UserFollowings.propTypes = propTypes;
36 |
37 | export default UserFollowings;
38 |
--------------------------------------------------------------------------------
/client/src/components/SongsBodyCardMobileEvents.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 |
5 | const propTypes = {
6 | index: PropTypes.number.isRequired,
7 | isActive: PropTypes.bool.isRequired,
8 | playlist: PropTypes.string.isRequired,
9 | playSong: PropTypes.func.isRequired,
10 | };
11 |
12 | class SongsBodyCardMobileEvents extends Component {
13 | constructor() {
14 | super();
15 | this.onClick = this.onClick.bind(this);
16 | }
17 |
18 | onClick() {
19 | const { index, isActive, playlist, playSong } = this.props;
20 | if (isActive) {
21 | const audioElement = document.getElementById('audio');
22 | if (audioElement.paused) {
23 | audioElement.play();
24 | } else {
25 | audioElement.pause();
26 | }
27 | } else {
28 | playSong(playlist, index);
29 | }
30 | }
31 |
32 | render() {
33 | return (
34 |
40 | );
41 | }
42 | }
43 |
44 |
45 | SongsBodyCardMobileEvents.propTypes = propTypes;
46 |
47 | export default SongsBodyCardMobileEvents;
48 |
--------------------------------------------------------------------------------
/client/src/constants/ApiConstants.js:
--------------------------------------------------------------------------------
1 | const API_HOSTNAME = '//api.soundcloud.com';
2 | export const CLIENT_ID = 'f4323c6f7c0cd73d2d786a2b1cdae80c';
3 |
4 | const constructUrl = url => `${API_HOSTNAME}${url}${url.indexOf('?') === -1 ? '?' : '&'}client_id=${CLIENT_ID}`;
5 |
6 | export const SESSION_FOLLOWINGS_URL = `${API_HOSTNAME}/me/followings`;
7 | export const SESSION_LIKES_URL = `${API_HOSTNAME}/me/favorites`;
8 | export const SESSION_PLAYLISTS_URL = `${API_HOSTNAME}/me/playlists`;
9 | export const SESSION_STREAM_URL = `${API_HOSTNAME}/me/activities/tracks/affiliated?limit=50`;
10 | export const SESSION_USER_URL = `${API_HOSTNAME}/me`;
11 | export const SONG_URL = constructUrl('/tracks/:id');
12 | export const SONG_COMMENTS_URL = constructUrl('/tracks/:id/comments');
13 | export const SONGS_URL = constructUrl('/tracks?linked_partitioning=1&limit=50&offset=0');
14 | export const TOGGLE_FOLLOW_URL = `${API_HOSTNAME}/me/followings/:id`;
15 | export const TOGGLE_LIKE_URL = `${API_HOSTNAME}/me/favorites/:id`;
16 | export const USER_FOLLOWINGS_URL = constructUrl('/users/:id/followings');
17 | export const USER_PROFILES_URL = constructUrl('/users/:id/web-profiles');
18 | export const USER_URL = constructUrl('/users/:id');
19 | export const USER_SONGS_URL = constructUrl('/users/:id/tracks');
20 |
--------------------------------------------------------------------------------
/client/styles/custom/components/button.scss:
--------------------------------------------------------------------------------
1 | .button {
2 | display: inline-block;
3 | padding: 12px;
4 | min-width: 100px;
5 | background-color: $white;
6 | text-align: center;
7 | color: $black;
8 | font-size: 12px;
9 | border: 1px solid #C7C7C7;
10 | border-radius: 2px;
11 | outline: 0;
12 | transition: background-color 0.2s ease-in-out;
13 | user-select: none;
14 |
15 | &:hover {
16 | background-color: $lightestGray;
17 | cursor: pointer;
18 | }
19 |
20 | &:active {
21 | background-color: $lighterGray;
22 | }
23 | }
24 |
25 | .button--block {
26 | display: block;
27 | }
28 |
29 | .button--margin {
30 | margin: 10px;
31 | }
32 |
33 | .button--short {
34 | padding: 4px 6px;
35 | font-size: 11px;
36 | }
37 |
38 | .button--orange {
39 | color: $white;
40 | background-color: $orange;
41 | border: 1px solid $darkerOrange;
42 |
43 | &:hover {
44 | background-color: $darkOrange;
45 | }
46 |
47 | &:active {
48 | background-color: $darkerOrange;
49 | }
50 | }
51 |
52 | .button--red {
53 | color: $white;
54 | background-color: $red;
55 | border: 1px solid $darkerRed;
56 |
57 | &:hover {
58 | background-color: $darkRed;
59 | }
60 |
61 | &:active {
62 | background-color: $darkerRed;
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/client/src/components/SongComment.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import IMAGE_SIZES from '../constants/ImageConstants';
4 | import { formatSeconds } from '../utils/NumberUtils';
5 | import getImageUrl from '../utils/ImageUtils';
6 |
7 | const propTypes = {
8 | comment: PropTypes.shape({}).isRequired,
9 | index: PropTypes.number.isRequired,
10 | };
11 |
12 | const SongComment = ({ comment, index }) => {
13 | const { body, unixTimestamp, user } = comment;
14 | const { avatarUrl, username } = user;
15 |
16 | return (
17 |
18 |
22 |
23 |
24 | {body}
25 |
26 |
27 | {username}
28 |
29 |
30 |
31 | {formatSeconds(unixTimestamp)}
32 |
33 |
34 | );
35 | };
36 |
37 | SongComment.propTypes = propTypes;
38 |
39 | export default SongComment;
40 |
--------------------------------------------------------------------------------
/client/src/containers/RootContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 |
4 | import { initEnvironment } from '../actions/EnvironmentActions';
5 | import { initRouter } from '../actions/RouterActions';
6 | import { initAuth } from '../actions/SessionActions';
7 | import Root from '../components/Root';
8 | import SongContainer from '../containers/SongContainer';
9 | import SongsContainer from '../containers/SongsContainer';
10 | import UserContainer from '../containers/UserContainer';
11 |
12 | import {
13 | INDEX_PATH,
14 | PLAYLIST_PATH,
15 | SONG_PATH,
16 | SONGS_PATH,
17 | USER_PATH,
18 | } from '../constants/RouterConstants';
19 |
20 | const RootContainer = props => ;
21 |
22 | const mapStateToProps = (state) => {
23 | const { router } = state;
24 |
25 | return {
26 | paths: [INDEX_PATH, PLAYLIST_PATH, SONG_PATH, SONGS_PATH, USER_PATH],
27 | router,
28 | routes: {
29 | [INDEX_PATH]: SongsContainer,
30 | [PLAYLIST_PATH]: SongsContainer,
31 | [SONG_PATH]: SongContainer,
32 | [SONGS_PATH]: SongsContainer,
33 | [USER_PATH]: UserContainer,
34 | },
35 | };
36 | };
37 |
38 |
39 | export default connect(mapStateToProps, {
40 | initAuth,
41 | initEnvironment,
42 | initRouter,
43 | })(RootContainer);
44 |
--------------------------------------------------------------------------------
/client/src/constants/PlaylistConstants.js:
--------------------------------------------------------------------------------
1 | export const GENRE_PLAYLIST_TYPE = 'GENRE_PLAYLIST_TYPE';
2 | export const PLAYLIST_PLAYLIST_TYPE = 'PLAYLIST_PLAYLIST_TYPE';
3 | export const SEARCH_PLAYLIST_TYPE = 'SEARCH_PLAYLIST_TYPE';
4 | export const SONG_PLAYLIST_TYPE = 'SONG_PLAYLIST_TYPE';
5 | export const SESSION_PLAYLIST_TYPE = 'SESSION_PLAYLIST_TYPE';
6 | export const USER_PLAYLIST_TYPE = 'USER_PLAYLIST_TYPE';
7 |
8 | export const HISTORY_PLAYLIST = 'HISTORY_PLAYLIST';
9 | export const SESSION_LIKES_PLAYLIST = `${SESSION_PLAYLIST_TYPE}|LIKES`;
10 | export const SESSION_STREAM_PLAYLIST = `${SESSION_PLAYLIST_TYPE}|STREAM`;
11 |
12 | export const GENRES = [
13 | { key: 'chill', query: 'chill house' },
14 | { key: 'deep', query: 'deep house' },
15 | { key: 'dubstep', query: 'dubstep' },
16 | { key: 'house', query: 'house' },
17 | { key: 'progressive', query: 'progressive house' },
18 | { key: 'tech', query: 'tech house' },
19 | { key: 'trance', query: 'trance' },
20 | { key: 'tropical', query: 'tropical house' },
21 | ];
22 |
23 | export const GENRE_QUERY_MAP = GENRES.reduce((obj, genre) => ({
24 | ...obj,
25 | [genre.key]: genre.query,
26 | }), {});
27 |
28 | export const TIMES = [
29 | { key: '7', label: '7 days' },
30 | { key: '30', label: '30 days' },
31 | { key: '90', label: '90 days' },
32 | ];
33 |
--------------------------------------------------------------------------------
/client/src/components/stickyOnScroll.jsx:
--------------------------------------------------------------------------------
1 | /* global window */
2 | import React, { Component } from 'react';
3 |
4 | const stickyOnScroll = (InnerComponent, scrollThreshold) => {
5 | class StickyOnScrollComponent extends Component {
6 | constructor(props) {
7 | super(props);
8 | this.onScroll = this.onScroll.bind(this);
9 | this.state = { sticky: false };
10 | }
11 |
12 | componentDidMount() {
13 | window.addEventListener('scroll', this.onScroll, false);
14 | }
15 |
16 | componentWillUnmount() {
17 | window.removeEventListener('scroll', this.onScroll, false);
18 | }
19 |
20 | onScroll() {
21 | const { scrollY } = window;
22 | const { sticky } = this.state;
23 | const scrolledPastThreshold = scrollY >= scrollThreshold;
24 |
25 | if (scrolledPastThreshold && !sticky) {
26 | this.setState({ sticky: true });
27 | } else if (!scrolledPastThreshold && sticky) {
28 | this.setState({ sticky: false });
29 | }
30 | }
31 |
32 | render() {
33 | const { sticky } = this.state;
34 |
35 | return (
36 |
40 | );
41 | }
42 | }
43 |
44 | return StickyOnScrollComponent;
45 | };
46 |
47 | export default stickyOnScroll;
48 |
--------------------------------------------------------------------------------
/client/src/components/SongsHeaderTimes.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Link from '../components/Link';
4 | import { SONGS_PATH } from '../constants/RouterConstants';
5 |
6 | const propTypes = {
7 | genre: PropTypes.string.isRequired,
8 | navigateTo: PropTypes.func.isRequired,
9 | search: PropTypes.string.isRequired,
10 | time: PropTypes.string.isRequired,
11 | times: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
12 | };
13 |
14 | const SongsHeaderTimes = ({ genre, navigateTo, search, time, times }) => (
15 |
16 |
17 |
18 | {times.map(t => (
19 |
30 | {t.label}
31 |
32 | ))}
33 |
34 |
35 | );
36 |
37 | SongsHeaderTimes.propTypes = propTypes;
38 |
39 | export default SongsHeaderTimes;
40 |
--------------------------------------------------------------------------------
/client/styles/custom/components/nav-playlists.scss:
--------------------------------------------------------------------------------
1 | .nav-playlists {
2 | height: 100%;
3 | }
4 |
5 | .nav-playlists__item {
6 | display: flex;
7 | flex-direction: row;
8 | align-items: center;
9 | height: 48px;
10 | padding: 0 12px;
11 | overflow: hidden;
12 |
13 | & + .nav-playlists__item {
14 | border-top: 1px solid $lighterGray
15 | }
16 |
17 | &:hover {
18 | background-color: $lightestGray;
19 | text-decoration: none;
20 | }
21 |
22 | > * {
23 | pointer-events: none;
24 | }
25 | }
26 |
27 | .nav-playlists__item__main {
28 | display: flex;
29 | flex-direction: column;
30 | justify-content: center;
31 | height: 100%;
32 | }
33 |
34 | .nav-playlists__item__title {
35 | width: 180px;
36 | color: $black;
37 | font-size: 11px;
38 | overflow: hidden;
39 | text-overflow: ellipsis;
40 | white-space: nowrap;
41 | }
42 |
43 | .nav-playlists__item__meta {
44 | color: $gray;
45 | font-size: 11px;
46 | }
47 |
48 | .nav-playlists__item__songs {
49 | display: flex;
50 | flex-direction: row;
51 | flex: 1;
52 | justify-content: flex-end;
53 | margin-left: 4px;
54 | }
55 |
56 | .nav-playlists__item__song {
57 | width: 32px;
58 | height: 32px;
59 | background: no-repeat center center;
60 | background-size: cover;
61 |
62 | & + .nav-playlists__item__song {
63 | margin-left: 4px;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/client/src/components/InfiniteScroll.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 | /* global window */
3 | import PropTypes from 'prop-types';
4 | import React, { Component } from 'react';
5 |
6 | const defaultProps = {
7 | args: [],
8 | className: '',
9 | };
10 |
11 | const propTypes = {
12 | args: PropTypes.arrayOf(PropTypes.any),
13 | children: PropTypes.node.isRequired,
14 | className: PropTypes.string,
15 | onScroll: PropTypes.func.isRequired,
16 | };
17 |
18 | class InfiniteScroll extends Component {
19 | constructor(props) {
20 | super(props);
21 | this.onScroll = this.onScroll.bind(this);
22 | }
23 |
24 | componentDidMount() {
25 | window.addEventListener('scroll', this.onScroll, false);
26 | }
27 |
28 | componentWillUnmount() {
29 | window.removeEventListener('scroll', this.onScroll, false);
30 | }
31 |
32 | onScroll() {
33 | if ((window.innerHeight + window.scrollY) >= (document.body.offsetHeight - 200)) {
34 | const { args, onScroll } = this.props;
35 | onScroll(...args);
36 | }
37 | }
38 |
39 | render() {
40 | const { children, className } = this.props;
41 | return (
42 |
43 | {children}
44 |
45 | );
46 | }
47 | }
48 |
49 | InfiniteScroll.defaultProps = defaultProps;
50 | InfiniteScroll.propTypes = propTypes;
51 |
52 | export default InfiniteScroll;
53 |
--------------------------------------------------------------------------------
/client/src/containers/SongContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { playSong } from '../actions/PlayerActions';
4 | import { navigateTo } from '../actions/RouterActions';
5 | import { login, toggleLike } from '../actions/SessionActions';
6 | import fetchSongIfNeeded from '../actions/SongActions';
7 | import Song from '../components/Song';
8 | import { getId, getIsAuthenticated, getLikes, getPlayingSongId, getSidebarHeight } from '../selectors/CommonSelectors';
9 | import { getComments, getPlaylist, getSong, getSongs, getTimed } from '../selectors/SongSelectors';
10 |
11 | const SongContainer = props => ;
12 |
13 | const mapStateToProps = (state) => {
14 | const { player, playlists } = state;
15 |
16 | return {
17 | comments: getComments(state),
18 | id: getId(state),
19 | isAuthenticated: getIsAuthenticated(state),
20 | likes: getLikes(state),
21 | player,
22 | playingSongId: getPlayingSongId(state),
23 | playlist: getPlaylist(state),
24 | playlists,
25 | sidebarHeight: getSidebarHeight(state),
26 | song: getSong(state),
27 | songs: getSongs(state),
28 | timed: getTimed(state),
29 | };
30 | };
31 |
32 | export default connect(mapStateToProps, {
33 | fetchSongIfNeeded,
34 | login,
35 | navigateTo,
36 | playSong,
37 | toggleLike,
38 | })(SongContainer);
39 |
--------------------------------------------------------------------------------
/client/src/components/Root.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 |
4 | import Router from '../components/Router';
5 | import HistoryContainer from '../containers/HistoryContainer';
6 | import NavContainer from '../containers/NavContainer';
7 | import PlayerContainer from '../containers/PlayerContainer';
8 |
9 | const propTypes = {
10 | initAuth: PropTypes.func.isRequired,
11 | initEnvironment: PropTypes.func.isRequired,
12 | initRouter: PropTypes.func.isRequired,
13 | paths: PropTypes.arrayOf(PropTypes.string).isRequired,
14 | router: PropTypes.shape({
15 | keys: PropTypes.shape({}),
16 | options: PropTypes.shape({}),
17 | path: PropTypes.string,
18 | }).isRequired,
19 | routes: PropTypes.shape({}).isRequired,
20 | };
21 |
22 | class Root extends Component {
23 | componentWillMount() {
24 | const { initAuth, initEnvironment, initRouter, paths } = this.props;
25 | initAuth();
26 | initEnvironment();
27 | initRouter(paths);
28 | }
29 |
30 | render() {
31 | const { router, routes } = this.props;
32 | return (
33 |
39 | );
40 | }
41 | }
42 |
43 | Root.propTypes = propTypes;
44 |
45 | export default Root;
46 |
--------------------------------------------------------------------------------
/client/src/components/NavPlaylists.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import NavPlaylistsItem from '../components/NavPlaylistsItem';
4 | import Popover from '../components/Popover';
5 |
6 | const defaultProps = {
7 | navPlaylist: null,
8 | };
9 |
10 | const propTypes = {
11 | navigateTo: PropTypes.func.isRequired,
12 | navPlaylist: PropTypes.shape({}),
13 | navPlaylists: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
14 | showPlaylist: PropTypes.bool.isRequired,
15 | };
16 |
17 | const NavPlaylists = ({ navigateTo, navPlaylist, navPlaylists, showPlaylist }) => (
18 |
19 |
20 |
21 | {navPlaylist ? navPlaylist.title : 'Playlists'}
22 |
23 |
24 |
25 |
26 | {navPlaylists.map(playlist => (
27 |
32 | ))}
33 |
34 |
35 | );
36 |
37 | NavPlaylists.defaultProps = defaultProps;
38 | NavPlaylists.propTypes = propTypes;
39 |
40 | export default NavPlaylists;
41 |
--------------------------------------------------------------------------------
/client/src/components/PopoverPanel.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 |
5 | const propTypes = {
6 | children: PropTypes.node.isRequired,
7 | toggleIsOpen: PropTypes.func.isRequired,
8 | };
9 |
10 | class PopoverPanel extends Component {
11 | constructor() {
12 | super();
13 | this.onClick = this.onClick.bind(this);
14 | this.node = null;
15 | }
16 |
17 | componentDidMount() {
18 | document.addEventListener('click', this.onClick);
19 | }
20 |
21 | componentWillUnmount() {
22 | document.removeEventListener('click', this.onClick);
23 | }
24 |
25 | onClick(e) {
26 | const { target } = e;
27 | const { tagName } = target;
28 | const role = target.getAttribute('role');
29 |
30 | const outsideClick = !this.node.contains(target);
31 | const targetIsButton = role === 'button';
32 | const targetIsLink = role === 'link' || tagName === 'A';
33 |
34 | if (outsideClick || targetIsButton || targetIsLink) {
35 | const { toggleIsOpen } = this.props;
36 | toggleIsOpen();
37 | }
38 | }
39 |
40 | render() {
41 | const { children } = this.props;
42 | return (
43 | { this.node = node; }}>
44 | {children}
45 |
46 | );
47 | }
48 | }
49 |
50 | PopoverPanel.propTypes = propTypes;
51 |
52 | export default PopoverPanel;
53 |
--------------------------------------------------------------------------------
/client/src/components/Popover.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import PopoverPanel from '../components/PopoverPanel';
4 |
5 | const defaultProps = {
6 | className: '',
7 | };
8 |
9 | const propTypes = {
10 | className: PropTypes.string,
11 | children: PropTypes.node.isRequired,
12 | };
13 |
14 | class Popover extends Component {
15 | constructor() {
16 | super();
17 | this.toggleIsOpen = this.toggleIsOpen.bind(this);
18 | this.state = { isOpen: false };
19 | }
20 |
21 | toggleIsOpen() {
22 | this.setState(state => ({
23 | isOpen: !state.isOpen,
24 | }));
25 | }
26 |
27 | render() {
28 | const { isOpen } = this.state;
29 | const { className, children } = this.props;
30 |
31 | return (
32 |
33 |
39 | {children[0]}
40 |
41 | {isOpen
42 | ? (
43 |
46 | {children[1]}
47 |
48 | ) : null
49 | }
50 |
51 | );
52 | }
53 | }
54 |
55 | Popover.defaultProps = defaultProps;
56 | Popover.propTypes = propTypes;
57 |
58 | export default Popover;
59 |
--------------------------------------------------------------------------------
/client/src/components/NavPlaylistsItem.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Link from '../components/Link';
4 | import { PLAYLIST_PATH } from '../constants/RouterConstants';
5 | import getImageUrl from '../utils/ImageUtils';
6 |
7 | const propTypes = {
8 | navigateTo: PropTypes.func.isRequired,
9 | playlist: PropTypes.shape({}).isRequired,
10 | };
11 |
12 | const NavPlaylistsItem = ({ navigateTo, playlist }) => {
13 | const { id, title, tracks } = playlist;
14 |
15 | return (
16 |
23 |
24 |
25 | {title}
26 |
27 |
28 | {`${tracks.length} Song${tracks.length === 1 ? '' : 's'}`}
29 |
30 |
31 |
32 | {tracks.slice(0, 5).map(song => (
33 |
38 | ))}
39 |
40 |
41 | );
42 | };
43 |
44 | NavPlaylistsItem.propTypes = propTypes;
45 |
46 | export default NavPlaylistsItem;
47 |
--------------------------------------------------------------------------------
/client/src/components/Link.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import { compileHash } from '../utils/RouterUtils';
4 |
5 | const defaultProps = {
6 | className: '',
7 | keys: {},
8 | onClick: null,
9 | options: {},
10 | title: '',
11 | };
12 |
13 | const propTypes = {
14 | children: PropTypes.node.isRequired,
15 | className: PropTypes.string,
16 | onClick: PropTypes.func,
17 | navigateTo: PropTypes.func.isRequired,
18 | keys: PropTypes.shape({}),
19 | options: PropTypes.shape({}),
20 | path: PropTypes.string.isRequired,
21 | title: PropTypes.string,
22 | };
23 |
24 | class Link extends Component {
25 | constructor() {
26 | super();
27 | this.onClick = this.onClick.bind(this);
28 | }
29 |
30 | onClick(e) {
31 | e.preventDefault();
32 | const { keys, navigateTo, onClick, options, path } = this.props;
33 | navigateTo({ path, keys, options });
34 | if (typeof onClick === 'function') {
35 | onClick();
36 | }
37 | }
38 |
39 | render() {
40 | const { children, className, keys, options, path, title } = this.props;
41 |
42 | return (
43 |
49 | {children}
50 |
51 | );
52 | }
53 | }
54 |
55 | Link.defaultProps = defaultProps;
56 | Link.propTypes = propTypes;
57 |
58 | export default Link;
59 |
--------------------------------------------------------------------------------
/client/src/containers/UserContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import User from '../components/User';
4 | import { playSong } from '../actions/PlayerActions';
5 | import { navigateTo } from '../actions/RouterActions';
6 | import { login, toggleFollow, toggleLike } from '../actions/SessionActions';
7 | import fetchUserIfNeeded from '../actions/UserActions';
8 | import { getId, getIsAuthenticated, getLikes, getPlayingSongId, getSidebarHeight } from '../selectors/CommonSelectors';
9 | import { getFollowings, getIsFollowing, getPlaylist, getProfiles, getShouldFetchUser, getSongs, getUser } from '../selectors/UserSelectors';
10 |
11 | const UserContainer = props => ;
12 |
13 | const mapStateToProps = (state) => {
14 | const { player } = state;
15 |
16 | return {
17 | followings: getFollowings(state),
18 | id: getId(state),
19 | isAuthenticated: getIsAuthenticated(state),
20 | isFollowing: getIsFollowing(state),
21 | likes: getLikes(state),
22 | player,
23 | playingSongId: getPlayingSongId(state),
24 | playlist: getPlaylist(state),
25 | profiles: getProfiles(state),
26 | sidebarHeight: getSidebarHeight(state),
27 | shouldFetchUser: getShouldFetchUser(state),
28 | songs: getSongs(state),
29 | user: getUser(state),
30 | };
31 | };
32 |
33 | export default connect(mapStateToProps, {
34 | fetchUserIfNeeded,
35 | login,
36 | toggleFollow,
37 | toggleLike,
38 | navigateTo,
39 | playSong,
40 | })(UserContainer);
41 |
--------------------------------------------------------------------------------
/client/src/components/ArtworkPlay.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 |
5 | const propTypes = {
6 | index: PropTypes.number.isRequired,
7 | isActive: PropTypes.bool.isRequired,
8 | isPlaying: PropTypes.bool.isRequired,
9 | playlist: PropTypes.string.isRequired,
10 | playSong: PropTypes.func.isRequired,
11 | };
12 |
13 | class ArtworkPlay extends Component {
14 | constructor() {
15 | super();
16 | this.playSong = this.playSong.bind(this);
17 | this.togglePlay = this.togglePlay.bind(this);
18 | }
19 |
20 | playSong() {
21 | const { index, playlist, playSong } = this.props;
22 | playSong(playlist, index);
23 | }
24 |
25 | togglePlay() {
26 | const { isPlaying } = this.props;
27 | const audioElement = document.getElementById('audio');
28 |
29 | if (isPlaying) {
30 | audioElement.pause();
31 | } else {
32 | audioElement.play();
33 | }
34 | }
35 |
36 | render() {
37 | const { isActive, isPlaying } = this.props;
38 | return (
39 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | ArtworkPlay.propTypes = propTypes;
52 |
53 | export default ArtworkPlay;
54 |
--------------------------------------------------------------------------------
/client/styles/custom/components/nav-session.scss:
--------------------------------------------------------------------------------
1 | .nav-session {
2 | display: flex;
3 | flex-direction: row;
4 | height: 100%;
5 | }
6 |
7 | .nav-session__item {
8 | position: relative;
9 | display: flex;
10 | flex-direction: row;
11 | align-items: center;
12 | height: 100%;
13 | padding: 0 25px;
14 | color: $mediumGray;
15 | font-size: 11px;
16 | letter-spacing: 2px;
17 | text-decoration: none;
18 | text-transform: uppercase;
19 | transition: background-color 200ms ease-in-out;
20 |
21 | &:hover {
22 | background-color: $darkestGray;
23 | text-decoration: none;
24 |
25 | .nav-session__item__badge {
26 | background-color: $lightBlue;
27 | }
28 | }
29 | }
30 |
31 | .nav-session__item__text {
32 | max-width: 300px;
33 | overflow: hidden;
34 | text-overflow: ellipsis;
35 | white-space: nowrap;
36 | }
37 |
38 | .nav-session__item__icon {
39 | margin-left: 4px;
40 | }
41 |
42 | .nav-session__item__badge {
43 | position: absolute;
44 | top: 12px;
45 | right: 12px;
46 | display: flex;
47 | min-width: 16px;
48 | padding: 0 1px 1px 0;
49 | align-items: center;
50 | justify-content: center;
51 | background-color: $blue;
52 | border-radius: 7px;
53 | transition: background-color 200ms ease-in-out;
54 | }
55 |
56 | .nav-session__item__badge__text {
57 | color: $white;
58 | font-size: 8px;
59 | letter-spacing: normal;
60 | text-transform: none;
61 | text-shadow: 0 0 2px #666;
62 | }
63 |
64 | .nav-session__item--active {
65 | background-color: $darkestGray;
66 | color: $white;
67 | font-weight: 600;
68 | }
69 |
--------------------------------------------------------------------------------
/client/src/components/NavUser.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import LoginPopoverPanel from '../components/LoginPopoverPanel';
4 | import SessionPopoverPanel from '../components/SessionPopoverPanel';
5 | import Popover from '../components/Popover';
6 | import getImageUrl from '../utils/ImageUtils';
7 |
8 | const defaultProps = {
9 | user: null,
10 | };
11 |
12 | const propTypes = {
13 | isAuthenticated: PropTypes.bool.isRequired,
14 | login: PropTypes.func.isRequired,
15 | logout: PropTypes.func.isRequired,
16 | user: PropTypes.shape({}),
17 | };
18 |
19 | const NavUser = ({ isAuthenticated, login, logout, user }) => {
20 | if (isAuthenticated) {
21 | const { avatarUrl } = user;
22 | return (
23 |
24 |
31 |
32 |
33 | );
34 | }
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | NavUser.defaultProps = defaultProps;
48 | NavUser.propTypes = propTypes;
49 |
50 | export default NavUser;
51 |
--------------------------------------------------------------------------------
/client/webpack.dev.config.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require('autoprefixer');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 | const OfflinePlugin = require('offline-plugin');
4 | const path = require('path');
5 | const webpack = require('webpack');
6 |
7 | module.exports = {
8 | context: path.resolve('client/src/'),
9 | devtool: 'eval',
10 | entry: [
11 | 'react-hot-loader/patch',
12 | './index.jsx',
13 | ],
14 | output: {
15 | filename: 'js/[name].js',
16 | },
17 | resolve: {
18 | extensions: ['.js', '.jsx'],
19 | },
20 | module: {
21 | loaders: [
22 | {
23 | test: /\.(js|jsx)$/,
24 | loaders: ['babel-loader'],
25 | exclude: /node_modules/,
26 | },
27 | {
28 | test: /\.scss$/,
29 | use: [
30 | { loader: 'css-hot-loader' },
31 | { loader: 'style-loader' },
32 | { loader: 'css-loader' },
33 | {
34 | loader: 'postcss-loader',
35 | options: {
36 | plugins: () => [autoprefixer({ browsers: ['> 1%', 'IE >= 10'] })],
37 | },
38 | },
39 | { loader: 'sass-loader' },
40 | ],
41 | },
42 | ],
43 | },
44 | plugins: [
45 | new webpack.HotModuleReplacementPlugin(),
46 | new webpack.NamedModulesPlugin(),
47 | new webpack.NoEmitOnErrorsPlugin(),
48 | new HtmlWebpackPlugin({ template: '../public/index.html' }),
49 | new webpack.IgnorePlugin(/\.svg$/),
50 | new OfflinePlugin({ caches: { main: [] } }),
51 | ],
52 | devServer: {
53 | host: '0.0.0.0',
54 | hot: true,
55 | port: '8080',
56 | },
57 | };
58 |
--------------------------------------------------------------------------------
/client/src/components/SongComments.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import SidebarBody from '../components/SidebarBody';
4 | import SongComment from '../components/SongComment';
5 | import Switch from '../components/Switch';
6 | import { SONG_PATH } from '../constants/RouterConstants';
7 |
8 | const propTypes = {
9 | comments: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
10 | id: PropTypes.number.isRequired,
11 | navigateTo: PropTypes.func.isRequired,
12 | sidebarHeight: PropTypes.number.isRequired,
13 | sticky: PropTypes.bool.isRequired,
14 | timed: PropTypes.bool.isRequired,
15 | };
16 |
17 | const SongComments = ({ comments, id, navigateTo, sidebarHeight, sticky, timed }) => (
18 |
22 |
23 |
24 | Comments
25 |
26 |
27 |
36 |
37 |
38 |
39 | {comments.map((comment, i) => (
40 |
45 | ))}
46 |
47 |
48 | );
49 |
50 | SongComments.propTypes = propTypes;
51 |
52 | export default SongComments;
53 |
--------------------------------------------------------------------------------
/client/src/utils/RouterUtils.js:
--------------------------------------------------------------------------------
1 | import pathRegexp, { compile } from 'path-to-regexp';
2 |
3 | const compileOptions = options => Object.keys(options)
4 | .map(key => `${key}=${options[key]}`)
5 | .join('&');
6 |
7 | export const compileHash = (route) => {
8 | const { path, keys, options } = route;
9 |
10 | const toPath = compile(path);
11 | const query = compileOptions(options);
12 | return `#/${toPath(keys)}${query === '' ? '' : `?${query}`}`;
13 | };
14 |
15 | const parseRouteKeys = (pathString, result) => {
16 | const { keys, regexp } = result;
17 | const regexpResult = regexp.exec(pathString);
18 |
19 | return keys.reduce((obj, key, i) => ({
20 | ...obj,
21 | [key.name]: i + 1 < regexpResult.length ? regexpResult[i + 1] : '',
22 | }), {});
23 | };
24 |
25 | const parseRouteOptions = optionsString => optionsString
26 | .split('&')
27 | .map(str => str.split('='))
28 | .filter(keyValuePair => keyValuePair.length === 2)
29 | .reduce((obj, keyValuePair) => ({
30 | ...obj,
31 | [keyValuePair[0]]: keyValuePair[1],
32 | }), {});
33 |
34 | export const parseRoute = (hash, paths) => {
35 | const hashParts = hash.split('?');
36 | const pathString = hashParts[0];
37 | const optionsString = hashParts.length > 1 ? hashParts[1] : '';
38 |
39 | const result = paths
40 | .map((path) => {
41 | const keys = [];
42 | const regexp = pathRegexp(path, keys);
43 |
44 | return { path, regexp, keys };
45 | })
46 | .find(path => path.regexp.test(pathString));
47 |
48 | const path = result ? result.path : pathString;
49 | const keys = result ? parseRouteKeys(pathString, result) : {};
50 | const options = parseRouteOptions(optionsString);
51 |
52 | return { path, keys, options };
53 | };
54 |
--------------------------------------------------------------------------------
/client/src/constants/ActionTypes.js:
--------------------------------------------------------------------------------
1 | export const CHANGE_ROUTE = 'CHANGE_ROUTE';
2 | export const FETCH_NEW_STREAM_SONGS_SUCCESS = 'FETCH_NEW_STREAM_SONGS_SUCCESS';
3 | export const FETCH_SESSION_LIKES_SUCCESS = 'FETCH_SESSION_LIKES_SUCCESS';
4 | export const FETCH_SESSION_FOLLOWINGS_SUCCESS = 'FETCH_SESSION_FOLLOWINGS_SUCCESS';
5 | export const FETCH_SESSION_PLAYLISTS_SUCCESS = 'FETCH_SESSION_PLAYLISTS_SUCCESS';
6 | export const FETCH_SESSION_USER_SUCCESS = 'FETCH_SESSION_USER_SUCCESS';
7 | export const FETCH_SONG_COMMENTS_SUCCESS = 'FETCH_SONG_COMMENTS_SUCCESS';
8 | export const FETCH_SONGS_REQUEST = 'FETCH_SONGS_REQUEST';
9 | export const FETCH_SONGS_SUCCESS = 'FETCH_SONGS_SUCCESS';
10 | export const FETCH_USER_FOLLOWINGS_SUCCESS = 'FETCH_USER_FOLLOWINGS_SUCCESS';
11 | export const FETCH_USER_PROFILES_SUCCESS = 'FETCH_USER_PROFILES_SUCCESS';
12 | export const FETCH_USER_SUCCESS = 'FETCH_USER_SUCCESS';
13 | export const LOAD_NEW_STREAM_SONGS = 'LOAD_NEW_STREAM_SONGS';
14 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
15 | export const LOGOUT = 'LOGOUT';
16 | export const ON_LOAD_START = 'ON_LOAD_START';
17 | export const ON_LOADED_METADATA = 'ON_LOADED_METADATA';
18 | export const ON_PAUSE = 'ON_PAUSE';
19 | export const ON_PLAY = 'ON_PLAY';
20 | export const ON_TIME_UPDATE = 'ON_TIME_UPDATE';
21 | export const ON_VOLUME_CHANGE = 'ON_VOLUME_CHANGE';
22 | export const PLAY_SONG = 'PLAY_SONG';
23 | export const TOGGLE_FOLLOW = 'TOGGLE_FOLLOW';
24 | export const TOGGLE_LIKE = 'TOGGLE_LIKE';
25 | export const TOGGLE_LIKE_SUCCESS = 'TOGGLE_LIKE_SUCCESS';
26 | export const TOGGLE_REPEAT = 'TOGGLE_REPEAT';
27 | export const TOGGLE_SHOW_HISTORY = 'TOGGLE_SHOW_HISTORY';
28 | export const TOGGLE_SHUFFLE = 'TOGGLE_SHUFFLE';
29 | export const WINDOW_RESIZE = 'WINDOW_RESIZE';
30 |
--------------------------------------------------------------------------------
/client/src/components/Stats.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Heart from '../components/Heart';
4 | import { addCommas } from '../utils/NumberUtils';
5 |
6 | const defaultProps = {
7 | className: '',
8 | };
9 |
10 | const propTypes = {
11 | className: PropTypes.string,
12 | commentCount: PropTypes.number.isRequired,
13 | favoritingsCount: PropTypes.number.isRequired,
14 | id: PropTypes.number.isRequired,
15 | isAuthenticated: PropTypes.bool.isRequired,
16 | liked: PropTypes.bool.isRequired,
17 | login: PropTypes.func.isRequired,
18 | playbackCount: PropTypes.number.isRequired,
19 | toggleLike: PropTypes.func.isRequired,
20 | };
21 |
22 | const Stats = ({
23 | className,
24 | commentCount,
25 | id,
26 | isAuthenticated,
27 | favoritingsCount,
28 | liked,
29 | login,
30 | playbackCount,
31 | toggleLike,
32 | }) => (
33 |
34 |
43 |
44 |
45 |
46 | {addCommas(playbackCount)}
47 |
48 |
49 |
50 |
51 |
52 | {addCommas(commentCount)}
53 |
54 |
55 |
56 | );
57 |
58 | Stats.defaultProps = defaultProps;
59 | Stats.propTypes = propTypes;
60 |
61 | export default Stats;
62 |
--------------------------------------------------------------------------------
/client/src/containers/SongsContainer.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { playSong } from '../actions/PlayerActions';
4 | import { navigateTo } from '../actions/RouterActions';
5 | import { fetchSongsIfNeeded, fetchSongsNext } from '../actions/PlaylistActions';
6 | import { login, toggleLike } from '../actions/SessionActions';
7 | import Songs from '../components/Songs';
8 | import { GENRES, TIMES } from '../constants/PlaylistConstants';
9 | import {
10 | getGenre,
11 | getIsAuthenticated,
12 | getIsMobile,
13 | getIsPlaying,
14 | getLikes,
15 | getPlayingSongId,
16 | getSearch,
17 | getShowLikes,
18 | getShowPlaylist,
19 | getShowStream,
20 | getTime,
21 | } from '../selectors/CommonSelectors';
22 | import getPlaylistData from '../selectors/SongsSelectors';
23 |
24 | const SongsContainer = props => ;
25 |
26 | const mapStateToProps = (state) => {
27 | const { environment } = state;
28 | const { height } = environment;
29 |
30 | return {
31 | ...getPlaylistData(state),
32 | height,
33 | isAuthenticated: getIsAuthenticated(state),
34 | isMobile: getIsMobile(state),
35 | isPlaying: getIsPlaying(state),
36 | likes: getLikes(state),
37 | playingSongId: getPlayingSongId(state),
38 | showLikes: getShowLikes(state),
39 | showPlaylist: getShowPlaylist(state),
40 | showStream: getShowStream(state),
41 | genre: getGenre(state),
42 | genres: GENRES,
43 | search: getSearch(state),
44 | time: getTime(state),
45 | times: TIMES,
46 | };
47 | };
48 |
49 | export default connect(mapStateToProps, {
50 | fetchSongsIfNeeded,
51 | fetchSongsNext,
52 | login,
53 | navigateTo,
54 | playSong,
55 | toggleLike,
56 | })(SongsContainer);
57 |
--------------------------------------------------------------------------------
/client/src/utils/ScrollUtils.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 |
3 | const totalHeightofRows = (rowCount, rowHeight, marginBetweenRows) =>
4 | (rowHeight * rowCount) + (marginBetweenRows * (rowCount - 1));
5 |
6 | const ITEMS_PER_ROW = 5;
7 | const MARGIN_BETWEEN_ROWS = 20;
8 | const NUM_OF_BUFFER_ROWS = 5;
9 | const ROW_HEIGHT = 132;
10 |
11 | const THREE_ROWS_HEIGHT = totalHeightofRows(3, ROW_HEIGHT, MARGIN_BETWEEN_ROWS);
12 | const TWO_ROWS_HEIGHT = totalHeightofRows(2, ROW_HEIGHT, MARGIN_BETWEEN_ROWS);
13 |
14 | const scrollState = (height, count, isMobile) => {
15 | if (isMobile) {
16 | return {
17 | paddingTop: 0,
18 | paddingBottom: 0,
19 | start: 0,
20 | end: count,
21 | };
22 | }
23 |
24 | const scrollY = window.scrollY;
25 |
26 | let paddingTop = 0;
27 | let paddingBottom = 0;
28 | let start = 0;
29 | let end = count;
30 |
31 | const rowWithMarginHeight = ROW_HEIGHT + MARGIN_BETWEEN_ROWS;
32 | const shouldPadTop = scrollY > THREE_ROWS_HEIGHT;
33 |
34 | if (shouldPadTop) {
35 | const rowsToPad = Math.floor((scrollY - TWO_ROWS_HEIGHT) / rowWithMarginHeight);
36 | paddingTop = rowsToPad * rowWithMarginHeight;
37 | start = rowsToPad * ITEMS_PER_ROW;
38 | }
39 |
40 | const rowsOnScreen = Math.ceil(height / rowWithMarginHeight);
41 | const itemsToShow = (rowsOnScreen + NUM_OF_BUFFER_ROWS) * ITEMS_PER_ROW;
42 | const shouldPadBottom = count > (start + itemsToShow);
43 |
44 | if (shouldPadBottom) {
45 | end = start + itemsToShow;
46 | const rowsToPad = Math.ceil((count - end) / ITEMS_PER_ROW);
47 | paddingBottom = rowsToPad * rowWithMarginHeight;
48 | }
49 |
50 | return {
51 | end,
52 | paddingBottom,
53 | paddingTop,
54 | start,
55 | };
56 | };
57 |
58 | export default scrollState;
59 |
--------------------------------------------------------------------------------
/client/src/reducers/session.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 |
3 | const initialState = {
4 | followings: {},
5 | id: null,
6 | likes: {},
7 | oauthToken: null,
8 | newStreamSongs: [],
9 | };
10 |
11 | const session = (state = initialState, action) => {
12 | switch (action.type) {
13 | case types.FETCH_NEW_STREAM_SONGS_SUCCESS:
14 | return {
15 | ...state,
16 | newStreamSongs: [...state.newStreamSongs, ...action.songs],
17 | };
18 |
19 | case types.FETCH_SESSION_FOLLOWINGS_SUCCESS:
20 | return {
21 | ...state,
22 | followings: action.followings,
23 | };
24 |
25 | case types.FETCH_SESSION_LIKES_SUCCESS:
26 | return {
27 | ...state,
28 | likes: action.likes,
29 | };
30 |
31 | case types.FETCH_SESSION_USER_SUCCESS:
32 | return {
33 | ...state,
34 | id: action.id,
35 | };
36 |
37 | case types.LOAD_NEW_STREAM_SONGS:
38 | return {
39 | ...state,
40 | newStreamSongs: [],
41 | };
42 |
43 | case types.LOGIN_SUCCESS:
44 | return {
45 | ...state,
46 | oauthToken: action.oauthToken,
47 | };
48 |
49 | case types.TOGGLE_FOLLOW:
50 | return {
51 | ...state,
52 | followings: {
53 | ...state.followings,
54 | [action.id]: action.follow ? 1 : 0,
55 | },
56 | };
57 |
58 | case types.TOGGLE_LIKE:
59 | return {
60 | ...state,
61 | likes: {
62 | ...state.likes,
63 | [action.id]: action.liked ? 1 : 0,
64 | },
65 | };
66 |
67 | case types.LOGOUT:
68 | return { ...initialState };
69 |
70 | default:
71 | return state;
72 | }
73 | };
74 |
75 | export default session;
76 |
--------------------------------------------------------------------------------
/client/src/components/UserFollowing.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Link from '../components/Link';
4 | import { USER_PATH } from '../constants/RouterConstants';
5 | import { addCommas } from '../utils/NumberUtils';
6 | import getImageUrl from '../utils/ImageUtils';
7 | import { getLocation } from '../utils/UserUtils';
8 |
9 | const propTypes = {
10 | navigateTo: PropTypes.func.isRequired,
11 | following: PropTypes.shape({}).isRequired,
12 | };
13 |
14 | const UserFollowing = ({ following, navigateTo }) => {
15 | const { avatarUrl, followersCount, id, username } = following;
16 |
17 | return (
18 |
19 |
23 |
24 |
30 | {username}
31 |
32 |
33 |
34 |
35 | {getLocation(following)}
36 |
37 |
38 |
39 |
40 |
41 | {addCommas(followersCount)}
42 |
43 |
44 | Followers
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | UserFollowing.propTypes = propTypes;
52 |
53 | export default UserFollowing;
54 |
--------------------------------------------------------------------------------
/client/src/selectors/PlayerSelectors.js:
--------------------------------------------------------------------------------
1 | import { denormalize } from 'normalizr';
2 | import { createSelector } from 'reselect';
3 | import { CLIENT_ID } from '../constants/ApiConstants';
4 | import { songSchema } from '../constants/Schemas';
5 | import { getEntities, getPlayingIndex, getPlayingSongId, getPlaylist, getPlaylists } from '../selectors/CommonSelectors';
6 |
7 | export const getSong = createSelector(
8 | getEntities,
9 | getPlayingSongId,
10 | (entities, playingSongId) => (playingSongId !== null
11 | ? denormalize(playingSongId, songSchema, entities)
12 | : null
13 | ),
14 | );
15 |
16 | export const getAudioUrl = createSelector(
17 | getSong,
18 | song => (song ? `${song.streamUrl}?client_id=${CLIENT_ID}` : ''),
19 | );
20 |
21 | const getPlaylistItemsLength = createSelector(
22 | getPlaylist,
23 | getPlaylists,
24 | (playlist, playlists) => (playlist
25 | ? playlists[playlist].items.length
26 | : 0
27 | ),
28 | );
29 |
30 | export const getNextIndex = createSelector(
31 | getPlaylistItemsLength,
32 | getPlayingIndex,
33 | (playlistItemsLength, playingIndex) => (playingIndex === playlistItemsLength - 1
34 | ? 0
35 | : playingIndex + 1
36 | ),
37 | );
38 |
39 | export const getPrevIndex = createSelector(
40 | getPlaylistItemsLength,
41 | getPlayingIndex,
42 | (playlistItemsLength, playingIndex) => (playingIndex > 0
43 | ? playingIndex - 1
44 | : null
45 | ),
46 | );
47 |
48 | export const getShuffleIndex = (state) => {
49 | const playlistItemsLength = getPlaylistItemsLength(state);
50 | const playingIndex = getPlayingIndex(state);
51 |
52 | const randomIndex = Math.floor((Math.random() * (playlistItemsLength - 1)) + 0);
53 | if (playingIndex === randomIndex) {
54 | return getShuffleIndex(state);
55 | }
56 |
57 | return randomIndex;
58 | };
59 |
--------------------------------------------------------------------------------
/client/src/selectors/SongSelectors.js:
--------------------------------------------------------------------------------
1 | import { denormalize } from 'normalizr';
2 | import { createSelector } from 'reselect';
3 | import { SONG_PLAYLIST_TYPE } from '../constants/PlaylistConstants';
4 | import { songSchema } from '../constants/Schemas';
5 | import { getCurrentTime, getEntities, getId, getPlayingSongId, getPlaylists } from '../selectors/CommonSelectors';
6 |
7 | export const getSong = createSelector(
8 | getEntities,
9 | getId,
10 | (entities, id) => (id in entities.songs ? denormalize(id, songSchema, entities) : null),
11 | );
12 |
13 | export const getIsActive = createSelector(
14 | getPlayingSongId,
15 | getId,
16 | (playingSongId, id) => playingSongId === id,
17 | );
18 |
19 | export const getSongComments = createSelector(
20 | getSong,
21 | song => (song && song.comments
22 | ? song.comments
23 | : []
24 | ),
25 | );
26 |
27 | export const getPlaylist = createSelector(
28 | getId,
29 | id => [SONG_PLAYLIST_TYPE, id].join('|'),
30 | );
31 |
32 | export const getSongs = createSelector(
33 | getPlaylist,
34 | getPlaylists,
35 | getEntities,
36 | (playlist, playlists, entities) => (playlist in playlists
37 | ? denormalize(playlists[playlist].items.slice(1), [songSchema], entities)
38 | : []
39 | ),
40 | );
41 |
42 | export const getTimed = state => Boolean(state.router.route.options.timed) || false;
43 |
44 | export const getComments = createSelector(
45 | getIsActive,
46 | getTimed,
47 | getSongComments,
48 | getCurrentTime,
49 | (isActive, timed, comments, currentTime) => {
50 | if (isActive && timed) {
51 | const start = currentTime - (currentTime % 10);
52 | const end = start + 10;
53 | return comments.filter(({ unixTimestamp }) => unixTimestamp >= start && unixTimestamp < end);
54 | }
55 |
56 | return comments;
57 | },
58 | );
59 |
--------------------------------------------------------------------------------
/client/src/selectors/UserSelectors.js:
--------------------------------------------------------------------------------
1 | import { denormalize } from 'normalizr';
2 | import { createSelector } from 'reselect';
3 | import { USER_PLAYLIST_TYPE } from '../constants/PlaylistConstants';
4 | import { songSchema, userSchema } from '../constants/Schemas';
5 | import { getEntities, getId, getPlaylists, getSessionFollowings } from '../selectors/CommonSelectors';
6 |
7 | export const getPlaylist = createSelector(
8 | getId,
9 | id => [USER_PLAYLIST_TYPE, id].join('|'),
10 | );
11 |
12 | export const getSongs = createSelector(
13 | getPlaylist,
14 | getPlaylists,
15 | getEntities,
16 | (playlist, playlists, entities) => (playlist in playlists
17 | ? denormalize(playlists[playlist].items, [songSchema], entities)
18 | : []
19 | ),
20 | );
21 |
22 | export const getUser = createSelector(
23 | getId,
24 | getEntities,
25 | (id, entities) => (id in entities.users
26 | ? denormalize(id, userSchema, entities)
27 | : null
28 | ),
29 | );
30 |
31 | export const getFollowings = createSelector(
32 | getUser,
33 | getEntities,
34 | (user, entities) => (user && user.followings
35 | ? denormalize(user.followings, [userSchema], entities)
36 | : []
37 | ),
38 | );
39 |
40 | export const getIsFollowing = createSelector(
41 | getId,
42 | getSessionFollowings,
43 | (id, followings) => Boolean(id in followings && followings[id]),
44 | );
45 |
46 | export const getShouldFetchUser = createSelector(
47 | getId,
48 | getEntities,
49 | (id, entities) => {
50 | const { users } = entities;
51 | const userExists = id in users;
52 | const userHasProfiles = userExists ? 'profiles' in users[id] : false;
53 |
54 | return !userExists || !userHasProfiles;
55 | },
56 | );
57 |
58 | export const getProfiles = createSelector(
59 | getUser,
60 | user => (user && user.profiles ? user.profiles : []),
61 | );
62 |
--------------------------------------------------------------------------------
/client/styles/custom/components/song-main.scss:
--------------------------------------------------------------------------------
1 | .song-main {
2 | display: flex;
3 | flex-direction: row;
4 | width: 100%;
5 | height: 144px;
6 | background-color: #fff;
7 | border: 1px solid $lighterGray;
8 | }
9 |
10 | .song-main--active {
11 | border-color: $green;
12 | }
13 |
14 | .song-main__artwork {
15 | display: flex;
16 | align-items: center;;
17 | justify-content: center;
18 | flex-shrink: 0;
19 | width: 140px;
20 | height: 100%;
21 | }
22 |
23 | .song-main__artwork__image {
24 | width: 120px;
25 | height: 120px;
26 | background: no-repeat center center;
27 | background-size: cover;
28 | }
29 |
30 | .song-main__main {
31 | width: 438px;
32 | height: 100%;
33 | margin: 10px;
34 | }
35 |
36 | .song-main__title {
37 | width: 100%;
38 | font-size: 18px;
39 | font-weight: 300;
40 | overflow: hidden;
41 | text-overflow: ellipsis;
42 | white-space: nowrap;
43 | }
44 |
45 | .song-main__user {
46 | display: flex;
47 | flex-direction: row;
48 | align-items: center;
49 | width: 100%;
50 | margin-top: 6px;
51 | overflow: hidden;
52 | }
53 |
54 | .song-main__user__avatar {
55 | width: 24px;
56 | height: 24px;
57 | border-radius: 50%;
58 | background: no-repeat center center;
59 | background-size: cover;
60 |
61 | & + .song-main__user__username {
62 | margin-left: 10px;
63 | }
64 | }
65 |
66 | .song-main__user__username {
67 | flex: 1;
68 | color: $blue;
69 | font-size: 16px;
70 | overflow: hidden;
71 | text-overflow: ellipsis;
72 | }
73 |
74 | .song-main__stats {
75 | margin-top: 10px;
76 | }
77 |
78 | .song-main__description {
79 | height: 30px;
80 | margin-top: 10px;
81 | font-size: 11px;
82 | text-overflow: ellipsis;
83 | overflow: hidden;
84 | }
85 |
86 | .song-main__waveform {
87 | width: 200px;
88 | height: 100%;
89 | border-left: 1px solid $lighterGray;
90 | }
91 |
--------------------------------------------------------------------------------
/client/src/components/NavSearch.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 | import { SONGS_PATH } from '../constants/RouterConstants';
5 |
6 | const propTypes = {
7 | navigateTo: PropTypes.func.isRequired,
8 | };
9 |
10 | class NavSearch extends Component {
11 | constructor(props) {
12 | super(props);
13 | this.onKeyDown = this.onKeyDown.bind(this);
14 | this.onKeyPress = this.onKeyPress.bind(this);
15 | this.input = null;
16 | }
17 |
18 | componentDidMount() {
19 | document.addEventListener('keydown', this.onKeyDown, false);
20 | }
21 |
22 | componentWillUnmount() {
23 | document.removeEventListener('keydown', this.onKeyDown, false);
24 | }
25 |
26 | onKeyDown(e) {
27 | if (e.keyCode === 191) {
28 | const insideInput = e.target.tagName.toLowerCase().match(/input|textarea/);
29 | if (!insideInput) {
30 | e.preventDefault();
31 | this.input.focus();
32 | }
33 | }
34 | }
35 |
36 | onKeyPress(e) {
37 | if (e.charCode === 13) {
38 | const { navigateTo } = this.props;
39 | const value = e.currentTarget.value.trim();
40 | if (value !== '') {
41 | navigateTo({
42 | keys: {},
43 | path: SONGS_PATH,
44 | options: { q: value },
45 | });
46 | }
47 | }
48 | }
49 |
50 | render() {
51 | return (
52 |
53 |
54 | { this.input = node; }}
56 | className="nav-search__input"
57 | placeholder="SEARCH"
58 | onKeyPress={this.onKeyPress}
59 | type="text"
60 | />
61 |
62 | );
63 | }
64 | }
65 |
66 | NavSearch.propTypes = propTypes;
67 |
68 | export default NavSearch;
69 |
--------------------------------------------------------------------------------
/client/src/components/SongList.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import SongListItem from '../components/SongListItem';
4 |
5 | const defaultProps = {
6 | className: '',
7 | offsetIndex: 0,
8 | id: null,
9 | playingSongId: null,
10 | };
11 |
12 | const propTypes = {
13 | className: PropTypes.string,
14 | id: PropTypes.number,
15 | isAuthenticated: PropTypes.bool.isRequired,
16 | likes: PropTypes.shape({}).isRequired,
17 | offsetIndex: PropTypes.number,
18 | login: PropTypes.func.isRequired,
19 | navigateTo: PropTypes.func.isRequired,
20 | player: PropTypes.shape({}).isRequired,
21 | playingSongId: PropTypes.number,
22 | playlist: PropTypes.string.isRequired,
23 | playSong: PropTypes.func.isRequired,
24 | songs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
25 | toggleLike: PropTypes.func.isRequired,
26 | };
27 |
28 | const SongList = ({
29 | className,
30 | id,
31 | isAuthenticated,
32 | likes,
33 | login,
34 | navigateTo,
35 | offsetIndex,
36 | player,
37 | playingSongId,
38 | playlist,
39 | playSong,
40 | songs,
41 | toggleLike,
42 | }) => (
43 |
44 | {songs.map((song, i) => (song.id !== id
45 | ? (
46 |
60 | ) : null
61 | ))}
62 |
63 | );
64 |
65 | SongList.defaultProps = defaultProps;
66 | SongList.propTypes = propTypes;
67 |
68 | export default SongList;
69 |
--------------------------------------------------------------------------------
/client/src/actions/SongActions.js:
--------------------------------------------------------------------------------
1 | import { normalize } from 'normalizr';
2 | import { fetchSongs, fetchSongsSuccess } from '../actions/PlaylistActions';
3 | import * as types from '../constants/ActionTypes';
4 | import { SONG_URL, SONG_COMMENTS_URL, USER_SONGS_URL } from '../constants/ApiConstants';
5 | import { songSchema } from '../constants/Schemas';
6 | import { callApi } from '../utils/ApiUtils';
7 |
8 | const fetchSongCommentsSuccess = (id, comments) => ({
9 | type: types.FETCH_SONG_COMMENTS_SUCCESS,
10 | entities: {
11 | songs: {
12 | [id]: { comments },
13 | },
14 | },
15 | });
16 |
17 | const fetchSongComments = id => async (dispatch) => {
18 | const { json } = await callApi(SONG_COMMENTS_URL.replace(':id', id));
19 | const comments = json
20 | .map(comment => ({
21 | ...comment,
22 | unixTimestamp: Math.floor(comment.timestamp / 1000),
23 | }))
24 | .sort((a, b) => a.timestamp - b.timestamp);
25 |
26 | dispatch(fetchSongCommentsSuccess(id, comments));
27 | };
28 |
29 | const fetchSong = (id, playlist) => async (dispatch) => {
30 | const { json } = await callApi(SONG_URL.replace(':id', id));
31 | const { userId } = json;
32 |
33 | const { entities, result } = normalize(json, songSchema);
34 | dispatch(fetchSongsSuccess(playlist, [result], entities, null, null));
35 | dispatch(fetchSongComments(id));
36 | dispatch(fetchSongs(playlist, USER_SONGS_URL.replace(':id', userId)));
37 | };
38 |
39 | const shouldFetchSong = (id, state) => {
40 | const { entities } = state;
41 | const { songs } = entities;
42 | const songExists = id in songs;
43 | const songHasComments = songExists ? 'comments' in songs[id] : false;
44 |
45 | return !songExists || !songHasComments;
46 | };
47 |
48 | const fetchSongIfNeeded = (id, playlist) => (dispatch, getState) => {
49 | if (shouldFetchSong(id, getState())) {
50 | dispatch(fetchSong(id, playlist));
51 | }
52 | };
53 |
54 | export default fetchSongIfNeeded;
55 |
--------------------------------------------------------------------------------
/client/styles/custom/components/user-main.scss:
--------------------------------------------------------------------------------
1 | .user-main {
2 | display: flex;
3 | flex-direction: row;
4 | width: 100%;
5 | height: 144px;
6 | background-color: #fff;
7 | border: 1px solid $lighterGray;
8 | }
9 |
10 | .user-main__avatar {
11 | display: flex;
12 | align-items: center;;
13 | justify-content: center;
14 | flex-shrink: 0;
15 | width: 140px;
16 | height: 100%;
17 | margin-right: 10px;
18 | }
19 |
20 | .user-main__avatar__image {
21 | width: 120px;
22 | height: 120px;
23 | background: no-repeat center center;
24 | background-size: cover;
25 | border-radius: 50%;
26 | }
27 |
28 | .user-main__main {
29 | flex: 1;
30 | overflow: hidden;
31 | margin-right: 10px;
32 | }
33 |
34 | .user-main__title {
35 | display: flex;
36 | flex-direction: row;
37 | align-items: center;
38 | margin-top: 10px;
39 | }
40 |
41 | .user-main__username {
42 | flex: 1;
43 | font-size: 18px;
44 | font-weight: 300;
45 | overflow: hidden;
46 | text-overflow: ellipsis;
47 | white-space: nowrap;
48 | }
49 |
50 | .user-main__location {
51 | display: flex;
52 | flex-direction: row;
53 | color: $gray;
54 | font-size: 18px;
55 | }
56 |
57 | .user-main__location__icon {
58 | margin-right: 10px;
59 | }
60 |
61 | .user-main__meta {
62 | display: flex;
63 | flex-direction: row;
64 | margin-top: 10px;
65 | font-size: 11px;
66 | overflow: hidden;
67 | text-overflow: ellipsis;
68 | white-space: nowrap;
69 | }
70 |
71 | .user-main__followings {
72 | display: flex;
73 | flex-direction: row;
74 | }
75 |
76 | .user-main__followings__count {
77 | margin-right: 10px;
78 | }
79 |
80 | .user-main__profile {
81 | display: flex;
82 | flex-direction: row;
83 | margin-left: 20px;
84 | }
85 |
86 | .user-main__profile__icon {
87 | margin-right: 10px;
88 | color: $gray;
89 | }
90 |
91 | .user-main__description {
92 | margin-top: 10px;
93 | max-height: 30px;
94 | font-size: 11px;
95 | overflow: hidden;
96 | text-overflow: ellipsis;
97 | }
98 |
--------------------------------------------------------------------------------
/client/src/components/SongsHeader.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import SongsHeaderGenres from '../components/SongsHeaderGenres';
4 | import SongsHeaderTimes from '../components/SongsHeaderTimes';
5 |
6 | const propTypes = {
7 | genre: PropTypes.string.isRequired,
8 | genres: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
9 | navigateTo: PropTypes.func.isRequired,
10 | search: PropTypes.string.isRequired,
11 | showLikes: PropTypes.bool.isRequired,
12 | showPlaylist: PropTypes.bool.isRequired,
13 | showStream: PropTypes.bool.isRequired,
14 | sticky: PropTypes.bool.isRequired,
15 | time: PropTypes.string.isRequired,
16 | times: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
17 | };
18 |
19 | const SongsHeader = ({
20 | genre,
21 | genres,
22 | navigateTo,
23 | search,
24 | showLikes,
25 | showPlaylist,
26 | showStream,
27 | sticky,
28 | time,
29 | times,
30 | }) => {
31 | if (showLikes || showStream || showPlaylist) {
32 | return null;
33 | }
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
46 |
47 |
48 |
55 |
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | SongsHeader.propTypes = propTypes;
63 |
64 | export default SongsHeader;
65 |
--------------------------------------------------------------------------------
/client/src/components/Heart.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import HeartCount from '../components/HeartCount';
4 | import LoginPopoverPanel from '../components/LoginPopoverPanel';
5 | import Popover from '../components/Popover';
6 |
7 | const defaultProps = {
8 | className: '',
9 | favoritingsCount: null,
10 | };
11 |
12 | const propTypes = {
13 | className: PropTypes.string,
14 | favoritingsCount: PropTypes.number,
15 | id: PropTypes.number.isRequired,
16 | isAuthenticated: PropTypes.bool.isRequired,
17 | liked: PropTypes.bool.isRequired,
18 | login: PropTypes.func.isRequired,
19 | toggleLike: PropTypes.func.isRequired,
20 | };
21 |
22 | class Heart extends Component {
23 | constructor() {
24 | super();
25 | this.onClick = this.onClick.bind(this);
26 | }
27 |
28 | onClick() {
29 | const { id, liked, toggleLike } = this.props;
30 | toggleLike(id, !liked);
31 | }
32 |
33 | render() {
34 | const { className, favoritingsCount, isAuthenticated, liked, login } = this.props;
35 | if (!isAuthenticated) {
36 | return (
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | );
45 | }
46 |
47 | return (
48 |
59 | );
60 | }
61 | }
62 |
63 | Heart.defaultProps = defaultProps;
64 | Heart.propTypes = propTypes;
65 |
66 | export default Heart;
67 |
--------------------------------------------------------------------------------
/client/src/containers/PlayerContainer.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import { connect } from 'react-redux';
4 | import {
5 | onLoadedMetadata,
6 | onLoadStart,
7 | onPause,
8 | onPlay,
9 | onTimeUpdate,
10 | onVolumeChange,
11 | playNextSong,
12 | playNextSongFromButton,
13 | playPrevSong,
14 | playSong,
15 | toggleRepeat,
16 | toggleShuffle,
17 | } from '../actions/PlayerActions';
18 | import { navigateTo } from '../actions/RouterActions';
19 | import toggleShowHistory from '../actions/HistoryActions';
20 | import Player from '../components/Player';
21 | import { getPlayingSongId, getPlaylists } from '../selectors/CommonSelectors';
22 | import { getAudioUrl, getNextIndex, getSong } from '../selectors/PlayerSelectors';
23 | import { getShowHistory } from '../selectors/HistorySelectors';
24 |
25 | const defaultProps = {
26 | song: null,
27 | };
28 |
29 | const propTypes = {
30 | song: PropTypes.shape({}),
31 | };
32 |
33 | const PlayerContainer = (props) => {
34 | const { song } = props;
35 | return song ? : null;
36 | };
37 |
38 | PlayerContainer.defaultProps = defaultProps;
39 | PlayerContainer.propTypes = propTypes;
40 |
41 | const mapStateToProps = (state) => {
42 | const { entities, player } = state;
43 | const { songs, users } = entities;
44 |
45 | return {
46 | audioUrl: getAudioUrl(state),
47 | nextIndex: getNextIndex(state),
48 | player,
49 | playingSongId: getPlayingSongId(state),
50 | playlists: getPlaylists(state),
51 | showHistory: getShowHistory(state),
52 | song: getSong(state),
53 | songs,
54 | users,
55 | };
56 | };
57 |
58 | export default connect(mapStateToProps, {
59 | navigateTo,
60 | onLoadedMetadata,
61 | onLoadStart,
62 | onPause,
63 | onPlay,
64 | onTimeUpdate,
65 | onVolumeChange,
66 | playNextSong,
67 | playNextSongFromButton,
68 | playPrevSong,
69 | playSong,
70 | toggleRepeat,
71 | toggleShowHistory,
72 | toggleShuffle,
73 | })(PlayerContainer);
74 |
--------------------------------------------------------------------------------
/client/src/components/SongsHeaderGenres.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import Link from '../components/Link';
4 | import { SONGS_PATH } from '../constants/RouterConstants';
5 |
6 | const propTypes = {
7 | genre: PropTypes.string.isRequired,
8 | genres: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
9 | navigateTo: PropTypes.func.isRequired,
10 | time: PropTypes.string.isRequired,
11 | };
12 |
13 | class SongsHeaderGenres extends Component {
14 | constructor() {
15 | super();
16 | this.onClick = this.onClick.bind(this);
17 | this.state = { expanded: false };
18 | }
19 |
20 | onClick() {
21 | this.setState(state => ({
22 | expanded: !state.expanded,
23 | }));
24 | }
25 |
26 | render() {
27 | const { expanded } = this.state;
28 | const { genre, genres, navigateTo, time } = this.props;
29 |
30 | return (
31 |
32 |
38 | {genre || 'genre'}
39 |
40 |
41 | {genres.map(g => (
42 |
43 |
53 | {g.key}
54 |
55 |
56 | ))}
57 |
58 |
59 | );
60 | }
61 | }
62 |
63 | SongsHeaderGenres.propTypes = propTypes;
64 |
65 | export default SongsHeaderGenres;
66 |
--------------------------------------------------------------------------------
/client/styles/custom/components/song-list.scss:
--------------------------------------------------------------------------------
1 | .song-list__item {
2 | display: flex;
3 | flex-direction: row;
4 | align-items: center;
5 | height: 80px;
6 | background-color: $white;
7 | border-left: 1px solid $lighterGray;
8 | border-right: 1px solid $lighterGray;
9 | border-top: 1px solid $lighterGray;
10 |
11 | &:last-child {
12 | border-top: 1px solid $lighterGray;
13 | }
14 | }
15 |
16 | .song-list__item__artwork {
17 | display: flex;
18 | align-items: center;
19 | justify-content: center;
20 | width: 80px;
21 | margin-right: 10px;
22 | }
23 |
24 | .song-list__item__artwork__image {
25 | width: 60px;
26 | height: 60px;
27 | background: no-repeat center center;
28 | background-size: cover;
29 | }
30 |
31 | .song-list__item__main {
32 | width: 500px;
33 | }
34 |
35 | .song-list__item__title {
36 | display: block;
37 | color: $black;
38 | font-size: 14px;
39 | font-weight: 300;
40 | white-space: nowrap;
41 | overflow: hidden;
42 | text-overflow: ellipsis;
43 | }
44 |
45 | .song-list__item__meta {
46 | display: flex;
47 | flex-direction: row;
48 | align-items: center;
49 | margin-top: 10px;
50 | }
51 |
52 | .song-list__item__user {
53 | display: flex;
54 | flex-direction: row;
55 | align-items: center;
56 | overflow: hidden;
57 | text-overflow: ellipsis;
58 | }
59 |
60 | .song-list__item__user__avatar {
61 | width: 24px;
62 | height: 24px;
63 | margin-right: 10px;
64 | background: no-repeat center center;
65 | background-size: cover;
66 | border-radius: 50%;
67 | }
68 |
69 | .song-list__item__stats {
70 | display: flex;
71 | flex-direction: row;
72 | flex-shrink: 0;
73 | margin-left: 20px;
74 | }
75 |
76 | .song-list__item__stat {
77 | color: $gray;
78 | font-size: 11px;
79 |
80 | & + .song-list__item__stat {
81 | margin-left: 20px;
82 | }
83 | }
84 |
85 | .song-list__item__stat__text {
86 | margin-left: 10px;
87 | }
88 |
89 | .song-list__item__waveform {
90 | width: 200px;
91 | height: 100%;
92 | margin-left: 10px;
93 | border-left: 1px solid $lighterGray;
94 | }
95 |
--------------------------------------------------------------------------------
/client/src/actions/UserActions.js:
--------------------------------------------------------------------------------
1 | import { normalize } from 'normalizr';
2 | import { fetchSongs } from '../actions/PlaylistActions';
3 | import * as types from '../constants/ActionTypes';
4 | import { USER_FOLLOWINGS_URL, USER_PROFILES_URL, USER_SONGS_URL, USER_URL } from '../constants/ApiConstants';
5 | import { userSchema } from '../constants/Schemas';
6 | import { callApi } from '../utils/ApiUtils';
7 |
8 | const fetchUserFollowingsSuccess = entities => ({
9 | type: types.FETCH_USER_FOLLOWINGS_SUCCESS,
10 | entities,
11 | });
12 |
13 | const fetchUserFollowings = id => async (dispatch) => {
14 | const { json } = await callApi(USER_FOLLOWINGS_URL.replace(':id', id));
15 | const { collection } = json;
16 | const { entities, result } = normalize(collection, [userSchema]);
17 |
18 | dispatch(fetchUserFollowingsSuccess({
19 | users: {
20 | ...entities.users,
21 | [id]: { followings: result },
22 | },
23 | }));
24 | };
25 |
26 | const fetchUserProfilesSuccess = (id, profiles) => ({
27 | type: types.FETCH_USER_PROFILES_SUCCESS,
28 | entities: {
29 | users: {
30 | [id]: { profiles },
31 | },
32 | },
33 | });
34 |
35 | const fetchUserProfiles = id => async (dispatch) => {
36 | const { json } = await callApi(USER_PROFILES_URL.replace(':id', id));
37 | dispatch(fetchUserProfilesSuccess(id, json.slice(0, 6)));
38 | };
39 |
40 | const fetchUserSuccess = entities => ({
41 | type: types.FETCH_USER_SUCCESS,
42 | entities,
43 | });
44 |
45 | const fetchUser = (id, playlist) => async (dispatch) => {
46 | const { json } = await callApi(USER_URL.replace(':id', id));
47 | const { entities } = normalize(json, userSchema);
48 | dispatch(fetchUserSuccess(entities));
49 |
50 | dispatch(fetchSongs(playlist, USER_SONGS_URL.replace(':id', id)));
51 | dispatch(fetchUserFollowings(id));
52 | dispatch(fetchUserProfiles(id));
53 | };
54 |
55 | const fetchUserIfNeeded = (shouldFetchUser, id, playlist) => (dispatch) => {
56 | if (shouldFetchUser) {
57 | dispatch(fetchUser(id, playlist));
58 | }
59 | };
60 |
61 | export default fetchUserIfNeeded;
62 |
--------------------------------------------------------------------------------
/client/src/actions/PlayerActions.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 | import { getPlaylist, getRepeat, getShuffle } from '../selectors/CommonSelectors';
3 | import { getNextIndex, getPrevIndex, getShuffleIndex } from '../selectors/PlayerSelectors';
4 |
5 | export const onLoadedMetadata = duration => ({
6 | type: types.ON_LOADED_METADATA,
7 | duration,
8 | });
9 |
10 | export const onLoadStart = () => ({
11 | type: types.ON_LOAD_START,
12 | });
13 |
14 | export const onPause = () => ({
15 | type: types.ON_PAUSE,
16 | });
17 |
18 | export const onPlay = () => ({
19 | type: types.ON_PLAY,
20 | });
21 |
22 | export const onTimeUpdate = currentTime => ({
23 | type: types.ON_TIME_UPDATE,
24 | currentTime,
25 | });
26 |
27 | export const onVolumeChange = (muted, volume) => ({
28 | type: types.ON_VOLUME_CHANGE,
29 | muted,
30 | volume,
31 | });
32 |
33 | export const playSong = (playlist, playingIndex) => ({
34 | type: types.PLAY_SONG,
35 | playlist,
36 | playingIndex,
37 | });
38 |
39 | export const playPrevSong = () => (dispatch, getState) => {
40 | const state = getState();
41 | const playlist = getPlaylist(state);
42 | const prevIndex = getPrevIndex(state);
43 |
44 | if (prevIndex !== null) {
45 | dispatch(playSong(playlist, prevIndex));
46 | }
47 | };
48 |
49 | export const playNextSong = (fromButtonPress = false) => (dispatch, getState) => {
50 | const state = getState();
51 | const nextIndex = getNextIndex(state);
52 | const playlist = getPlaylist(state);
53 | const repeat = getRepeat(state);
54 | const shuffle = getShuffle(state);
55 |
56 | if (shuffle) {
57 | const shuffleIndex = getShuffleIndex(state);
58 | dispatch(playSong(playlist, shuffleIndex));
59 | } else if (nextIndex !== 0 || repeat || fromButtonPress) {
60 | dispatch(playSong(playlist, nextIndex));
61 | }
62 | };
63 |
64 | export const playNextSongFromButton = () => dispatch => dispatch(playNextSong(true));
65 |
66 | export const toggleRepeat = () => ({ type: types.TOGGLE_REPEAT });
67 |
68 | export const toggleShuffle = () => ({ type: types.TOGGLE_SHUFFLE });
69 |
--------------------------------------------------------------------------------
/client/src/components/NavStream.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import Link from '../components/Link';
4 | import { SONGS_PATH } from '../constants/RouterConstants';
5 |
6 | const propTypes = {
7 | fetchNewStreamSongs: PropTypes.func.isRequired,
8 | loadNewStreamSongs: PropTypes.func.isRequired,
9 | navigateTo: PropTypes.func.isRequired,
10 | newStreamSongs: PropTypes.arrayOf(PropTypes.number).isRequired,
11 | showStream: PropTypes.bool.isRequired,
12 | streamFutureUrl: PropTypes.string.isRequired,
13 | };
14 |
15 | class NavStream extends Component {
16 | constructor() {
17 | super();
18 | this.interval = null;
19 | this.onClick = this.onClick.bind(this);
20 | }
21 |
22 | componentWillMount() {
23 | this.interval = setInterval(() => {
24 | const { fetchNewStreamSongs, streamFutureUrl } = this.props;
25 | if (streamFutureUrl) {
26 | fetchNewStreamSongs(streamFutureUrl);
27 | }
28 | }, 60000);
29 | }
30 |
31 | componentWillUnmount() {
32 | clearInterval(this.interval);
33 | this.interval = null;
34 | }
35 |
36 | onClick() {
37 | const { loadNewStreamSongs, newStreamSongs } = this.props;
38 | loadNewStreamSongs(newStreamSongs);
39 | }
40 |
41 | render() {
42 | const { navigateTo, newStreamSongs, showStream } = this.props;
43 | const newStreamSongsCount = newStreamSongs.length;
44 |
45 | return (
46 |
53 | Stream
54 | {newStreamSongsCount
55 | ? (
56 |
57 |
58 | {newStreamSongsCount}
59 |
60 |
61 | ) : null}
62 |
63 | );
64 | }
65 | }
66 |
67 | NavStream.propTypes = propTypes;
68 |
69 | export default NavStream;
70 |
--------------------------------------------------------------------------------
/client/styles/custom/components/waveform.scss:
--------------------------------------------------------------------------------
1 | .waveform {
2 | position: relative;
3 |
4 | &:hover {
5 | cursor: pointer;
6 |
7 | .waveform__hover-bg,
8 | .waveform__hover-icon {
9 | opacity: 1;
10 | }
11 | }
12 | }
13 |
14 | .waveform--active {
15 | &:hover {
16 | .waveform__seek-bg,
17 | .waveform__seek-line {
18 | display: block;
19 | }
20 | }
21 |
22 | .waveform__hover-bg,
23 | .waveform__hover-icon {
24 | display: none;
25 | }
26 | }
27 |
28 | .waveform__image {
29 | position: relative;
30 | width: 100%;
31 | height: 100%;
32 | background: no-repeat center center;
33 | background-size: 100% 100%;
34 | outline: 0;
35 | z-index: 2;
36 | }
37 |
38 | .waveform__bg {
39 | position: absolute;
40 | top: 0;
41 | left: 0;
42 | height: 100%;
43 | background-color: $lightGreen;
44 | z-index: 0;
45 | }
46 |
47 | .waveform__seek-bg {
48 | display: none;
49 | position: absolute;
50 | top: 0;
51 | left: 0;
52 | height: 100%;
53 | background-color: rgba(0, 0, 0, 0.2);
54 | z-index: 1;
55 | }
56 |
57 | .waveform__seek-line {
58 | display: none;
59 | position: absolute;
60 | top: 0;
61 | left: 0;
62 | height: 100%;
63 | border-right: 1px solid $black;
64 | z-index: 3;
65 | }
66 |
67 | .waveform__hover-bg,
68 | .waveform__hover-icon {
69 | position: absolute;
70 | top: 0;
71 | left: 0;
72 | width: 100%;
73 | height: 100%;
74 | opacity: 0;
75 | transition: opacity 200ms ease-in-out;
76 | }
77 |
78 | .waveform__hover-bg {
79 | background-color: rgba(0, 0, 0, 0.4);
80 | z-index: 0;
81 | }
82 |
83 | .waveform__hover-icon {
84 | display: flex;
85 | align-items: center;
86 | justify-content: center;
87 | font-size: 24px;
88 | color: $green;
89 | z-index: 4;
90 | }
91 |
92 | .waveform__hover__icon {
93 | font-size: 18px;
94 | color: $green;
95 | }
96 |
97 | .waveform__events {
98 | position: absolute;
99 | top: 0;
100 | left: 0;
101 | width: 100%;
102 | height: 100%;
103 | outline: 0;
104 | z-index: 5;
105 | }
106 |
--------------------------------------------------------------------------------
/client/src/reducers/player.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 | import { SESSION_STREAM_PLAYLIST } from '../constants/PlaylistConstants';
3 |
4 | const initialState = {
5 | currentTime: 0,
6 | duration: 0,
7 | isPlaying: false,
8 | muted: false,
9 | repeat: false,
10 | shuffle: false,
11 | volume: 1,
12 | playingIndex: null,
13 | playlist: null,
14 | };
15 |
16 | const player = (state = initialState, action) => {
17 | switch (action.type) {
18 | case types.LOAD_NEW_STREAM_SONGS:
19 | return {
20 | ...state,
21 | playingIndex:
22 | state.playlist === SESSION_STREAM_PLAYLIST
23 | && state.playingIndex !== null
24 | ? state.playingIndex + action.newStreamSongs.length
25 | : state.playingIndex,
26 | };
27 |
28 | case types.ON_LOAD_START:
29 | return {
30 | ...state,
31 | currentTime: 0,
32 | duration: 0,
33 | };
34 |
35 | case types.ON_LOADED_METADATA:
36 | return {
37 | ...state,
38 | duration: action.duration,
39 | };
40 |
41 | case types.ON_PAUSE:
42 | return {
43 | ...state,
44 | isPlaying: false,
45 | };
46 |
47 | case types.ON_PLAY:
48 | return {
49 | ...state,
50 | isPlaying: true,
51 | };
52 |
53 | case types.ON_TIME_UPDATE:
54 | return {
55 | ...state,
56 | currentTime: action.currentTime,
57 | };
58 |
59 | case types.ON_VOLUME_CHANGE:
60 | return {
61 | ...state,
62 | muted: action.muted,
63 | volume: action.volume,
64 | };
65 |
66 | case types.PLAY_SONG:
67 | return {
68 | ...state,
69 | playingIndex: action.playingIndex,
70 | playlist: action.playlist,
71 | };
72 |
73 | case types.TOGGLE_REPEAT:
74 | return { ...state, repeat: !state.repeat };
75 |
76 | case types.TOGGLE_SHUFFLE:
77 | return { ...state, shuffle: !state.shuffle };
78 |
79 | case types.LOGOUT:
80 | return { ...initialState };
81 |
82 | default:
83 | return state;
84 | }
85 | };
86 |
87 | export default player;
88 |
--------------------------------------------------------------------------------
/client/src/components/HistorySong.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import ArtworkPlay from '../components/ArtworkPlay';
4 | import Link from '../components/Link';
5 | import { SONG_PATH, USER_PATH } from '../constants/RouterConstants';
6 | import getImageUrl from '../utils/ImageUtils';
7 |
8 | const propTypes = {
9 | index: PropTypes.number.isRequired,
10 | isActive: PropTypes.bool.isRequired,
11 | isPlaying: PropTypes.bool.isRequired,
12 | navigateTo: PropTypes.func.isRequired,
13 | playlist: PropTypes.string.isRequired,
14 | playSong: PropTypes.func.isRequired,
15 | song: PropTypes.shape({}).isRequired,
16 | };
17 |
18 | class HistorySong extends Component {
19 | render() {
20 | const { index, isActive, isPlaying, navigateTo, playlist, playSong, song } = this.props;
21 | const { artworkUrl, id, title, user } = song;
22 | const { username } = user;
23 |
24 | return (
25 |
31 |
43 |
44 |
50 | {title}
51 |
52 |
58 | {username}
59 |
60 |
61 |
62 | );
63 | }
64 | }
65 |
66 | HistorySong.propTypes = propTypes;
67 |
68 | export default HistorySong;
69 |
--------------------------------------------------------------------------------
/client/src/components/History.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import HistorySong from '../components/HistorySong';
4 |
5 | const defaultProps = {
6 | playingSongId: null,
7 | };
8 |
9 | const propTypes = {
10 | isPlaying: PropTypes.bool.isRequired,
11 | navigateTo: PropTypes.func.isRequired,
12 | playlist: PropTypes.string.isRequired,
13 | playingSongId: PropTypes.number,
14 | playSong: PropTypes.func.isRequired,
15 | showHistory: PropTypes.bool.isRequired,
16 | songs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
17 | toggleShowHistory: PropTypes.func.isRequired,
18 | };
19 |
20 | const History = ({
21 | isPlaying,
22 | navigateTo,
23 | playingSongId,
24 | playlist,
25 | playSong,
26 | showHistory,
27 | songs,
28 | toggleShowHistory,
29 | }) => {
30 | if (!showHistory) {
31 | return null;
32 | }
33 |
34 | return (
35 |
36 |
42 |
43 |
44 |
45 | Recently Played
46 |
47 |
53 |
54 |
55 |
56 |
57 | {songs.map((song, i) => (
58 |
68 | ))}
69 |
70 |
71 |
72 | );
73 | };
74 |
75 | History.defaultProps = defaultProps;
76 | History.propTypes = propTypes;
77 |
78 | export default History;
79 |
--------------------------------------------------------------------------------
/client/styles/custom/components/history.scss:
--------------------------------------------------------------------------------
1 | .history {
2 | position: fixed;
3 | top: 0;
4 | left: 0;
5 | width: 100%;
6 | height: 100%;
7 | z-index: 10;
8 | }
9 |
10 | .history__bg {
11 | width: 100%;
12 | height: 100%;
13 | background-color: rgba(0, 0, 0, 0.8);
14 | outline: 0;
15 |
16 | &:hover {
17 | cursor: pointer;
18 | }
19 | }
20 |
21 | .history__main {
22 | position: absolute;
23 | display: flex;
24 | flex-direction: column;
25 | top: 0;
26 | right: 0;
27 | width: 350px;
28 | height: 100%;
29 | background-color: $white;
30 | border-left: 1px solid $black;
31 | }
32 |
33 | .history__header {
34 | display: flex;
35 | flex-direction: row;
36 | align-items: center;
37 | background-color: $darkerGray;
38 | height: 50px;
39 | }
40 |
41 | .history__header__title {
42 | flex: 1;
43 | padding: 0 20px;
44 | color: $white;
45 | font-size: 11px;
46 |
47 | & + .history__header__button {
48 | border-left: 1px solid $lightBlack;
49 | }
50 | }
51 |
52 | .history__header__button {
53 | display: flex;
54 | align-items: center;
55 | justify-content: center;
56 | width: 50px;
57 | height: 100%;
58 | color: $gray;
59 | outline: 0;
60 |
61 | &:hover {
62 | color: $white;
63 | cursor: pointer;
64 | }
65 | }
66 |
67 | .history__body {
68 | flex: 1;
69 | overflow: scroll;
70 | }
71 |
72 | .history__song {
73 | display: flex;
74 | flex-direction: row;
75 | align-items: center;
76 | width: 350px;
77 | padding: 8px;
78 | outline: 0;
79 | border-bottom: 1px solid $lighterGray;
80 | }
81 |
82 | .history__song__artwork {
83 | flex-shrink: 0;
84 | width: 36px;
85 | height: 36px;
86 | background: no-repeat center center;
87 | background-size: cover;
88 | }
89 |
90 | .history__song__main {
91 | flex: 1;
92 | margin: 0 8px;
93 | overflow: hidden;
94 | }
95 |
96 | .history__song__title {
97 | display: block;
98 | color: $black;
99 | font-size: 11px;
100 | overflow: hidden;
101 | text-overflow: ellipsis;
102 | white-space: nowrap;
103 | }
104 |
105 | .history__song__username {
106 | font-size: 11px;
107 | color: $blue;
108 | }
109 |
--------------------------------------------------------------------------------
/client/src/components/NavSession.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Link from '../components/Link';
4 | import NavPlaylists from '../components/NavPlaylists';
5 | import NavStream from '../components/NavStream';
6 | import { SONGS_PATH } from '../constants/RouterConstants';
7 |
8 | const defaultProps = {
9 | navPlaylist: null,
10 | };
11 |
12 | const propTypes = {
13 | fetchNewStreamSongs: PropTypes.func.isRequired,
14 | isAuthenticated: PropTypes.bool.isRequired,
15 | loadNewStreamSongs: PropTypes.func.isRequired,
16 | navigateTo: PropTypes.func.isRequired,
17 | navPlaylist: PropTypes.shape({}),
18 | navPlaylists: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
19 | newStreamSongs: PropTypes.arrayOf(PropTypes.number).isRequired,
20 | showLikes: PropTypes.bool.isRequired,
21 | showPlaylist: PropTypes.bool.isRequired,
22 | showStream: PropTypes.bool.isRequired,
23 | streamFutureUrl: PropTypes.string.isRequired,
24 | };
25 |
26 | const NavSession = ({
27 | fetchNewStreamSongs,
28 | isAuthenticated,
29 | loadNewStreamSongs,
30 | navigateTo,
31 | navPlaylist,
32 | navPlaylists,
33 | newStreamSongs,
34 | showLikes,
35 | showPlaylist,
36 | showStream,
37 | streamFutureUrl,
38 | }) => {
39 | if (!isAuthenticated) {
40 | return null;
41 | }
42 |
43 | return (
44 |
45 |
53 |
59 | Likes
60 |
61 |
67 |
68 | );
69 | };
70 |
71 | NavSession.defaultProps = defaultProps;
72 | NavSession.propTypes = propTypes;
73 |
74 | export default NavSession;
75 |
--------------------------------------------------------------------------------
/client/webpack.prod.config.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require('autoprefixer');
2 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
3 | const HtmlWebpackPlugin = require('html-webpack-plugin');
4 | const OfflinePlugin = require('offline-plugin');
5 | const path = require('path');
6 | const webpack = require('webpack');
7 |
8 | module.exports = {
9 | context: path.resolve('client/src/'),
10 | entry: {
11 | main: './index.jsx',
12 | vendor: [
13 | 'babel-polyfill',
14 | 'camelize',
15 | 'isomorphic-fetch',
16 | 'js-cookie',
17 | 'lodash.merge',
18 | 'moment',
19 | 'normalizr',
20 | 'offline-plugin/runtime',
21 | 'path-to-regexp',
22 | 'prop-types',
23 | 'react',
24 | 'react-dom',
25 | 'react-redux',
26 | 'redux',
27 | 'redux-thunk',
28 | 'reselect',
29 | 'soundcloud',
30 | ],
31 | },
32 | output: {
33 | filename: 'js/[name].js',
34 | path: path.resolve('dist/public/'),
35 | },
36 | resolve: {
37 | extensions: ['.js', '.jsx'],
38 | },
39 | module: {
40 | loaders: [
41 | {
42 | test: /\.(js|jsx)$/,
43 | loaders: ['babel-loader'],
44 | exclude: /node_modules/,
45 | },
46 | {
47 | test: /\.scss$/,
48 | loader: ExtractTextPlugin.extract({
49 | fallback: 'style-loader',
50 | use: [
51 | { loader: 'css-loader' },
52 | {
53 | loader: 'postcss-loader',
54 | options: {
55 | plugins: () => [autoprefixer({ browsers: ['> 1%', 'IE >= 10'] })],
56 | },
57 | },
58 | { loader: 'sass-loader' },
59 | ],
60 | }),
61 | },
62 | ],
63 | },
64 | plugins: [
65 | new HtmlWebpackPlugin({ template: '../public/index.html' }),
66 | new ExtractTextPlugin('css/main.css'),
67 | new webpack.DefinePlugin({
68 | 'process.env.NODE_ENV': JSON.stringify('production'),
69 | }),
70 | new webpack.IgnorePlugin(/\.svg$/),
71 | new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', filename: 'js/vendor.js' }),
72 | new webpack.optimize.UglifyJsPlugin({
73 | parallel: {
74 | cache: true,
75 | workers: 2,
76 | },
77 | }),
78 | new OfflinePlugin({}),
79 | ],
80 | };
81 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "sound-redux",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "client/src/index.js",
6 | "scripts": {
7 | "prebuild": "npm run clean && npm run copy",
8 | "build": "NODE_ENV=production webpack -p --config ./client/webpack.prod.config.js",
9 | "clean": "rm -rf ./dist",
10 | "precopy": "mkdir -p ./dist/public",
11 | "copy": "cp -r ./client/public/* ./dist/public/",
12 | "lint": "eslint client/src",
13 | "start": "webpack-dev-server --progress --colors --content-base ./client/public --config ./client/webpack.dev.config.js"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "https://github.com/andrewngu/sound-redux.git"
18 | },
19 | "author": "",
20 | "license": "ISC",
21 | "bugs": {
22 | "url": "https://github.com/andrewngu/sound-redux/issues"
23 | },
24 | "homepage": "https://github.com/andrewngu/sound-redux",
25 | "devDependencies": {
26 | "autoprefixer": "^7.1.3",
27 | "babel-core": "^6.26.0",
28 | "babel-loader": "^7.1.2",
29 | "babel-preset-es2015": "^6.24.1",
30 | "babel-preset-react": "^6.24.1",
31 | "babel-preset-stage-2": "^6.24.1",
32 | "css-hot-loader": "^1.3.0",
33 | "css-loader": "^0.28.7",
34 | "eslint": "^4.18.2",
35 | "eslint-config-airbnb": "^15.1.0",
36 | "eslint-plugin-import": "^2.7.0",
37 | "eslint-plugin-jsx-a11y": "^5.1.1",
38 | "eslint-plugin-react": "^7.3.0",
39 | "html-webpack-plugin": "^2.30.1",
40 | "node-sass": "^4.5.3",
41 | "postcss-loader": "^2.0.6",
42 | "sass-loader": "^6.0.6",
43 | "style-loader": "^0.18.2",
44 | "webpack": "^3.6.0",
45 | "webpack-dev-server": "^3.1.11"
46 | },
47 | "dependencies": {
48 | "babel-polyfill": "^6.26.0",
49 | "camelize": "^1.0.0",
50 | "extract-text-webpack-plugin": "^3.0.0",
51 | "isomorphic-fetch": "^2.2.1",
52 | "js-cookie": "^2.1.4",
53 | "lodash.merge": "^4.6.2",
54 | "moment": "^2.19.3",
55 | "normalizr": "^3.2.3",
56 | "offline-plugin": "^4.8.3",
57 | "path-to-regexp": "^2.0.0",
58 | "prop-types": "^15.5.10",
59 | "react": "^16.0.0",
60 | "react-dom": "^16.0.1",
61 | "react-hot-loader": "v3.0.0-beta.7",
62 | "react-redux": "^5.0.6",
63 | "redux": "^3.7.2",
64 | "redux-thunk": "^2.2.0",
65 | "reselect": "^3.0.1",
66 | "soundcloud": "^3.2.1"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/client/src/components/SongsBodyRendered.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import SongsBodyCard from '../components/SongsBodyCard';
4 |
5 | const defaultProps = {
6 | playingSongId: null,
7 | };
8 |
9 | const propTypes = {
10 | end: PropTypes.number.isRequired,
11 | isAuthenticated: PropTypes.bool.isRequired,
12 | isPlaying: PropTypes.bool.isRequired,
13 | likes: PropTypes.shape({}).isRequired,
14 | login: PropTypes.func.isRequired,
15 | navigateTo: PropTypes.func.isRequired,
16 | playingSongId: PropTypes.number,
17 | playlist: PropTypes.string.isRequired,
18 | playSong: PropTypes.func.isRequired,
19 | songs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
20 | start: PropTypes.number.isRequired,
21 | toggleLike: PropTypes.func.isRequired,
22 | };
23 |
24 | const SongsBodyRendered = ({
25 | end,
26 | isAuthenticated,
27 | isPlaying,
28 | likes,
29 | login,
30 | navigateTo,
31 | playingSongId,
32 | playlist,
33 | playSong,
34 | songs,
35 | start,
36 | toggleLike,
37 | }) => {
38 | const cellsPerRow = 5;
39 | const length = songs.length;
40 | const rows = [];
41 |
42 | for (let i = start; i < end; i += cellsPerRow) {
43 | const row = [];
44 |
45 | for (let j = 0; j < cellsPerRow; j += 1) {
46 | const index = i + j;
47 | const song = index < length ? songs[index] : null;
48 |
49 | row.push(
50 |
51 | {song ? (
52 |
65 | ) : null}
66 |
,
67 | );
68 | }
69 |
70 | rows.push(
71 |
72 | {row}
73 |
,
74 | );
75 | }
76 |
77 | return {rows}
;
78 | };
79 |
80 | SongsBodyRendered.defaultProps = defaultProps;
81 | SongsBodyRendered.propTypes = propTypes;
82 |
83 | export default SongsBodyRendered;
84 |
--------------------------------------------------------------------------------
/client/src/actions/PlaylistActions.js:
--------------------------------------------------------------------------------
1 | import { normalize } from 'normalizr';
2 | import * as types from '../constants/ActionTypes';
3 | import { songSchema } from '../constants/Schemas';
4 |
5 | import { getPlaylists } from '../selectors/CommonSelectors';
6 | import { callApi } from '../utils/ApiUtils';
7 |
8 | export const fetchSongsRequest = playlist => ({
9 | type: types.FETCH_SONGS_REQUEST,
10 | playlist,
11 | });
12 |
13 | export const fetchSongsSuccess = (playlist, items, entities, nextUrl, futureUrl) => ({
14 | type: types.FETCH_SONGS_SUCCESS,
15 | entities,
16 | futureUrl,
17 | items,
18 | playlist,
19 | nextUrl,
20 | });
21 |
22 | export const fetchSongs = (playlist, url) => async (dispatch) => {
23 | dispatch(fetchSongsRequest(playlist));
24 |
25 | const { json } = await callApi(url);
26 |
27 | const collection = json.collection || json;
28 | const songs = collection
29 | .map(song => song.origin || song)
30 | .filter(song => song.kind === 'track' && song.streamable);
31 | const nextUrl = json.nextHref || null;
32 | const futureUrl = json.futureHref || null;
33 |
34 | const { result, entities } = normalize(songs, [songSchema]);
35 |
36 | dispatch(fetchSongsSuccess(playlist, result, entities, nextUrl, futureUrl));
37 | };
38 |
39 | export const fetchSongsIfNeeded = (playlist, playlistUrl) => (dispatch, getState) => {
40 | const state = getState();
41 | const playlists = getPlaylists(state);
42 | const playlistExists = playlist in playlists;
43 | const playlistIsFetching = playlistExists ? playlists[playlist].isFetching : false;
44 | const playlistHasItems = playlistExists ? Boolean(playlists[playlist].items.length) : false;
45 | const shouldFetchSongs = playlistUrl
46 | && (!playlistExists || (!playlistHasItems && !playlistIsFetching));
47 |
48 | if (shouldFetchSongs) {
49 | dispatch(fetchSongs(playlist, playlistUrl));
50 | }
51 | };
52 |
53 | export const fetchSongsNext = (playlist, playlistNextUrl) => (dispatch, getState) => {
54 | const state = getState();
55 | const playlists = getPlaylists(state);
56 | const playlistExists = playlist in playlists;
57 | const playlistIsFetching = playlistExists ? playlists[playlist].isFetching : false;
58 | const shouldFetchSongsNext = (playlistExists && !playlistIsFetching && playlistNextUrl);
59 |
60 | if (shouldFetchSongsNext) {
61 | dispatch(fetchSongs(playlist, playlistNextUrl));
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/client/styles/custom/components/songs-body-card.scss:
--------------------------------------------------------------------------------
1 | .songs-body-card {
2 | position: relative;
3 | width: 218px;
4 | background-color: $white;
5 | border: 1px solid $lighterGray;
6 |
7 | @include mobile {
8 | width: 100%;
9 | border-left: 0;
10 | border-right: 0;
11 | border-top: 0;
12 | }
13 | }
14 |
15 | .songs-body-card--active {
16 | border-color: $green;
17 |
18 | @include mobile {
19 | border-color: $lighterGray;
20 | }
21 | }
22 |
23 | .songs-body-card__inner {
24 | position: relative;
25 | padding: 10px;
26 | z-index: 0;
27 |
28 | @include mobile {
29 | display: flex;
30 | flex-direction: row;
31 | };
32 | }
33 |
34 | .songs-body-card__artwork {
35 | height: 70px;
36 | margin-bottom: 10px;
37 | background: no-repeat center center;
38 | background-size: cover;
39 |
40 | &:hover {
41 | cursor: pointer;
42 | }
43 |
44 | @include mobile {
45 | flex-shrink: 0;
46 | width: 40px;
47 | height: 40px;
48 | margin: 0 10px 0 0;
49 | }
50 | }
51 |
52 | .songs-body-card__main {
53 | display: flex;
54 | flex-direction: row;
55 | align-items: center;
56 |
57 | @include mobile {
58 | flex: 1;
59 | overflow: hidden;
60 | }
61 | }
62 |
63 | .songs-body-card__avatar {
64 | width: 24px;
65 | height: 24px;
66 | margin-right: 10px;
67 | background: no-repeat center center;
68 | background-size: cover;
69 | border-radius: 50%;
70 |
71 | @include mobile {
72 | display: none;
73 | }
74 | }
75 |
76 | .songs-body-card__details {
77 | flex: 1;
78 | overflow: hidden;
79 | }
80 |
81 | .songs-body-card__title {
82 | display: block;
83 | color: #222;
84 | font-size: 11px;
85 | overflow: hidden;
86 | text-overflow: ellipsis;
87 | white-space: nowrap;
88 | }
89 |
90 | .songs-body-card__username {
91 | display: block;
92 | margin-right: 30px;
93 | font-size: 11px;
94 | overflow: hidden;
95 | text-overflow: ellipsis;
96 | white-space: nowrap;
97 |
98 | @include mobile {
99 | color: $gray;
100 | };
101 | }
102 |
103 | .songs-body-card__heart {
104 | position: absolute;
105 | bottom: 8px;
106 | right: 10px;
107 | font-size: 14px;
108 |
109 | @include mobile {
110 | display: none;
111 | };
112 | }
113 |
114 | .songs-body-card__mobile-events {
115 | display: none;
116 | position: absolute;
117 | top: 0;
118 | left: 0;
119 | width: 100%;
120 | height: 100%;
121 | outline: 0;
122 | z-index: 1;
123 |
124 | @include mobile {
125 | display: block;
126 | };
127 | }
128 |
--------------------------------------------------------------------------------
/client/src/components/UserMain.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import UserFollowButton from '../components/UserFollowButton';
4 | import IMAGE_SIZES from '../constants/ImageConstants';
5 | import { addCommas } from '../utils/NumberUtils';
6 | import getImageUrl from '../utils/ImageUtils';
7 | import { getSocialIcon, getLocation } from '../utils/UserUtils';
8 |
9 | const propTypes = {
10 | isFollowing: PropTypes.bool.isRequired,
11 | profiles: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
12 | toggleFollow: PropTypes.func.isRequired,
13 | user: PropTypes.shape({}).isRequired,
14 | };
15 |
16 | const UserMain = ({ isFollowing, profiles, toggleFollow, user }) => {
17 | const { avatarUrl, description, followersCount, username } = user;
18 |
19 | return (
20 |
21 |
27 |
28 |
29 |
30 | {username}
31 |
32 |
33 |
38 |
39 |
40 |
41 |
42 |
43 | {getLocation(user)}
44 |
45 |
46 |
47 |
48 |
49 | {addCommas(followersCount)}
50 |
51 |
52 | Followers
53 |
54 |
55 | {profiles.map(({ id, service, title, url }) => (
56 |
62 | ))}
63 |
64 |
68 |
69 |
70 | );
71 | };
72 |
73 | UserMain.propTypes = propTypes;
74 |
75 | export default UserMain;
76 |
--------------------------------------------------------------------------------
/client/src/components/Waveform.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 |
3 | import PropTypes from 'prop-types';
4 | import React, { Component } from 'react';
5 | import WaveformEvents from '../components/WaveformEvents';
6 | import offsetLeft from '../utils/DomUtils';
7 |
8 | const defaultProps = {
9 | className: '',
10 | };
11 |
12 | const propTypes = {
13 | className: PropTypes.string,
14 | index: PropTypes.number.isRequired,
15 | isActive: PropTypes.bool.isRequired,
16 | player: PropTypes.shape({}).isRequired,
17 | playlist: PropTypes.string.isRequired,
18 | playSong: PropTypes.func.isRequired,
19 | song: PropTypes.shape({}).isRequired,
20 | };
21 |
22 | class Waveform extends Component {
23 | constructor(props) {
24 | super(props);
25 | this.onMouseMove = this.onMouseMove.bind(this);
26 | this.playSong = this.playSong.bind(this);
27 | this.seek = this.seek.bind(this);
28 | this.state = {
29 | seek: 0,
30 | };
31 | }
32 |
33 | onMouseMove(e) {
34 | const seek = ((e.clientX - offsetLeft(e.currentTarget)) / e.currentTarget.offsetWidth) * 100;
35 | this.setState({ seek });
36 | }
37 |
38 | playSong() {
39 | const { index, playlist, playSong } = this.props;
40 | playSong(playlist, index);
41 | }
42 |
43 | seek() {
44 | const audioElement = document.getElementById('audio');
45 | const { seek } = this.state;
46 | const { song } = this.props;
47 | const { duration } = song;
48 | const currentTime = Math.floor((seek / 100) * (duration / 1000));
49 | audioElement.currentTime = currentTime;
50 | }
51 |
52 | render() {
53 | const { seek } = this.state;
54 | const { className, isActive, player, song } = this.props;
55 | const { currentTime } = player;
56 | const { duration, waveformUrl } = song;
57 | const width = isActive ? (currentTime / (duration / 1000)) * 100 : 0;
58 |
59 | return (
60 |
61 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
78 |
79 | );
80 | }
81 | }
82 |
83 | Waveform.defaultProps = defaultProps;
84 | Waveform.propTypes = propTypes;
85 |
86 | export default Waveform;
87 |
--------------------------------------------------------------------------------
/client/src/components/Slider.jsx:
--------------------------------------------------------------------------------
1 | /* global document */
2 | import PropTypes from 'prop-types';
3 | import React, { Component } from 'react';
4 | import offsetLeft from '../utils/DomUtils';
5 |
6 | const defaultProps = {
7 | className: '',
8 | };
9 |
10 | const propTypes = {
11 | className: PropTypes.string,
12 | max: PropTypes.number.isRequired,
13 | onChange: PropTypes.func.isRequired,
14 | value: PropTypes.number.isRequired,
15 | };
16 |
17 | const prevent = (e) => {
18 | e.preventDefault();
19 | e.stopPropagation();
20 | };
21 |
22 | class Slider extends Component {
23 | constructor() {
24 | super();
25 | this.onClick = this.onClick.bind(this);
26 | this.onMouseDown = this.onMouseDown.bind(this);
27 | this.onMouseMove = this.onMouseMove.bind(this);
28 | this.onMouseUp = this.onMouseUp.bind(this);
29 | this.domNode = null;
30 | }
31 |
32 | componentWillUnmount() {
33 | document.removeEventListener('mousemove', this.onMouseMove);
34 | document.removeEventListener('mouseup', this.onMouseUp);
35 | }
36 |
37 | onClick(e) {
38 | const { max, onChange } = this.props;
39 | const percent = (e.clientX - offsetLeft(e.currentTarget)) / e.currentTarget.offsetWidth;
40 | onChange(percent * max);
41 | }
42 |
43 | onMouseDown() {
44 | document.addEventListener('mousemove', this.onMouseMove);
45 | document.addEventListener('mouseup', this.onMouseUp);
46 | }
47 |
48 | onMouseMove(e) {
49 | const { domNode, props } = this;
50 | const { max, onChange } = props;
51 |
52 | const diff = e.clientX - offsetLeft(domNode);
53 | const percent = Math.min(Math.max(diff / domNode.offsetWidth, 0), 1);
54 | onChange(percent * max);
55 | }
56 |
57 | onMouseUp() {
58 | document.removeEventListener('mousemove', this.onMouseMove);
59 | document.removeEventListener('mouseup', this.onMouseUp);
60 | }
61 |
62 | render() {
63 | const { className, max, value } = this.props;
64 | const width = `${(value / max) * 100}%`;
65 |
66 | return (
67 | { this.domNode = node; }}
71 | role="button"
72 | tabIndex="0"
73 | >
74 |
75 | {max > 0
76 | ? (
77 |
86 | ) : null
87 | }
88 |
89 |
90 | );
91 | }
92 | }
93 |
94 | Slider.defaultProps = defaultProps;
95 | Slider.propTypes = propTypes;
96 |
97 | export default Slider;
98 |
--------------------------------------------------------------------------------
/client/src/reducers/playlists.js:
--------------------------------------------------------------------------------
1 | import * as types from '../constants/ActionTypes';
2 | import { HISTORY_PLAYLIST, PLAYLIST_PLAYLIST_TYPE, SESSION_PLAYLIST_TYPE, SESSION_STREAM_PLAYLIST } from '../constants/PlaylistConstants';
3 |
4 | const initialState = {
5 | isFetching: false,
6 | items: [],
7 | futureUrl: null,
8 | nextUrl: null,
9 | };
10 |
11 | function playlist(state = initialState, action) {
12 | switch (action.type) {
13 | case types.FETCH_NEW_STREAM_SONGS_SUCCESS:
14 | return {
15 | ...state,
16 | futureUrl: action.futureUrl,
17 | };
18 |
19 | case types.FETCH_SONGS_REQUEST:
20 | return {
21 | ...state,
22 | isFetching: true,
23 | };
24 |
25 | case types.FETCH_SONGS_SUCCESS:
26 | return {
27 | ...state,
28 | futureUrl: action.futureUrl,
29 | isFetching: false,
30 | items: [...new Set([...state.items, ...action.items])],
31 | nextUrl: action.nextUrl,
32 | };
33 |
34 | case types.LOAD_NEW_STREAM_SONGS:
35 | return {
36 | ...state,
37 | items: [...action.newStreamSongs, ...state.items],
38 | };
39 |
40 | case types.PLAY_SONG: {
41 | if (action.playlist !== HISTORY_PLAYLIST) {
42 | return {
43 | ...state,
44 | items: [
45 | action.id,
46 | ...state.items.filter(id => id !== action.id),
47 | ],
48 | };
49 | }
50 |
51 | return state;
52 | }
53 |
54 | default:
55 | return state;
56 | }
57 | }
58 |
59 | export default function playlists(state = {}, action) {
60 | switch (action.type) {
61 | case types.FETCH_NEW_STREAM_SONGS_SUCCESS:
62 | case types.LOAD_NEW_STREAM_SONGS:
63 | return {
64 | ...state,
65 | [SESSION_STREAM_PLAYLIST]: playlist(state[SESSION_STREAM_PLAYLIST], action),
66 | };
67 |
68 | case types.FETCH_SONGS_REQUEST:
69 | case types.FETCH_SONGS_SUCCESS:
70 | return {
71 | ...state,
72 | [action.playlist]: playlist(state[action.playlist], action),
73 | };
74 |
75 | case types.PLAY_SONG:
76 | return {
77 | ...state,
78 | [HISTORY_PLAYLIST]: playlist(
79 | state[HISTORY_PLAYLIST],
80 | {
81 | ...action,
82 | id: state[action.playlist].items[action.playingIndex],
83 | },
84 | ),
85 | };
86 |
87 | case types.LOGOUT:
88 | return Object.keys(state)
89 | .filter((key) => {
90 | const type = key.split('|')[0];
91 | return type !== SESSION_PLAYLIST_TYPE && type !== PLAYLIST_PLAYLIST_TYPE;
92 | })
93 | .reduce((obj, key) => ({
94 | ...obj,
95 | [key]: state[key],
96 | }), {});
97 |
98 | default:
99 | return state;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/client/src/components/Nav.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Link from '../components/Link';
4 | import NavSearch from '../components/NavSearch';
5 | import NavSession from '../components/NavSession';
6 | import NavUser from '../components/NavUser';
7 | import { SONGS_PATH } from '../constants/RouterConstants';
8 |
9 | const defaultProps = {
10 | navPlaylist: null,
11 | user: null,
12 | };
13 |
14 | const propTypes = {
15 | fetchNewStreamSongs: PropTypes.func.isRequired,
16 | isAuthenticated: PropTypes.bool.isRequired,
17 | loadNewStreamSongs: PropTypes.func.isRequired,
18 | login: PropTypes.func.isRequired,
19 | logout: PropTypes.func.isRequired,
20 | navigateTo: PropTypes.func.isRequired,
21 | navPlaylist: PropTypes.shape({}),
22 | navPlaylists: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
23 | newStreamSongs: PropTypes.arrayOf(PropTypes.number).isRequired,
24 | showLikes: PropTypes.bool.isRequired,
25 | showPlaylist: PropTypes.bool.isRequired,
26 | showStream: PropTypes.bool.isRequired,
27 | streamFutureUrl: PropTypes.string.isRequired,
28 | user: PropTypes.shape({}),
29 | };
30 |
31 | const Nav = ({
32 | fetchNewStreamSongs,
33 | isAuthenticated,
34 | loadNewStreamSongs,
35 | login,
36 | logout,
37 | navigateTo,
38 | navPlaylist,
39 | navPlaylists,
40 | newStreamSongs,
41 | showLikes,
42 | showPlaylist,
43 | showStream,
44 | streamFutureUrl,
45 | user,
46 | }) => (
47 |
48 |
49 |
50 |
51 |
56 | SoundRedux
57 |
58 |
59 |
60 |
73 |
74 |
75 |
76 |
77 |
78 |
85 |
86 |
87 |
88 | );
89 |
90 | Nav.defaultProps = defaultProps;
91 | Nav.propTypes = propTypes;
92 |
93 | export default Nav;
94 |
--------------------------------------------------------------------------------
/client/src/selectors/CommonSelectors.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from 'reselect';
2 | import { TABLET_WIDTH } from '../constants/EnvironmentConstants';
3 | import { PLAYLIST_PATH, SONGS_PATH } from '../constants/RouterConstants';
4 |
5 | // entities selectors
6 | export const getEntities = state => state.entities;
7 |
8 | // environment selectors
9 | export const getHeight = state => state.environment.height;
10 | export const getWidth = state => state.environment.width;
11 | export const getSidebarHeight = createSelector(
12 | getHeight,
13 | height => height - 200,
14 | );
15 | export const getIsMobile = createSelector(
16 | getWidth,
17 | width => width < TABLET_WIDTH,
18 | );
19 |
20 | // playlists selectors
21 | export const getPlaylists = state => state.playlists;
22 |
23 | // player selectors
24 | export const getCurrentTime = state => state.player.currentTime;
25 | export const getIsPlaying = state => state.player.isPlaying;
26 | export const getPlayingIndex = state => state.player.playingIndex;
27 | export const getPlaylist = state => state.player.playlist;
28 | export const getPlayingSongId = createSelector(
29 | getPlayingIndex,
30 | getPlaylist,
31 | getPlaylists,
32 | (playingIndex, playlist, playlists) => {
33 | if (playlist && playingIndex !== null) {
34 | return playlists[playlist].items[playingIndex];
35 | }
36 |
37 | return null;
38 | },
39 | );
40 | export const getRepeat = state => state.player.repeat;
41 | export const getShuffle = state => state.player.shuffle;
42 |
43 | // router selectors
44 | export const getGenre = state => (state.router.route.options.q
45 | ? ''
46 | : (state.router.route.options.g || 'house')
47 | );
48 | export const getId = state => (state.router.route.keys.id ? Number(state.router.route.keys.id) : 0);
49 | export const getPath = state => state.router.route.path;
50 | export const getSearch = state => state.router.route.options.q || '';
51 | export const getSession = (state) => {
52 | const { s } = state.router.route.options;
53 | if (s === 'likes' || s === 'stream') {
54 | return s;
55 | }
56 |
57 | return '';
58 | };
59 | export const getShowLikes = createSelector(
60 | getPath,
61 | getSession,
62 | (path, session) => path === SONGS_PATH && session === 'likes',
63 | );
64 | export const getShowPlaylist = createSelector(
65 | getPath,
66 | path => path === PLAYLIST_PATH,
67 | );
68 | export const getShowStream = createSelector(
69 | getPath,
70 | getSession,
71 | (path, session) => path === SONGS_PATH && session === 'stream',
72 | );
73 | export const getTime = state => state.router.route.options.t || '';
74 |
75 | // session selectors
76 | export const getLikes = state => state.session.likes;
77 | export const getNewStreamSongs = state => state.session.newStreamSongs;
78 | export const getOauthToken = state => state.session.oauthToken;
79 | export const getSessionId = state => state.session.id;
80 | export const getSessionUser = createSelector(
81 | getSessionId,
82 | getEntities,
83 | (id, entities) => (id in entities.users
84 | ? entities.users[id]
85 | : null
86 | ),
87 | );
88 | export const getIsAuthenticated = createSelector(
89 | getOauthToken,
90 | getSessionUser,
91 | (oauthToken, user) => Boolean(oauthToken && user),
92 | );
93 | export const getSessionFollowings = state => state.session.followings;
94 |
--------------------------------------------------------------------------------
/client/src/components/SongsBody.jsx:
--------------------------------------------------------------------------------
1 | /* global window */
2 |
3 | import PropTypes from 'prop-types';
4 | import React, { Component } from 'react';
5 | import Loader from '../components/Loader';
6 | import SongsBodyRendered from '../components/SongsBodyRendered';
7 | import scrollState from '../utils/ScrollUtils';
8 |
9 | const defaultProps = {
10 | playingSongId: null,
11 | };
12 |
13 | const propTypes = {
14 | height: PropTypes.number.isRequired,
15 | isAuthenticated: PropTypes.bool.isRequired,
16 | isFetching: PropTypes.bool.isRequired,
17 | isMobile: PropTypes.bool.isRequired,
18 | isPlaying: PropTypes.bool.isRequired,
19 | likes: PropTypes.shape({}).isRequired,
20 | login: PropTypes.func.isRequired,
21 | navigateTo: PropTypes.func.isRequired,
22 | playingSongId: PropTypes.number,
23 | playlist: PropTypes.string.isRequired,
24 | playSong: PropTypes.func.isRequired,
25 | songs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
26 | toggleLike: PropTypes.func.isRequired,
27 | };
28 |
29 | class SongBody extends Component {
30 | constructor(props) {
31 | super(props);
32 | this.onScroll = this.onScroll.bind(this);
33 |
34 | this.state = scrollState(props.height, props.songs.length, props.isMobile);
35 | }
36 |
37 | componentDidMount() {
38 | window.addEventListener('scroll', this.onScroll, false);
39 | }
40 |
41 | componentWillReceiveProps(nextProps) {
42 | const { height, songs } = this.props;
43 | if (height !== nextProps.height || songs.length !== nextProps.songs.length) {
44 | this.setState(scrollState(nextProps.height, nextProps.songs.length, nextProps.isMobile));
45 | }
46 | }
47 |
48 | componentWillUnmount() {
49 | window.removeEventListener('scroll', this.onScroll, false);
50 | }
51 |
52 | onScroll() {
53 | const { height, isMobile, songs } = this.props;
54 | this.setState(scrollState(height, songs.length, isMobile));
55 | }
56 |
57 | render() {
58 | const {
59 | isAuthenticated,
60 | isFetching,
61 | isMobile,
62 | isPlaying,
63 | likes,
64 | login,
65 | navigateTo,
66 | playingSongId,
67 | playlist,
68 | playSong,
69 | songs,
70 | toggleLike,
71 | } = this.props;
72 | const { end, paddingBottom, paddingTop, start } = this.state;
73 |
74 | return (
75 |
95 | );
96 | }
97 | }
98 |
99 | SongBody.defaultProps = defaultProps;
100 | SongBody.propTypes = propTypes;
101 |
102 | export default SongBody;
103 |
--------------------------------------------------------------------------------
/client/styles/custom/components/player.scss:
--------------------------------------------------------------------------------
1 | .player {
2 | position: fixed;
3 | left: 0;
4 | bottom: 0;
5 | width: 100%;
6 | height: 48px;
7 | background-color: $white;
8 | border-top: 1px solid $lighterGray;
9 | user-select: none;
10 | z-index: 999;
11 |
12 | @include mobile {
13 | background-color: $darkestGray;
14 | border-top-color: $black;
15 | padding: 0 10px;
16 | };
17 | }
18 |
19 | .player__inner {
20 | display: flex;
21 | flex-direction: row;
22 | height: 100%;
23 | }
24 |
25 | .player__section {
26 | display: flex;
27 | flex-direction: row;
28 | align-items: center;
29 | height: 100%;
30 |
31 | & + .player__section {
32 | margin-left: 40px;
33 | }
34 | }
35 |
36 | .player__section--seek {
37 | flex: 1;
38 | }
39 |
40 | .player__section--volume {
41 | width: 100px;
42 | }
43 |
44 | .player__section--song {
45 | width: 200px;
46 |
47 | @include mobile {
48 | pointer-events: none;
49 | flex: 1;
50 | };
51 | }
52 |
53 | .player__section--seek,
54 | .player__section--time,
55 | .player__section--options,
56 | .player__section--volume {
57 | @include mobile {
58 | display: none;
59 | };
60 | }
61 |
62 | .player__song {
63 | display: flex;
64 | flex-direction: row;
65 | align-items: center;
66 | height: 100%;
67 | width: 100%;
68 | }
69 |
70 | .player__song__artwork {
71 | flex-shrink: 0;
72 | width: 30px;
73 | height: 30px;
74 | background: no-repeat center center;
75 | background-size: cover;
76 | }
77 |
78 | .player__song__main {
79 | flex: 1;
80 | margin-left: 8px;
81 | overflow: hidden;
82 | }
83 |
84 | .player__song__title {
85 | color: $black;
86 |
87 | @include mobile {
88 | color: $white;
89 | };
90 | }
91 |
92 | .player__song__username {
93 | color: $blue;
94 | }
95 |
96 | .player__song__title,
97 | .player__song__username {
98 | display: block;
99 | font-size: 11px;
100 | overflow: hidden;
101 | text-overflow: ellipsis;
102 | white-space: nowrap;
103 |
104 | @include mobile {
105 | color: $white;
106 | };
107 | }
108 |
109 | .player__buttons {
110 | display: flex;
111 | flex-direction: row;
112 | align-items: center;
113 | height: 100%;
114 | }
115 |
116 | .player__button {
117 | position: relative;
118 | padding: 4px;
119 | outline: 0;
120 |
121 | &:hover {
122 | cursor: pointer;
123 |
124 | .player__button__icon {
125 | color: $darkGray;
126 | }
127 | }
128 |
129 | &:active {
130 | .player__button__icon {
131 | color: $darkGreen;
132 | }
133 | }
134 |
135 | & + .player__button {
136 | margin-left: 30px;
137 | }
138 | }
139 |
140 | .player__button__icon {
141 | position: relative;
142 | color: $gray;
143 | z-index: 1;
144 | }
145 |
146 | .player__button--active {
147 | .player__button__icon {
148 | color: $green;
149 | }
150 |
151 | &:hover {
152 | .player__button__icon {
153 | color: $darkerGreen;
154 | }
155 | }
156 | }
157 |
158 | .player__time {
159 | display: flex;
160 | flex-direction: row;
161 | color: $gray;
162 | font-size: 11px;
163 | }
164 |
165 | .player__time__separator {
166 | margin: 0 10px;
167 | }
168 |
169 | .player__button--volume {
170 | position: relative;
171 | width: 6px;
172 | height: 22px;
173 |
174 | .player__button__icon {
175 | position: absolute;
176 | top: 0;
177 | left: 0;
178 | }
179 | }
180 |
181 | .player__button__icon--absolute {
182 | color: $green;
183 | z-index: 0;
184 | }
185 |
--------------------------------------------------------------------------------
/client/src/components/SongMain.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import ArtworkPlay from '../components/ArtworkPlay';
4 | import Link from '../components/Link';
5 | import Stats from '../components/Stats';
6 | import Waveform from '../components/Waveform';
7 | import { USER_PATH } from '../constants/RouterConstants';
8 | import IMAGE_SIZES from '../constants/ImageConstants';
9 | import getImageUrl from '../utils/ImageUtils';
10 |
11 | const propTypes = {
12 | isActive: PropTypes.bool.isRequired,
13 | isAuthenticated: PropTypes.bool.isRequired,
14 | liked: PropTypes.bool.isRequired,
15 | login: PropTypes.func.isRequired,
16 | navigateTo: PropTypes.func.isRequired,
17 | player: PropTypes.shape({}).isRequired,
18 | playlist: PropTypes.string.isRequired,
19 | playSong: PropTypes.func.isRequired,
20 | song: PropTypes.shape({}).isRequired,
21 | toggleLike: PropTypes.func.isRequired,
22 | };
23 |
24 | const SongMain = ({
25 | isActive,
26 | isAuthenticated,
27 | liked,
28 | login,
29 | navigateTo,
30 | player,
31 | playlist,
32 | playSong,
33 | song,
34 | toggleLike,
35 | }) => {
36 | const { isPlaying } = player;
37 | const {
38 | artworkUrl,
39 | commentCount,
40 | description,
41 | favoritingsCount,
42 | id,
43 | playbackCount,
44 | user,
45 | } = song;
46 | const { avatarUrl, username } = user;
47 |
48 | return (
49 |
50 |
66 |
67 |
68 | {song.title}
69 |
70 |
71 |
75 |
81 | {username}
82 |
83 |
84 |
95 |
96 | {description}
97 |
98 |
99 |
108 |
109 | );
110 | };
111 |
112 | SongMain.propTypes = propTypes;
113 |
114 | export default SongMain;
115 |
--------------------------------------------------------------------------------
/client/src/components/SongsBodyCard.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import Link from '../components/Link';
4 | import Heart from '../components/Heart';
5 | import ArtworkPlay from '../components/ArtworkPlay';
6 | import SongsBodyCardMobileEvents from '../components/SongsBodyCardMobileEvents';
7 | import { SONG_PATH, USER_PATH } from '../constants/RouterConstants';
8 | import IMAGE_SIZES from '../constants/ImageConstants';
9 | import getImageUrl from '../utils/ImageUtils';
10 | import formatSongTitle from '../utils/SongUtils';
11 |
12 | const propTypes = {
13 | index: PropTypes.number.isRequired,
14 | isActive: PropTypes.bool.isRequired,
15 | isAuthenticated: PropTypes.bool.isRequired,
16 | isPlaying: PropTypes.bool.isRequired,
17 | liked: PropTypes.bool.isRequired,
18 | login: PropTypes.func.isRequired,
19 | navigateTo: PropTypes.func.isRequired,
20 | playlist: PropTypes.string.isRequired,
21 | playSong: PropTypes.func.isRequired,
22 | song: PropTypes.shape({}).isRequired,
23 | toggleLike: PropTypes.func.isRequired,
24 | };
25 |
26 | const SongsBodyCard = ({
27 | index,
28 | isActive,
29 | isAuthenticated,
30 | isPlaying,
31 | liked,
32 | login,
33 | navigateTo,
34 | playlist,
35 | playSong,
36 | song,
37 | toggleLike,
38 | }) => {
39 | const { artworkUrl, id, title, user } = song;
40 | const { avatarUrl, username } = user;
41 |
42 | return (
43 |
44 |
45 |
59 |
60 |
66 |
67 |
74 | {formatSongTitle(title)}
75 |
76 |
83 | {username}
84 |
85 |
86 |
87 |
95 |
96 |
102 |
103 | );
104 | };
105 |
106 | SongsBodyCard.propTypes = propTypes;
107 |
108 | export default SongsBodyCard;
109 |
--------------------------------------------------------------------------------
/client/src/components/SongListItem.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import ArtworkPlay from '../components/ArtworkPlay';
4 | import Link from '../components/Link';
5 | import Stats from '../components/Stats';
6 | import Waveform from '../components/Waveform';
7 | import { SONG_PATH, USER_PATH } from '../constants/RouterConstants';
8 | import IMAGE_SIZES from '../constants/ImageConstants';
9 | import getImageUrl from '../utils/ImageUtils';
10 |
11 | const propTypes = {
12 | index: PropTypes.number.isRequired,
13 | isActive: PropTypes.bool.isRequired,
14 | isAuthenticated: PropTypes.bool.isRequired,
15 | liked: PropTypes.bool.isRequired,
16 | login: PropTypes.func.isRequired,
17 | navigateTo: PropTypes.func.isRequired,
18 | player: PropTypes.shape({}).isRequired,
19 | playlist: PropTypes.string.isRequired,
20 | playSong: PropTypes.func.isRequired,
21 | song: PropTypes.shape({}).isRequired,
22 | toggleLike: PropTypes.func.isRequired,
23 | };
24 |
25 | const SongListItem = ({
26 | index,
27 | isActive,
28 | isAuthenticated,
29 | liked,
30 | login,
31 | navigateTo,
32 | player,
33 | playlist,
34 | playSong,
35 | song,
36 | toggleLike,
37 | }) => {
38 | const { isPlaying } = player;
39 | const { artworkUrl, commentCount, favoritingsCount, id, playbackCount, title, user } = song;
40 | const { avatarUrl, username } = user;
41 |
42 | return (
43 |
44 |
60 |
61 |
67 | {title}
68 |
69 |
70 |
71 |
75 |
81 | {username}
82 |
83 |
84 |
95 |
96 |
97 |
106 |
107 | );
108 | };
109 |
110 | SongListItem.propTypes = propTypes;
111 |
112 | export default SongListItem;
113 |
--------------------------------------------------------------------------------
/client/src/components/User.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 |
4 | import SongList from '../components/SongList';
5 | import Loader from '../components/Loader';
6 | import stickyOnScroll from '../components/stickyOnScroll';
7 | import UserFollowings from '../components/UserFollowings';
8 | import UserMain from '../components/UserMain';
9 |
10 | const defaultProps = {
11 | playingSongId: null,
12 | user: null,
13 | };
14 |
15 | const propTypes = {
16 | fetchUserIfNeeded: PropTypes.func.isRequired,
17 | followings: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
18 | id: PropTypes.number.isRequired,
19 | isAuthenticated: PropTypes.bool.isRequired,
20 | isFollowing: PropTypes.bool.isRequired,
21 | likes: PropTypes.shape({}).isRequired,
22 | login: PropTypes.func.isRequired,
23 | navigateTo: PropTypes.func.isRequired,
24 | player: PropTypes.shape({}).isRequired,
25 | playingSongId: PropTypes.number,
26 | playlist: PropTypes.string.isRequired,
27 | playSong: PropTypes.func.isRequired,
28 | profiles: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
29 | shouldFetchUser: PropTypes.bool.isRequired,
30 | sidebarHeight: PropTypes.number.isRequired,
31 | sticky: PropTypes.bool.isRequired,
32 | songs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
33 | toggleFollow: PropTypes.func.isRequired,
34 | toggleLike: PropTypes.func.isRequired,
35 | user: PropTypes.shape({}),
36 | };
37 |
38 | class User extends Component {
39 | componentWillMount() {
40 | const { fetchUserIfNeeded, id, playlist, shouldFetchUser } = this.props;
41 | fetchUserIfNeeded(shouldFetchUser, id, playlist);
42 | }
43 |
44 | componentWillReceiveProps(nextProps) {
45 | const { fetchUserIfNeeded, id } = this.props;
46 | if (nextProps.id !== id) {
47 | fetchUserIfNeeded(nextProps.shouldFetchUser, nextProps.id, nextProps.playlist);
48 | }
49 | }
50 |
51 | render() {
52 | const {
53 | followings,
54 | isAuthenticated,
55 | isFollowing,
56 | likes,
57 | login,
58 | navigateTo,
59 | player,
60 | playlist,
61 | playingSongId,
62 | playSong,
63 | profiles,
64 | shouldFetchUser,
65 | sidebarHeight,
66 | sticky,
67 | songs,
68 | toggleFollow,
69 | toggleLike,
70 | user,
71 | } = this.props;
72 | if (shouldFetchUser) {
73 | return ;
74 | }
75 |
76 | return (
77 |
78 |
79 |
80 |
86 |
99 |
100 |
101 |
107 |
108 |
109 |
110 | );
111 | }
112 | }
113 |
114 | User.defaultProps = defaultProps;
115 | User.propTypes = propTypes;
116 |
117 | export default stickyOnScroll(User, 50);
118 |
--------------------------------------------------------------------------------
/client/src/components/Song.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 |
4 | import SongComments from '../components/SongComments';
5 | import SongList from '../components/SongList';
6 | import Loader from '../components/Loader';
7 | import SongMain from '../components/SongMain';
8 | import stickyOnScroll from '../components/stickyOnScroll';
9 |
10 | const defaultProps = {
11 | playingSongId: null,
12 | song: null,
13 | };
14 |
15 | const propTypes = {
16 | comments: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
17 | fetchSongIfNeeded: PropTypes.func.isRequired,
18 | id: PropTypes.number.isRequired,
19 | isAuthenticated: PropTypes.bool.isRequired,
20 | likes: PropTypes.shape({}).isRequired,
21 | login: PropTypes.func.isRequired,
22 | navigateTo: PropTypes.func.isRequired,
23 | player: PropTypes.shape({}).isRequired,
24 | playingSongId: PropTypes.number,
25 | playlist: PropTypes.string.isRequired,
26 | playSong: PropTypes.func.isRequired,
27 | sidebarHeight: PropTypes.number.isRequired,
28 | song: PropTypes.shape({}),
29 | songs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
30 | sticky: PropTypes.bool.isRequired,
31 | timed: PropTypes.bool.isRequired,
32 | toggleLike: PropTypes.func.isRequired,
33 | };
34 |
35 | class Song extends Component {
36 | componentWillMount() {
37 | const { fetchSongIfNeeded, id, playlist } = this.props;
38 | fetchSongIfNeeded(id, playlist);
39 | }
40 |
41 | componentWillReceiveProps(nextProps) {
42 | const { fetchSongIfNeeded, id } = this.props;
43 | if (nextProps.id !== id) {
44 | fetchSongIfNeeded(nextProps.id, nextProps.playlist);
45 | }
46 | }
47 |
48 | render() {
49 | const {
50 | comments,
51 | id,
52 | isAuthenticated,
53 | likes,
54 | login,
55 | navigateTo,
56 | playlist,
57 | player,
58 | playingSongId,
59 | playSong,
60 | sidebarHeight,
61 | song,
62 | songs,
63 | sticky,
64 | timed,
65 | toggleLike,
66 | } = this.props;
67 | if (!song) {
68 | return ;
69 | }
70 |
71 | return (
72 |
73 |
74 |
75 |
87 |
102 |
103 |
104 |
112 |
113 |
114 |
115 | );
116 | }
117 | }
118 |
119 | Song.defaultProps = defaultProps;
120 | Song.propTypes = propTypes;
121 |
122 | export default stickyOnScroll(Song, 50);
123 |
--------------------------------------------------------------------------------
/client/styles/custom/components/songs-header.scss:
--------------------------------------------------------------------------------
1 | .songs-header {
2 | position: relative;
3 | height: 40px;
4 | z-index: 2;
5 | }
6 |
7 | .songs-header--sticky {
8 | .songs-header__inner {
9 | position: fixed;
10 | top: 0;
11 | left: 0;
12 | width: 100%;
13 | z-index: 1;
14 | }
15 | }
16 |
17 | .songs-header__inner {
18 | height: 40px;
19 | background-color: $white;
20 | border-bottom: 1px solid $lighterGray;
21 | }
22 |
23 | .songs-header__sections {
24 | display: flex;
25 | flex-direction: row;
26 | height: 100%;
27 | }
28 |
29 | .songs-header__section {
30 | height: 100%;
31 | }
32 |
33 | .songs-header__section--genres {
34 | flex: 1;
35 | }
36 |
37 | .songs-header__section--time {
38 | @include mobile {
39 | display: none;
40 | };
41 | }
42 |
43 | .songs-header__genres {
44 | position: relative;
45 | height: 100%;
46 | border-right: 1px solid $lighterGray;
47 | }
48 |
49 | .songs-header__genres--expanded {
50 | .songs-header__genres__main {
51 | transform: translateY(0);
52 | }
53 | }
54 |
55 | .songs-header__genres__active {
56 | position: relative;
57 | display: none;
58 | align-items: center;
59 | justify-content: center;
60 | height: 100%;
61 | font-size: 11px;
62 | font-weight: normal;
63 | text-transform: uppercase;
64 | background-color: $white;
65 | outline: 0;
66 | z-index: 1;
67 |
68 | @include mobile {
69 | display: flex;
70 | }
71 | }
72 |
73 | .songs-header__genres__main {
74 | display: flex;
75 | flex-direction: row;
76 | height: 100%;
77 |
78 | @include mobile {
79 | display: block;
80 | position: absolute;
81 | top: 100%;
82 | left: 0;
83 | width: 100%;
84 | height: auto;
85 | transform: translateY(-100%);
86 | transition: transform 320ms ease-out;
87 | z-index: 0;
88 | };
89 | }
90 |
91 | .songs-header__genre {
92 | flex: 1;
93 | height: 100%;
94 | border-left: 1px solid $lighterGray;
95 |
96 | @include mobile {
97 | height: 39px;
98 | background-color: $white;
99 | border-left: 0;
100 | border-top: 1px solid $lighterGray;
101 |
102 | &:last-child {
103 | border-bottom: 1px solid $lighterGray;
104 | }
105 | }
106 | }
107 |
108 | .songs-header__genre__text {
109 | display: flex;
110 | flex-direction: row;
111 | align-items: center;
112 | justify-content: center;
113 | height: 100%;
114 | color: $darkGray;
115 | font-size: 11px;
116 | font-weight: 300;
117 | text-transform: uppercase;
118 | border-bottom: 2px solid transparent;
119 | border-top: 2px solid transparent;
120 | transition: all 200ms ease-in-out;
121 |
122 | &:hover {
123 | color: $black;
124 | text-decoration: none;
125 | }
126 | }
127 |
128 | .songs-header__genre--active {
129 | .songs-header__genre__text {
130 | border-bottom-color: $green;
131 | color: $black;
132 | }
133 |
134 | @include mobile {
135 | display: none;
136 | };
137 | }
138 |
139 | .songs-header__times {
140 | display: flex;
141 | flex-direction: row;
142 | align-items: center;
143 | justify-content: center;
144 | height: 100%;
145 | width: 217px;
146 | border-right: 1px solid $lighterGray;
147 | }
148 |
149 | .songs-header__times__inner {
150 | display: flex;
151 | flex-direction: row;
152 | align-items: center;
153 | height: 100%;
154 | }
155 |
156 | .songs-header__times__icon {
157 | color: $lightGray;
158 | }
159 |
160 | .songs-header__time {
161 | display: flex;
162 | flex-direction: row;
163 | align-items: center;
164 | height: 100%;
165 | margin-left: 20px;
166 | color: $darkGray;
167 | font-size: 11px;
168 | font-weight: 300;
169 | border-bottom: 1px solid transparent;
170 | border-top: 1px solid transparent;
171 | transition: all 200ms ease-in-out;
172 |
173 | &:hover {
174 | color: $blue;
175 | text-decoration: none;
176 | }
177 | }
178 |
179 | .songs-header__time--active {
180 | color: $blue;
181 | border-bottom-color: $blue;
182 | }
183 |
--------------------------------------------------------------------------------
/client/src/components/Songs.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 |
4 | import InfiniteScroll from '../components/InfiniteScroll';
5 | import SongsBody from '../components/SongsBody';
6 | import SongsHeader from '../components/SongsHeader';
7 | import stickyOnScroll from '../components/stickyOnScroll';
8 |
9 | const defaultProps = {
10 | playingSongId: null,
11 | playlistUrl: null,
12 | playlistNextUrl: null,
13 | time: null,
14 | };
15 |
16 | const propTypes = {
17 | fetchSongsIfNeeded: PropTypes.func.isRequired,
18 | fetchSongsNext: PropTypes.func.isRequired,
19 | genre: PropTypes.string.isRequired,
20 | genres: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
21 | height: PropTypes.number.isRequired,
22 | isAuthenticated: PropTypes.bool.isRequired,
23 | isFetching: PropTypes.bool.isRequired,
24 | isMobile: PropTypes.bool.isRequired,
25 | isPlaying: PropTypes.bool.isRequired,
26 | likes: PropTypes.shape({}).isRequired,
27 | login: PropTypes.func.isRequired,
28 | navigateTo: PropTypes.func.isRequired,
29 | playSong: PropTypes.func.isRequired,
30 | playingSongId: PropTypes.number,
31 | playlist: PropTypes.string.isRequired,
32 | playlistNextUrl: PropTypes.string,
33 | playlistUrl: PropTypes.string,
34 | search: PropTypes.string.isRequired,
35 | showLikes: PropTypes.bool.isRequired,
36 | showPlaylist: PropTypes.bool.isRequired,
37 | showStream: PropTypes.bool.isRequired,
38 | sticky: PropTypes.bool.isRequired,
39 | songs: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
40 | time: PropTypes.string.isRequired,
41 | times: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
42 | toggleLike: PropTypes.func.isRequired,
43 | };
44 |
45 | class Songs extends Component {
46 | componentWillMount() {
47 | const { fetchSongsIfNeeded, playlist, playlistUrl } = this.props;
48 | fetchSongsIfNeeded(playlist, playlistUrl);
49 | }
50 |
51 | componentWillReceiveProps(nextProps) {
52 | const { fetchSongsIfNeeded, playlist } = this.props;
53 | if (playlist !== nextProps.playlist) {
54 | fetchSongsIfNeeded(nextProps.playlist, nextProps.playlistUrl);
55 | }
56 | }
57 |
58 | render() {
59 | const {
60 | fetchSongsNext,
61 | genre,
62 | genres,
63 | height,
64 | isAuthenticated,
65 | isFetching,
66 | isMobile,
67 | isPlaying,
68 | navigateTo,
69 | likes,
70 | login,
71 | playingSongId,
72 | playlist,
73 | playlistNextUrl,
74 | playSong,
75 | search,
76 | showLikes,
77 | showPlaylist,
78 | showStream,
79 | sticky,
80 | songs,
81 | time,
82 | times,
83 | toggleLike,
84 | } = this.props;
85 |
86 | return (
87 |
88 |
100 |
101 |
116 |
117 |
118 | );
119 | }
120 | }
121 |
122 | Songs.defaultProps = defaultProps;
123 | Songs.propTypes = propTypes;
124 |
125 | export default stickyOnScroll(Songs, 50);
126 |
--------------------------------------------------------------------------------
/client/src/utils/PlaylistUtils.js:
--------------------------------------------------------------------------------
1 | import moment from 'moment';
2 | import { denormalize } from 'normalizr';
3 | import { SESSION_LIKES_URL, SESSION_STREAM_URL, SONGS_URL } from '../constants/ApiConstants';
4 | import { GENRE_PLAYLIST_TYPE, GENRE_QUERY_MAP, PLAYLIST_PLAYLIST_TYPE, SEARCH_PLAYLIST_TYPE, SESSION_LIKES_PLAYLIST, SESSION_STREAM_PLAYLIST } from '../constants/PlaylistConstants';
5 | import { songSchema } from '../constants/Schemas';
6 |
7 | const isFetching = (playlist, playlists) => (playlist in playlists
8 | ? playlists[playlist].isFetching
9 | : false
10 | );
11 |
12 | const genrePlaylistUrl = (genre, time) => {
13 | const genreUriSegment = `&tags=${GENRE_QUERY_MAP[genre] || genre}`;
14 | const timeUriSegment = time ? `&created_at[from]=${moment().subtract(Number(time), 'days').format('YYYY-MM-DD%2012:00:00')}` : '';
15 |
16 | return `${SONGS_URL}${timeUriSegment}${genreUriSegment}`;
17 | };
18 |
19 | const searchPlaylistUrl = (search, time) => {
20 | const searchUriSegment = `&q=${search}`;
21 | const timeUriSegment = time ? `&created_at[from]=${moment().subtract(Number(time), 'days').format('YYYY-MM-DD%2012:00:00')}` : '';
22 |
23 | return `${SONGS_URL}${timeUriSegment}${searchUriSegment}`;
24 | };
25 |
26 | const playlistUrl = (playlist) => {
27 | const [type] = playlist.split('|');
28 |
29 | if (SESSION_STREAM_PLAYLIST) {
30 | return SESSION_STREAM_URL;
31 | }
32 |
33 | switch (type) {
34 | case GENRE_PLAYLIST_TYPE:
35 | return genrePlaylistUrl(playlist);
36 | case SEARCH_PLAYLIST_TYPE:
37 | return searchPlaylistUrl(playlist);
38 | default:
39 | return '';
40 | }
41 | };
42 |
43 | const playlistNextUrl = (
44 | playlist,
45 | playlists,
46 | oauthToken,
47 | ) => (playlist in playlists && playlists[playlist].nextUrl
48 | ? `${playlists[playlist].nextUrl}${oauthToken ? `&oauth_token=${oauthToken}` : ''}`
49 | : null
50 | );
51 |
52 | const playlistSongs = (playlist, playlists, entities) => (playlist in playlists
53 | ? denormalize(playlists[playlist].items, [songSchema], entities)
54 | : []
55 | );
56 |
57 | export const playlistData = (
58 | genre,
59 | search,
60 | showLike,
61 | showPlaylist,
62 | showStream,
63 | time,
64 | entities,
65 | id,
66 | oauthToken,
67 | playlists,
68 | ) => {
69 | if (showLike) {
70 | const playlist = SESSION_LIKES_PLAYLIST;
71 | return {
72 | isFetching: isFetching(playlist, playlists),
73 | playlist,
74 | playlistUrl: `${SESSION_LIKES_URL}?oauth_token=${oauthToken}`,
75 | playlistNextUrl: null,
76 | songs: playlistSongs(playlist, playlists, entities),
77 | };
78 | }
79 |
80 | if (showPlaylist) {
81 | const playlist = `${PLAYLIST_PLAYLIST_TYPE}|${id}`;
82 | return {
83 | isFetching: isFetching(playlist, playlists),
84 | playlist,
85 | playlistUrl: null,
86 | playlistNextUrl: null,
87 | songs: playlistSongs(playlist, playlists, entities),
88 | };
89 | }
90 |
91 | if (showStream) {
92 | const playlist = SESSION_STREAM_PLAYLIST;
93 | return {
94 | isFetching: isFetching(playlist, playlists),
95 | playlist,
96 | playlistUrl: `${SESSION_STREAM_URL}?oauth_token=${oauthToken}`,
97 | playlistNextUrl: playlistNextUrl(playlist, playlists, oauthToken),
98 | songs: playlistSongs(playlist, playlists, entities),
99 | };
100 | }
101 |
102 | if (search) {
103 | const playlist = [SEARCH_PLAYLIST_TYPE, search, time].join('|');
104 | return {
105 | isFetching: isFetching(playlist, playlists),
106 | playlist,
107 | playlistUrl: searchPlaylistUrl(search, time),
108 | playlistNextUrl: playlistNextUrl(playlist, playlists),
109 | songs: playlistSongs(playlist, playlists, entities),
110 | };
111 | }
112 |
113 | const playlist = [GENRE_PLAYLIST_TYPE, genre, time].join('|');
114 | return {
115 | isFetching: isFetching(playlist, playlists),
116 | playlist,
117 | playlistUrl: genrePlaylistUrl(genre, time),
118 | playlistNextUrl: playlistNextUrl(playlist, playlists),
119 | songs: playlistSongs(playlist, playlists, entities),
120 | };
121 | };
122 |
123 | export default playlistUrl;
124 |
--------------------------------------------------------------------------------
/client/src/components/audio.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 |
4 | const propTypes = {
5 | audioUrl: PropTypes.string.isRequired,
6 | onLoadedMetadata: PropTypes.func.isRequired,
7 | onLoadStart: PropTypes.func.isRequired,
8 | onPause: PropTypes.func.isRequired,
9 | onPlay: PropTypes.func.isRequired,
10 | onTimeUpdate: PropTypes.func.isRequired,
11 | onVolumeChange: PropTypes.func.isRequired,
12 | playNextSong: PropTypes.func.isRequired,
13 | };
14 |
15 | const audio = (InnerComponent) => {
16 | class AudioComponent extends Component {
17 | constructor() {
18 | super();
19 | this.audioElement = null;
20 |
21 | this.onEnded = this.onEnded.bind(this);
22 | this.onLoadedMetadata = this.onLoadedMetadata.bind(this);
23 | this.onLoadStart = this.onLoadStart.bind(this);
24 | this.onPause = this.onPause.bind(this);
25 | this.onPlay = this.onPlay.bind(this);
26 | this.onTimeUpdate = this.onTimeUpdate.bind(this);
27 | this.onVolumeChange = this.onVolumeChange.bind(this);
28 |
29 | this.changeCurrentTime = this.changeCurrentTime.bind(this);
30 | this.changeVolume = this.changeVolume.bind(this);
31 | this.toggleMuted = this.toggleMuted.bind(this);
32 | this.togglePlay = this.togglePlay.bind(this);
33 | }
34 |
35 | componentDidMount() {
36 | const { audioElement } = this;
37 | audioElement.play();
38 | }
39 |
40 | componentDidUpdate(prevProps) {
41 | const { audioElement, props } = this;
42 | const { audioUrl } = props;
43 | if (prevProps.audioUrl !== audioUrl) {
44 | audioElement.play();
45 | }
46 | }
47 |
48 | onEnded() {
49 | const { playNextSong } = this.props;
50 | playNextSong();
51 | }
52 |
53 | onLoadedMetadata() {
54 | const { audioElement, props } = this;
55 | const { onLoadedMetadata } = props;
56 | onLoadedMetadata(Math.floor(audioElement.duration));
57 | }
58 |
59 | onLoadStart() {
60 | const { onLoadStart } = this.props;
61 | onLoadStart();
62 | }
63 |
64 | onPlay() {
65 | const { onPlay } = this.props;
66 | onPlay();
67 | }
68 |
69 | onPause() {
70 | const { onPause } = this.props;
71 | onPause();
72 | }
73 |
74 | onTimeUpdate() {
75 | const { audioElement, props } = this;
76 | const { onTimeUpdate } = props;
77 | onTimeUpdate(Math.floor(audioElement.currentTime));
78 | }
79 |
80 | onVolumeChange() {
81 | const { audioElement, props } = this;
82 | const { muted, volume } = audioElement;
83 | const { onVolumeChange } = props;
84 | onVolumeChange(muted, volume);
85 | }
86 |
87 | changeCurrentTime(currentTime) {
88 | this.audioElement.currentTime = currentTime;
89 | }
90 |
91 | changeVolume(volume) {
92 | const { audioElement } = this;
93 | audioElement.muted = false;
94 | audioElement.volume = volume;
95 | }
96 |
97 | toggleMuted() {
98 | const { audioElement } = this;
99 | const { muted } = audioElement;
100 | audioElement.muted = !muted;
101 | }
102 |
103 | togglePlay() {
104 | const { audioElement } = this;
105 | if (audioElement.paused) {
106 | audioElement.play();
107 | } else {
108 | audioElement.pause();
109 | }
110 | }
111 |
112 | render() {
113 | const { audioUrl } = this.props;
114 |
115 | return (
116 |
117 |
138 | );
139 | }
140 | }
141 |
142 | AudioComponent.propTypes = propTypes;
143 |
144 | return AudioComponent;
145 | };
146 |
147 | export default audio;
148 |
--------------------------------------------------------------------------------
/client/src/components/Player.jsx:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React from 'react';
3 | import audio from '../components/audio';
4 | import Link from '../components/Link';
5 | import Slider from '../components/Slider';
6 | import { SONG_PATH, USER_PATH } from '../constants/RouterConstants';
7 | import { formatSeconds } from '../utils/NumberUtils';
8 | import volumeClassName from '../utils/PlayerUtils';
9 |
10 | const propTypes = {
11 | changeCurrentTime: PropTypes.func.isRequired,
12 | changeVolume: PropTypes.func.isRequired,
13 | navigateTo: PropTypes.func.isRequired,
14 | player: PropTypes.shape({}).isRequired,
15 | playNextSongFromButton: PropTypes.func.isRequired,
16 | playPrevSong: PropTypes.func.isRequired,
17 | showHistory: PropTypes.bool.isRequired,
18 | song: PropTypes.shape({}).isRequired,
19 | toggleMuted: PropTypes.func.isRequired,
20 | togglePlay: PropTypes.func.isRequired,
21 | toggleRepeat: PropTypes.func.isRequired,
22 | toggleShowHistory: PropTypes.func.isRequired,
23 | toggleShuffle: PropTypes.func.isRequired,
24 | };
25 |
26 | const Player = ({
27 | changeCurrentTime,
28 | changeVolume,
29 | navigateTo,
30 | player,
31 | playNextSongFromButton,
32 | playPrevSong,
33 | showHistory,
34 | song,
35 | toggleMuted,
36 | togglePlay,
37 | toggleRepeat,
38 | toggleShowHistory,
39 | toggleShuffle,
40 | }) => {
41 | const { currentTime, duration, isPlaying, muted, repeat, shuffle } = player;
42 | const { artworkUrl, id, title, user } = song;
43 | const { username } = user;
44 | const volume = muted ? 0 : player.volume;
45 |
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 |
59 | {title}
60 |
61 |
67 | {username}
68 |
69 |
70 |
71 |
72 |
73 |
74 |
80 |
81 |
82 |
88 |
89 |
90 |
96 |
97 |
98 |
99 |
100 |
101 |
106 |
107 |
108 |
109 | {formatSeconds(currentTime)}
110 |
111 | /
112 |
113 | {formatSeconds(duration)}
114 |
115 |
116 |
117 |
118 |
124 |
125 |
126 |
132 |
133 |
134 |
140 |
141 |
142 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
159 |
160 |
161 |
162 | );
163 | };
164 |
165 | Player.propTypes = propTypes;
166 |
167 | export default audio(Player);
168 |
--------------------------------------------------------------------------------