├── public
├── favicon.ico
├── images
│ ├── og.png
│ ├── music.jpg
│ ├── photo_add.png
│ ├── playlist_add.jpg
│ ├── more_circle.svg
│ ├── play.svg
│ ├── pause.svg
│ ├── skip_back.svg
│ ├── play_next.svg
│ ├── add_to_playlist.svg
│ ├── chevron_wide.svg
│ ├── skip_next.svg
│ ├── logo_js.svg
│ ├── trash.svg
│ ├── logo_apple.svg
│ ├── logo_music.svg
│ └── note.svg
├── manifest.json
└── index.html
├── src
├── js
│ ├── components
│ │ ├── bar
│ │ │ ├── actions.js
│ │ │ ├── reducer.js
│ │ │ ├── index.js
│ │ │ └── components
│ │ │ │ └── controls
│ │ │ │ ├── track_info.js
│ │ │ │ ├── cover.js
│ │ │ │ ├── volume_slider
│ │ │ │ └── index.js
│ │ │ │ ├── track_buttons.js
│ │ │ │ ├── index.js
│ │ │ │ ├── scrubber.js
│ │ │ │ └── mini_controls.js
│ │ ├── welcome
│ │ │ └── index.js
│ │ └── header
│ │ │ └── index.js
│ ├── toolbox
│ │ ├── components
│ │ │ ├── icon
│ │ │ │ ├── README.md
│ │ │ │ └── index.js
│ │ │ ├── title
│ │ │ │ └── index.js
│ │ │ ├── options_button
│ │ │ │ └── index.js
│ │ │ ├── api
│ │ │ │ └── index.js
│ │ │ ├── file_input
│ │ │ │ └── index.js
│ │ │ ├── playlist_button
│ │ │ │ └── index.js
│ │ │ ├── album_button
│ │ │ │ └── index.js
│ │ │ └── button
│ │ │ │ └── index.js
│ │ ├── constants
│ │ │ ├── index.js
│ │ │ ├── animation.js
│ │ │ └── color.js
│ │ └── index.js
│ ├── views
│ │ ├── actions.js
│ │ ├── index.js
│ │ ├── reducer.js
│ │ ├── library
│ │ │ ├── index.js
│ │ │ └── recently_played.js
│ │ ├── artist_list
│ │ │ └── index.js
│ │ ├── album_list
│ │ │ └── index.js
│ │ ├── artist
│ │ │ └── index.js
│ │ ├── playlist_list
│ │ │ └── index.js
│ │ ├── view_container.js
│ │ ├── album
│ │ │ └── index.js
│ │ └── playlist
│ │ │ └── index.js
│ ├── audio
│ │ ├── actions.js
│ │ └── reducer.js
│ ├── rootReducer.js
│ ├── index.js
│ ├── popups
│ │ ├── components
│ │ │ └── header
│ │ │ │ └── index.js
│ │ ├── index.js
│ │ ├── options
│ │ │ └── index.js
│ │ ├── playlist_selector
│ │ │ └── index.js
│ │ └── playlist_creator
│ │ │ └── index.js
│ └── api
│ │ ├── reducer.js
│ │ └── actions.js
├── index.js
├── App.js
└── registerServiceWorker.js
├── .gitignore
├── package.json
├── LICENSE
└── README.md
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tvillarete/apple-music-js/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/images/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tvillarete/apple-music-js/HEAD/public/images/og.png
--------------------------------------------------------------------------------
/public/images/music.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tvillarete/apple-music-js/HEAD/public/images/music.jpg
--------------------------------------------------------------------------------
/src/js/components/bar/actions.js:
--------------------------------------------------------------------------------
1 | export const toggleFullscreen = () => ({ type: 'TOGGLE_FULLSCREEN' });
2 |
--------------------------------------------------------------------------------
/public/images/photo_add.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tvillarete/apple-music-js/HEAD/public/images/photo_add.png
--------------------------------------------------------------------------------
/public/images/playlist_add.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tvillarete/apple-music-js/HEAD/public/images/playlist_add.jpg
--------------------------------------------------------------------------------
/src/js/toolbox/components/icon/README.md:
--------------------------------------------------------------------------------
1 | ```
2 |
3 | ```
4 |
5 | ```
6 |
7 | ```
--------------------------------------------------------------------------------
/src/js/views/actions.js:
--------------------------------------------------------------------------------
1 | export const pushView = view => ({ type: 'PUSH_VIEW', view });
2 | export const popView = () => ({ type: 'POP_VIEW' });
3 | export const pushPopup = popup => ({ type: 'PUSH_POPUP', popup });
4 | export const popPopup = () => ({ type: 'POP_POPUP' });
5 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/title/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Text = styled.h1`
5 | font-size: 32px;
6 | margin: 8px 0;
7 | `;
8 |
9 | const Title = ({ label }) => {
10 | return {label}
11 | }
12 |
13 | export default Title;
14 |
--------------------------------------------------------------------------------
/src/js/views/index.js:
--------------------------------------------------------------------------------
1 | export { default as Library } from './library';
2 | export { default as Artists } from './artist_list';
3 | export { default as Artist } from './artist';
4 | export { default as Albums } from './album_list';
5 | export { default as Album } from './album';
6 | export { default as Playlists } from './playlist_list';
7 | export { default as Playlist } from './playlist';
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env.local
15 | .env.development.local
16 | .env.test.local
17 | .env.production.local
18 |
19 | npm-debug.log*
20 | yarn-debug.log*
21 | yarn-error.log*
22 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/public/images/more_circle.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 | import registerServiceWorker from './registerServiceWorker';
5 | import { Provider } from 'react-redux';
6 | import store from './js/rootReducer';
7 |
8 | ReactDOM.render(
9 |
10 |
11 | ,
12 | document.getElementById('root')
13 | );
14 | registerServiceWorker();
15 |
--------------------------------------------------------------------------------
/src/js/components/bar/reducer.js:
--------------------------------------------------------------------------------
1 | const initialState = {
2 | isFullscreen: false
3 | };
4 |
5 | const navReducer = (state = initialState, action) => {
6 | switch (action.type) {
7 | case 'TOGGLE_FULLSCREEN':
8 | return {
9 | ...state,
10 | isFullscreen: !state.isFullscreen
11 | }
12 | default:
13 | return state;
14 | }
15 | };
16 |
17 | export default navReducer;
18 |
--------------------------------------------------------------------------------
/public/images/play.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/src/js/toolbox/constants/index.js:
--------------------------------------------------------------------------------
1 | /*
2 | import borderRadius from './borderRadius';
3 | import breakpoint from './breakpoint';
4 | */
5 | import color from './color';
6 | import animation from './animation';
7 | /*
8 | import fontSize from './fontSize';
9 | import lineHeight from './lineHeight';
10 | import shadows from './shadows';
11 | import spacing from './spacing';
12 | import transition from './transition';
13 | */
14 |
15 | export default {
16 | /*
17 | borderRadius,
18 | breakpoint,
19 | */
20 | color,
21 | animation
22 | /*
23 | fontSize,
24 | lineHeight,
25 | shadows,
26 | spacing,
27 | transition,
28 | */
29 | };
30 |
--------------------------------------------------------------------------------
/src/js/audio/actions.js:
--------------------------------------------------------------------------------
1 | export const playSong = ({ playlist, index }) => {
2 | return dispatch => {
3 | dispatch({
4 | type: 'PLAY_SONG',
5 | playlist,
6 | index,
7 | });
8 | };
9 | };
10 |
11 | export const resume = () => ({ type: 'RESUME' });
12 | export const pause = () => ({ type: 'PAUSE' });
13 | export const nextSong = () => ({ type: 'NEXT_SONG' });
14 | export const prevSong = () => ({ type: 'PREV_SONG' });
15 | export const addToQueue = track => ({ type: 'ADD_TO_QUEUE', track });
16 | export const changeVolume = volume => ({ type: 'CHANGE_VOLUME', volume });
17 | export const updateTime = info => ({ type: 'UPDATE_TIME', info });
18 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import MusicJS from './js';
3 | import { injectGlobal } from 'styled-components';
4 |
5 | injectGlobal`
6 | body {
7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
8 | -webkit-tap-highlight-color: rgba(0,0,0,0);
9 | }
10 | `;
11 |
12 | class App extends Component {
13 | render() {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 | }
21 |
22 | document.addEventListener('touchstart', function() {}, true);
23 |
24 | export default App;
25 |
--------------------------------------------------------------------------------
/public/images/pause.svg:
--------------------------------------------------------------------------------
1 |
18 |
--------------------------------------------------------------------------------
/src/js/rootReducer.js:
--------------------------------------------------------------------------------
1 | import { createStore, combineReducers, applyMiddleware } from 'redux';
2 | import thunkMiddleware from 'redux-thunk';
3 | import { devToolsEnhancer } from 'redux-devtools-extension';
4 | import viewReducer from './views/reducer';
5 | import apiReducer from './api/reducer';
6 | import audioReducer from './audio/reducer';
7 | import navReducer from './components/bar/reducer';
8 |
9 | const rootReducer = combineReducers({
10 | viewState: viewReducer,
11 | apiState: apiReducer,
12 | audioState: audioReducer,
13 | navState: navReducer,
14 | });
15 |
16 | const store = createStore(
17 | rootReducer,
18 | devToolsEnhancer(),
19 | applyMiddleware(thunkMiddleware),
20 | );
21 |
22 | export default store;
23 |
--------------------------------------------------------------------------------
/public/images/skip_back.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/js/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 | import WelcomeScreen from './components/welcome';
4 | import ViewContainer from './views/view_container';
5 | import Header from './components/header';
6 | import PopupContainer from './popups';
7 | import BottomBar from './components/bar';
8 |
9 | const Container = styled.div`
10 | position: fixed;
11 | top: 0;
12 | bottom: 0;
13 | left: 0;
14 | right: 0;
15 | display: flex;
16 | flex-direction: column;
17 | `;
18 |
19 | export default class MusicJS extends Component {
20 | render() {
21 | return (
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/js/popups/components/header/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Container = styled.div`
5 | position: relative;
6 | display: flex;
7 | height: 48px;
8 | padding: 0 16px;
9 | background: ${props => props.color};
10 | `;
11 |
12 | const Section = styled.div`
13 | display: flex;
14 | flex: 1;
15 | align-items: center;
16 |
17 | &:nth-child(2) {
18 | justify-content: center;
19 | }
20 |
21 | &:last-child {
22 | justify-content: flex-end;
23 | }
24 | `;
25 |
26 | const Header = ({ left, center, right, color }) => {
27 | return (
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default Header;
37 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "apple-music-js",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "http://tannerv.com/music",
6 | "dependencies": {
7 | "feather-icons": "^4.7.3",
8 | "react": "^16.4.1",
9 | "react-dom": "^16.12.0",
10 | "react-lazy-load": "^3.0.13",
11 | "react-rangeslider": "^2.2.0",
12 | "react-redux": "^5.0.7",
13 | "react-scripts": "^2.1.8",
14 | "redux": "^4.0.0",
15 | "redux-thunk": "^2.3.0",
16 | "styled-components": "^3.3.3"
17 | },
18 | "scripts": {
19 | "start": "react-scripts start",
20 | "build": "react-scripts build",
21 | "test": "react-scripts test --env=jsdom",
22 | "eject": "react-scripts eject"
23 | },
24 | "devDependencies": {
25 | "redux-devtools-extension": "^2.13.5"
26 | },
27 | "browserslist": [
28 | ">0.2%",
29 | "not dead",
30 | "not ie <= 11",
31 | "not op_mini all"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/public/images/play_next.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/public/images/add_to_playlist.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/js/views/reducer.js:
--------------------------------------------------------------------------------
1 | const initialState = {
2 | stack: [
3 | {
4 | name: 'Library',
5 | props: {},
6 | },
7 | ],
8 | popupStack: [],
9 | };
10 |
11 | const viewReducer = (state = initialState, action) => {
12 | switch (action.type) {
13 | case 'PUSH_VIEW':
14 | return {
15 | ...state,
16 | stack: state.stack.concat(action.view),
17 | };
18 | case 'POP_VIEW':
19 | return {
20 | ...state,
21 | stack: state.stack.slice(0, state.stack.length - 1),
22 | };
23 | case 'PUSH_POPUP':
24 | return {
25 | ...state,
26 | popupStack: state.popupStack.concat(action.popup),
27 | };
28 | case 'POP_POPUP':
29 | return {
30 | ...state,
31 | popupStack: state.popupStack.slice(0, state.popupStack.length - 1),
32 | };
33 | default:
34 | return state;
35 | }
36 | };
37 |
38 | export default viewReducer;
39 |
--------------------------------------------------------------------------------
/src/js/toolbox/constants/animation.js:
--------------------------------------------------------------------------------
1 | import { keyframes } from 'styled-components';
2 |
3 | export default {
4 | fadeIn: keyframes`
5 | 0% {
6 | opacity: 0;
7 | }
8 | `,
9 |
10 | fadeOut: keyframes`
11 | 100% {
12 | opacity: 0;
13 | }
14 | `,
15 |
16 | scale: keyframes`
17 | 0% {
18 | transform: scale(0);
19 | opacity: 0;
20 | }
21 | `,
22 |
23 | scaleOut: keyframes`
24 | 100% {
25 | transform: scale(0);
26 | opacity: 0;
27 | }
28 | `,
29 |
30 | slideInFromBottom: keyframes`
31 | 0% {
32 | transform: translateY(100vh);
33 | }
34 | `,
35 |
36 | slideOutToBottom: keyframes`
37 | 100% {
38 | transform: translateY(100vh);
39 | }
40 | `,
41 |
42 | slideInFromRight: keyframes`
43 | 0% {
44 | transform: translateX(100vw);
45 | }
46 | `,
47 |
48 | slideOutToRight: keyframes`
49 | 100% {
50 | transform: translateX(100vw);
51 | }
52 | `,
53 | };
54 |
--------------------------------------------------------------------------------
/src/js/components/bar/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 | import { connect } from 'react-redux';
4 | import Controls from './components/controls';
5 | import Cover from './components/controls/cover';
6 |
7 | const OuterContainer = styled.div`
8 | z-index: 50;
9 | position: fixed;
10 | bottom: 0;
11 | left: 0;
12 | right: 0;
13 | height: 64px;
14 | display: flex;
15 | justify-content: flex-start;
16 | align-items: center;
17 |
18 | @media screen and (max-width: 750px) {
19 | height: 7em;
20 | flex-direction: column-reverse;
21 | }
22 | `;
23 |
24 | const mapStateToProps = state => {
25 | return {
26 | audioState: state.audioState,
27 | navState: state.navState,
28 | }
29 | }
30 |
31 | class BottomBar extends Component {
32 | render() {
33 | return (
34 |
35 |
36 |
37 |
38 | );
39 | }
40 | }
41 |
42 | export default connect(mapStateToProps)(BottomBar)
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Tanner Villarete
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/public/images/chevron_wide.svg:
--------------------------------------------------------------------------------
1 |
19 |
--------------------------------------------------------------------------------
/public/images/skip_next.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
45 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/options_button/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import constants from '../../constants';
4 |
5 | const { color } = constants;
6 |
7 | const Container = styled.div`
8 | display: flex;
9 | justify-content: ${props => props.center && 'center'};
10 | justify-content: ${props => props.hasImage && 'space-between'};
11 | min-height: 64px;
12 | border-bottom: 1px solid ${color.gray[3]};
13 | padding: 0 16px;
14 | cursor: pointer;
15 |
16 | &:active {
17 | background: ${color.gray[2]};
18 | }
19 | `;
20 |
21 | const TextContainer = styled.div`
22 | display: flex;
23 | align-items: center;
24 | `;
25 |
26 | const Icon = styled.img`
27 | height: 30px;
28 | width: 40px;
29 | margin: auto 0;
30 | `;
31 |
32 | const Label = styled.h2`
33 | margin: 0;
34 | font-size: 1.3rem;
35 | color: ${color.red[4]};
36 | font-weight: ${props => props.bold ? 'bold' : 'normal'};
37 | `;
38 |
39 | const OptionsButton = ({ label, icon, image, center, bold, onClick }) => {
40 | return (
41 |
42 |
43 |
44 |
45 | {image && }
46 |
47 | );
48 | };
49 |
50 | export default OptionsButton;
51 |
--------------------------------------------------------------------------------
/public/images/logo_js.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/api/index.js:
--------------------------------------------------------------------------------
1 | class ApiClass {
2 | constructor() {
3 | this.url = `https://57c62f9e.ngrok.io/api`;
4 | }
5 |
6 | makeRequest = ({ url, data }) => {
7 | return new Promise(function(resolve, reject) {
8 | fetch(url, data)
9 | .then(response => {
10 | response.json().then(data => {
11 | if (response.status >= 300) {
12 | reject(data.message);
13 | }
14 | resolve(data);
15 | });
16 | })
17 | .catch(e => {
18 | reject(Error(e));
19 | });
20 | });
21 | };
22 |
23 | fetchArtists = () => {
24 | return this.makeRequest({
25 | url: `${this.url}/artists`,
26 | data: {
27 | method: "GET"
28 | }
29 | });
30 | };
31 |
32 | fetchArtist = artist => {
33 | return this.makeRequest({
34 | url: `${this.url}/artist/${artist}`,
35 | data: {
36 | method: "GET"
37 | }
38 | });
39 | };
40 |
41 | fetchAlbums = () => {
42 | return this.makeRequest({
43 | url: `${this.url}/albums`,
44 | data: {
45 | method: "GET"
46 | }
47 | });
48 | };
49 |
50 | fetchAlbum = ({ album }) => {
51 | return this.makeRequest({
52 | url: `${this.url}/album/${album}`,
53 | data: {
54 | method: "GET"
55 | }
56 | });
57 | };
58 | }
59 |
60 | const Api = new ApiClass();
61 | export default Api;
62 |
--------------------------------------------------------------------------------
/public/images/trash.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/images/logo_apple.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/icon/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import styled from 'styled-components';
4 | import feather from 'feather-icons';
5 |
6 | const SvgContainer = styled.span`
7 | display: inline-block;
8 | width: ${props => props.size}px;
9 | height: ${props => props.size}px;
10 | color: ${props => props.color};
11 | stroke-width: ${props => props.strokeWidth};
12 | `;
13 |
14 | const Icon = props => (
15 |
29 | );
30 |
31 | Icon.propTypes = {
32 | /** Icon name. See the complete list of icons at https://feathericons.com */
33 | name: PropTypes.string.isRequired,
34 | /** Set class name of containing element. */
35 | className: PropTypes.string,
36 | /** Any legal CSS color value for the stroke color */
37 | color: PropTypes.string,
38 | /** Width and height of the icon in pixels */
39 | size: PropTypes.number,
40 | /** Stroke width of the icon in pixels */
41 | strokeWidth: PropTypes.number,
42 | };
43 |
44 | Icon.defaultProps = {
45 | className: '',
46 | color: 'currentColor',
47 | size: 24,
48 | strokeWidth: 2,
49 | };
50 |
51 | export default Icon;
52 |
--------------------------------------------------------------------------------
/public/images/logo_music.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/js/views/library/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { pushView } from '../actions';
5 | import { Button } from '../../toolbox';
6 | import RecentlyPlayed from './recently_played';
7 |
8 | const Container = styled.div`
9 | margin-top: 48px;
10 | `;
11 |
12 | const ButtonContainer = styled.div``;
13 |
14 | class LibraryView extends Component {
15 | changeView = name => {
16 | this.props.pushView({
17 | name,
18 | props: {}
19 | });
20 | }
21 |
22 | render() {
23 | return (
24 |
25 |
26 |
45 |
46 |
47 | );
48 | }
49 | }
50 |
51 | const mapStateToProps = state => {
52 | return {
53 | viewState: state.viewState,
54 | };
55 | };
56 |
57 | const mapDispatchToProps = dispatch => {
58 | return {
59 | pushView: view => dispatch(pushView(view)),
60 | }
61 | }
62 |
63 | export default connect(mapStateToProps, mapDispatchToProps)(LibraryView);
64 |
--------------------------------------------------------------------------------
/src/js/toolbox/index.js:
--------------------------------------------------------------------------------
1 | //import './plugins';
2 |
3 | /**
4 | * Exporting glamor instance for external use.
5 | * https://github.com/threepointone/glamor/issues/290#issuecomment-358400717
6 | * */
7 | export { default as Button } from './components/button';
8 | export { default as AlbumButton } from './components/album_button';
9 | export { default as PlaylistButton } from './components/playlist_button';
10 | export { default as OptionsButton } from './components/options_button';
11 | export { default as Title } from './components/title';
12 | export { default as Api } from './components/api';
13 | export { default as Icon } from './components/icon';
14 | export { default as FileInput } from './components/file_input';
15 | /*
16 | export { default as glamorous } from 'glamorous';
17 |
18 | export { default as Avatar } from './components/Avatar/Avatar';
19 | export { default as ButtonGroup } from './components/ButtonGroup/ButtonGroup';
20 | export { default as Checkbox } from './components/Checkbox/Checkbox';
21 | export { default as Grid } from './components/Grid/Grid';
22 | export { default as Label } from './components/Label/Label';
23 | export { default as PageNavBar } from './components/PageNavBar/PageNavBar';
24 | export { default as PillBadge } from './components/PillBadge/PillBadge';
25 | export { default as Radio } from './components/Radio/Radio';
26 | export { default as RadioGroup } from './components/RadioGroup/RadioGroup';
27 | export { default as SearchInput } from './components/SearchInput/SearchInput';
28 | export { default as Tab } from './components/Tab/Tab';
29 | export { default as Tabs } from './components/Tabs/Tabs';
30 | export { default as Textarea } from './components/Textarea/Textarea';
31 | export { default as TextField } from './components/TextField/TextField';
32 | */
33 |
34 | export { default as constants } from './constants';
35 |
--------------------------------------------------------------------------------
/src/js/components/bar/components/controls/track_info.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 | import { connect } from 'react-redux';
4 | import { constants } from '../../../../toolbox';
5 | import { resume, pause, nextSong } from '../../../../audio/actions';
6 |
7 | const { color } = constants;
8 |
9 | const Container = styled.div`
10 | margin-top: 16px;
11 | `;
12 |
13 | const Title = styled.h3`
14 | text-align: center;
15 | margin: 0;
16 | `;
17 |
18 | const Subtitle = styled.h3`
19 | color: ${color.red[4]};
20 | font-weight: normal;
21 | text-align: center;
22 | margin: 8px 0 0 0;
23 | `;
24 |
25 | const mapStateToProps = state => {
26 | return {
27 | audioState: state.audioState,
28 | navState: state.navState,
29 | };
30 | };
31 |
32 | const mapDispatchToProps = dispatch => {
33 | return {
34 | resume: () => dispatch(resume()),
35 | pause: () => dispatch(pause()),
36 | nextSong: () => dispatch(nextSong()),
37 | };
38 | };
39 |
40 | class TrackInfo extends Component {
41 | render() {
42 | const { audioState } = this.props;
43 | const { queue, inQueue, hasAudio, playlist, currentIndex } = audioState;
44 | const track =
45 | queue.length && inQueue
46 | ? queue[0]
47 | : hasAudio
48 | ? playlist[currentIndex]
49 | : {
50 | name: 'Apple Music.js',
51 | artist: 'Tanner Villarete',
52 | album: 'Cal Poly',
53 | };
54 |
55 | return (
56 |
57 | {track.name}
58 | {`${track.artist} — ${track.album}`}
59 |
60 | );
61 | }
62 | }
63 |
64 | export default connect(mapStateToProps, mapDispatchToProps)(TrackInfo);
65 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/file_input/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 |
4 | const Container = styled.div`
5 | display: flex;
6 | height: 150px;
7 | width: 150px;
8 | justify-content: center;
9 | align-items: center;
10 | overflow: hidden;
11 | border-radius: 4px;
12 | `;
13 |
14 | const Input = styled.input`
15 | display: none;
16 | `;
17 |
18 | const Label = styled.label`
19 | height: 100%;
20 | width: 100%;
21 | position: relative;
22 | cursor: pointer;
23 |
24 | img {
25 | max-height: 100%;
26 | }
27 |
28 | &:active {
29 | filter: brightness(0.95);
30 | }
31 | `;
32 |
33 | class FileInput extends Component {
34 | constructor(props) {
35 | super(props);
36 | this.state = {
37 | img: props.img,
38 | };
39 | }
40 |
41 | base64Encode = img => {
42 | const reader = new FileReader();
43 | reader.readAsDataURL(img);
44 | reader.onload = () => this.props.onUpload(reader.result);
45 | reader.onerror = function(error) {
46 | console.log('Error: ', error);
47 | };
48 | };
49 |
50 | sendImage = img => {
51 | this.base64Encode(img);
52 | this.setState({ img: window.URL.createObjectURL(img) });
53 | };
54 |
55 | render() {
56 | const { img } = this.state;
57 |
58 | return (
59 |
60 |
63 | this.sendImage(e.target.files[0])}
68 | />
69 |
70 | );
71 | }
72 | }
73 |
74 | export default FileInput;
75 |
--------------------------------------------------------------------------------
/src/js/views/artist_list/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { fetchArtists } from '../../api/actions';
5 | import { pushView } from '../actions';
6 | import { Button } from '../../toolbox';
7 |
8 | const Container = styled.div`
9 | margin-top: 48px;
10 | `;
11 |
12 | const ButtonContainer = styled.div``;
13 |
14 | class ArtistListView extends Component {
15 | viewArtist = artist => {
16 | this.props.pushView({
17 | name: 'Artist',
18 | title: artist,
19 | props: {
20 | artist
21 | }
22 | });
23 | }
24 |
25 | componentDidMount() {
26 | if (!this.props.apiState.data.artists.length) {
27 | this.props.fetchArtists();
28 | }
29 | }
30 |
31 | render() {
32 | const { artists } = this.props.apiState.data;
33 |
34 | return (
35 |
36 |
37 | {artists &&
38 | artists.map((artist, index) => (
39 | this.viewArtist(artist.artist)}
44 | />
45 | ))}
46 |
47 |
48 | );
49 | }
50 | }
51 |
52 | const mapStateToProps = state => {
53 | return {
54 | viewState: state.viewState,
55 | apiState: state.apiState,
56 | };
57 | };
58 |
59 | const mapDispatchToProps = dispatch => {
60 | return {
61 | pushView: view => dispatch(pushView(view)),
62 | fetchArtists: () => dispatch(fetchArtists()),
63 | };
64 | };
65 |
66 | export default connect(mapStateToProps, mapDispatchToProps)(ArtistListView);
67 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/playlist_button/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import constants from '../../constants';
4 | import Icon from '../icon';
5 |
6 | const { color } = constants;
7 |
8 | const Container = styled.div`
9 | display: flex;
10 | height: 112px;
11 | cursor: pointer;
12 |
13 | &:active {
14 | background: ${color.gray[2]};
15 | }
16 | `;
17 |
18 | const ImgContainer = styled.div`
19 | position: relative;
20 | display: flex;
21 | justify-content: center;
22 | align-items: center;
23 | height: 104px;
24 | width: 104px;
25 | border-radius: 4px;
26 | overflow: hidden;
27 | `;
28 |
29 | const Artwork = styled.img`
30 | height: 100%;
31 | width: 100%;
32 | pointer-events: none;
33 | user-select: none;
34 | `;
35 |
36 | const TextContainer = styled.div`
37 | display: flex;
38 | align-items: center;
39 | flex: 1;
40 | margin: 8px 0 4px 16px;
41 | border-bottom: 1px solid ${color.gray[2]};
42 | `;
43 |
44 | const Title = styled.h3`
45 | font-weight: normal;
46 | margin: 0;
47 | user-select: none;
48 | color: ${props => props.color ? color[props.color][4] : 'black'};
49 | `;
50 |
51 | const ChevronContainer = styled.div`
52 | height: 100%;
53 | width: 3em;
54 | display: flex;
55 | justify-content: center;
56 | align-items: center;
57 |
58 | svg {
59 | color: ${color.gray[4]};
60 | height: 20px;
61 | width: 20px;
62 | }
63 | `;
64 |
65 | const PlaylistButton = ({
66 | title,
67 | img,
68 | color,
69 | chevron,
70 | onClick,
71 | }) => {
72 | return (
73 |
74 |
75 |
76 |
77 |
78 | {title}
79 |
80 | {chevron && (
81 |
82 |
83 |
84 | )}
85 |
86 | );
87 | };
88 |
89 | export default PlaylistButton;
90 |
--------------------------------------------------------------------------------
/src/js/components/bar/components/controls/cover.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { toggleFullscreen } from '../../actions';
5 | import { constants } from '../../../../toolbox';
6 |
7 | const { color, animation } = constants;
8 | const { fadeIn, fadeOut } = animation;
9 |
10 | const Container = styled.div`
11 | position: fixed;
12 | display: ${props => !props.isOpen && 'none'};
13 | top: 0;
14 | bottom: 0;
15 | left: 0;
16 | right: 0;
17 | background: ${color.grayAlpha[4]};
18 | cursor: pointer;
19 | animation: ${props => (props.isClosing ? fadeOut : fadeIn)} 0.35s ease;
20 | `;
21 |
22 | const mapStateToProps = state => {
23 | return {
24 | navState: state.navState,
25 | };
26 | };
27 |
28 | const mapDispatchToProps = dispatch => {
29 | return {
30 | toggleFullscreen: () => dispatch(toggleFullscreen()),
31 | };
32 | };
33 |
34 | class Cover extends Component {
35 | constructor(props) {
36 | super(props);
37 | const { navState } = props;
38 | const { isFullscreen } = navState;
39 |
40 | this.state = {
41 | isOpen: isFullscreen,
42 | isClosing: false,
43 | };
44 | }
45 |
46 | static getDerivedStateFromProps(nextProps, prevState) {
47 | return {
48 | isOpen: nextProps.navState.isFullscreen,
49 | isClosing: !nextProps.navState.isFullscreen && prevState.isOpen,
50 | };
51 | }
52 |
53 | animateClosed() {
54 | setTimeout(() => {
55 | this.setState({
56 | isOpen: false,
57 | isClosing: false,
58 | });
59 | }, 330);
60 | }
61 |
62 | componentDidUpdate(nextProps) {
63 | if (this.state.isClosing) {
64 | this.animateClosed();
65 | }
66 | }
67 |
68 | render() {
69 | const { isOpen, isClosing } = this.state;
70 | return (
71 |
76 | );
77 | }
78 | }
79 |
80 | export default connect(mapStateToProps, mapDispatchToProps)(Cover);
81 |
--------------------------------------------------------------------------------
/src/js/views/library/recently_played.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { pushView } from '../actions';
5 | import { AlbumButton } from '../../toolbox';
6 |
7 | const Container = styled.div`
8 | margin-top: 24px;
9 | `;
10 |
11 | const Header = styled.h3`
12 | margin: 8px 0;
13 | font-size: 21px;
14 | `;
15 |
16 | const ButtonContainer = styled.div`
17 | display: flex;
18 | flex-wrap: wrap;
19 | margin-top: 16px;
20 |
21 | div {
22 | margin-left: 0;
23 | }
24 | `;
25 |
26 | const mapStateToProps = state => {
27 | return {
28 | audioState: state.audioState,
29 | };
30 | };
31 |
32 | const mapDispatchToProps = dispatch => {
33 | return {
34 | pushView: view => dispatch(pushView(view)),
35 | };
36 | };
37 |
38 | class RecentlyPlayed extends Component {
39 | viewAlbum = ({ artist, album }) => {
40 | this.props.pushView({
41 | name: 'Album',
42 | title: album,
43 | props: {
44 | hideTitle: true,
45 | artist,
46 | album
47 | }
48 | });
49 | }
50 |
51 | render() {
52 | const { audioState } = this.props;
53 | const { recents } = audioState;
54 |
55 | return (
56 |
57 |
58 |
59 | {recents &&
60 | recents.map((item, index) => {
61 | const { album, artist, artwork } = item;
62 | const url = `https://tannerv.ddns.net/SpotiFree/${artwork}`;
63 |
64 | return (
65 | this.viewAlbum({ artist, album })}
71 | />
72 | );
73 | })}
74 |
75 |
76 | );
77 | }
78 | }
79 |
80 | export default connect(mapStateToProps, mapDispatchToProps)(RecentlyPlayed);
81 |
--------------------------------------------------------------------------------
/src/js/toolbox/constants/color.js:
--------------------------------------------------------------------------------
1 | export default {
2 | blue: [
3 | '#EBF4FB',
4 | '#D6E8F7',
5 | '#ADD2EF',
6 | '#5CA4E0',
7 | '#0071CE',
8 | '#005091',
9 | '#00325C',
10 | ],
11 | blueAlpha: [
12 | 'rgba(0, 113, 206, 0.08)',
13 | 'rgba(0, 113, 206, 0.16)',
14 | 'rgba(0, 113, 206, 0.32)',
15 | 'rgba(0, 113, 206, 0.64)',
16 | ],
17 | green: [
18 | '#F2F9ED',
19 | '#E4F3DC',
20 | '#C9E7B9',
21 | '#93D073',
22 | '#57B524',
23 | '#35820F',
24 | '#195103',
25 | ],
26 | greenAlpha: [
27 | 'rgba(87, 181, 36, 0.08)',
28 | 'rgba(87, 181, 36, 0.16)',
29 | 'rgba(87, 181, 36, 0.32)',
30 | 'rgba(87, 181, 36, 0.64)',
31 | ],
32 | red: [
33 | '#FDEEEC',
34 | '#FBDED9',
35 | '#F8BDB4',
36 | '#F07B69',
37 | '#f62a54',
38 | '#A11601',
39 | '#610B00',
40 | ],
41 | redAlpha: [
42 | 'rgba(232, 48, 20, 0.08)',
43 | 'rgba(232, 48, 20, 0.16)',
44 | 'rgba(232, 48, 20, 0.32)',
45 | 'rgba(232, 48, 20, 0.64)',
46 | ],
47 | yellow: [
48 | '#FFFAEC',
49 | '#FEF5D9',
50 | '#FDECB3',
51 | '#FBD966',
52 | '#F9C310',
53 | '#C27E00',
54 | '#914400',
55 | ],
56 | yellowAlpha: [
57 | 'rgba(249, 195, 16, 0.08)',
58 | 'rgba(249, 195, 16, 0.16)',
59 | 'rgba(249, 195, 16, 0.32)',
60 | 'rgba(249, 195, 16, 0.64)',
61 | ],
62 | gray: [
63 | '#FAFAFA',
64 | '#F5F5F5',
65 | '#EEEEEE',
66 | '#E1E1E2',
67 | '#BDBEBF',
68 | '#9E9FA0',
69 | '#757778',
70 | '#636567',
71 | '#424446',
72 | '#212426',
73 | ],
74 | grayAlpha: [
75 | 'rgba(0, 3, 6, 0.02)',
76 | 'rgba(0, 3, 6, 0.04)',
77 | 'rgba(0, 3, 6, 0.07)',
78 | 'rgba(0, 3, 6, 0.12)',
79 | 'rgba(0, 3, 6, 0.26)',
80 | 'rgba(0, 3, 6, 0.38)',
81 | 'rgba(0, 3, 6, 0.54)',
82 | 'rgba(0, 3, 6, 0.61)',
83 | 'rgba(0, 3, 6, 0.74)',
84 | 'rgba(0, 3, 6, 0.87)',
85 | ],
86 | whiteAlpha: [
87 | 'rgba(255, 255, 255, 0.25)',
88 | 'rgba(255, 255, 255, 0.5)',
89 | 'rgba(255, 255, 255, 0.75)',
90 | 'rgba(255, 255, 255, 0.97)',
91 | ],
92 | white: '#FFFFFF',
93 | black: '#000000',
94 | };
95 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
25 |
26 |
27 |
36 | Music.js
37 |
38 |
39 |
42 |
43 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/src/js/components/bar/components/controls/volume_slider/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import Slider from 'react-rangeslider';
5 | import 'react-rangeslider/lib/index.css';
6 | import { changeVolume } from '../../../../../audio/actions';
7 | import { constants } from '../../../../../toolbox';
8 |
9 | const { color } = constants;
10 |
11 | const Container = styled.div`
12 | height: 48px;
13 | width: 90%;
14 | margin: auto;
15 | display: flex;
16 |
17 | .rangeslider-horizontal.time-slider,
18 | .volume-slider {
19 | height: 4px;
20 | width: 90%;
21 | margin: auto;
22 | box-shadow: none;
23 |
24 | .rangeslider__fill {
25 | background: ${color.gray[6]};
26 | box-shadow: none;
27 | }
28 |
29 | .rangeslider__handle {
30 | width: 7px;
31 | height: 7px;
32 | box-shadow: none;
33 | background: ${color.gray[6]};
34 | border: none;
35 | outline: none;
36 |
37 | &::active {
38 | height: 30px;
39 | width: 30px;
40 | }
41 |
42 | &::after {
43 | display: none;
44 | }
45 | }
46 | }
47 |
48 | .volume-slider .rangeslider__handle {
49 | width: 30px;
50 | height: 30px;
51 | background: white;
52 | border: 1px solid rgba(0, 0, 0, 0.12);
53 | box-shadow: 0 3px 3px rgb(150, 150, 150);
54 | }
55 | `;
56 |
57 | const mapStateToProps = state => {
58 | return {
59 | audioState: state.audioState,
60 | };
61 | };
62 |
63 | const mapDispatchToProps = dispatch => {
64 | return {
65 | changeVolume: volume => dispatch(changeVolume(volume)),
66 | };
67 | };
68 |
69 | class VolumeSlider extends Component {
70 | handleChange = val => {
71 | this.props.changeVolume(val / 100);
72 | };
73 |
74 | render() {
75 | const { audioState } = this.props;
76 | const { volume } = audioState;
77 |
78 | return (
79 |
80 |
86 |
87 | );
88 | }
89 | }
90 |
91 | export default connect(mapStateToProps, mapDispatchToProps)(VolumeSlider);
92 |
--------------------------------------------------------------------------------
/public/images/note.svg:
--------------------------------------------------------------------------------
1 |
42 |
43 |
--------------------------------------------------------------------------------
/src/js/api/reducer.js:
--------------------------------------------------------------------------------
1 | const initialState = {
2 | data: {
3 | artists: [],
4 | albums: [],
5 | playlists: {},
6 | artistData: {},
7 | albumData: {},
8 | },
9 | };
10 |
11 | const apiReducer = (state = initialState, action) => {
12 | switch (action.type) {
13 | case 'FETCH_ARTIST_LIST_SUCCESS':
14 | return {
15 | ...state,
16 | data: {
17 | ...state.data,
18 | artists: action.artists,
19 | artistData: action.artistData,
20 | },
21 | };
22 | case 'FETCH_ARTIST_SUCCESS':
23 | return {
24 | ...state,
25 | data: {
26 | ...state.data,
27 | artistData: {
28 | ...state.data.artistData,
29 | [action.name]: action.albums,
30 | },
31 | albumData: {
32 | ...state.data.albumData,
33 | ...action.albumData,
34 | },
35 | },
36 | };
37 | case 'FETCH_ALBUM_LIST_SUCCESS':
38 | return {
39 | ...state,
40 | data: {
41 | ...state.data,
42 | albumData: {
43 | ...state.data.albumData,
44 | ...action.albumData,
45 | },
46 | albums: action.albums,
47 | },
48 | };
49 | case 'FETCH_ALBUM_SUCCESS':
50 | return {
51 | ...state,
52 | data: {
53 | ...state.data,
54 | albumData: {
55 | ...state.data.albumData,
56 | [action.album]: action.tracks,
57 | },
58 | },
59 | };
60 | case 'FETCH_PLAYLIST_LIST_SUCCESS':
61 | return {
62 | ...state,
63 | data: {
64 | ...state.data,
65 | playlists: action.playlists,
66 | },
67 | };
68 | case 'CREATE_PLAYLIST':
69 | return {
70 | ...state,
71 | data: {
72 | ...state.data,
73 | playlists: action.playlists,
74 | },
75 | };
76 | case 'UPDATE_PLAYLIST':
77 | return {
78 | ...state,
79 | playlists: action.playlists,
80 | };
81 | default:
82 | return state;
83 | }
84 | };
85 |
86 | export default apiReducer;
87 |
--------------------------------------------------------------------------------
/src/js/views/album_list/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { fetchAlbums } from '../../api/actions';
5 | import { pushView } from '../actions';
6 | import { AlbumButton, constants } from '../../toolbox';
7 |
8 | const { color } = constants;
9 |
10 | const Container = styled.div`
11 | margin-top: 48px;
12 | border-top: 1px solid ${color.gray[2]};
13 | `;
14 |
15 | const ButtonContainer = styled.div`
16 | display: flex;
17 | flex-wrap: wrap;
18 | margin-top: 16px;
19 | `;
20 |
21 | const mapStateToProps = state => {
22 | return {
23 | viewState: state.viewState,
24 | apiState: state.apiState,
25 | };
26 | };
27 |
28 | const mapDispatchToProps = dispatch => {
29 | return {
30 | pushView: view => dispatch(pushView(view)),
31 | fetchAlbums: () => dispatch(fetchAlbums()),
32 | };
33 | };
34 |
35 | class AlbumListView extends Component {
36 | viewAlbum = ({ artist, album }) => {
37 | this.props.pushView({
38 | name: 'Album',
39 | title: album,
40 | props: {
41 | hideTitle: true,
42 | artist,
43 | album
44 | }
45 | });
46 | }
47 |
48 | componentDidMount() {
49 | const { apiState } = this.props;
50 | const { albums } = apiState.data;
51 |
52 | if (albums.length === 0) {
53 | this.props.fetchAlbums();
54 | }
55 | }
56 |
57 | render() {
58 | const { apiState } = this.props;
59 | const { albums } = apiState.data;
60 |
61 | return (
62 |
63 |
64 | {albums &&
65 | albums.map((item, index) => {
66 | const { album, artist, artwork } = item;
67 | const url = `https://tannerv.ddns.net/SpotiFree/${artwork}`;
68 |
69 | return (
70 | this.viewAlbum({ artist, album })}
76 | />
77 | );
78 | })}
79 |
80 |
81 | );
82 | }
83 | }
84 |
85 | export default connect(mapStateToProps, mapDispatchToProps)(AlbumListView);
86 |
--------------------------------------------------------------------------------
/src/js/popups/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import PlaylistCreator from './playlist_creator';
5 | import PlaylistSelector from './playlist_selector';
6 | import OptionsMenu from './options';
7 |
8 | const popups = {
9 | 'Playlist Creator': ,
10 | 'Playlist Selector': ,
11 | 'Options':
12 | };
13 |
14 | const Container = styled.div``;
15 |
16 | const mapStateToProps = state => {
17 | return {
18 | viewState: state.viewState,
19 | };
20 | };
21 |
22 | const PopupStack = connect(mapStateToProps)(({ popupStack, closing }) => {
23 | return popupStack.map(({ name, props }, index) => {
24 | const popup = popups[name];
25 | props.index = index;
26 | props.closing = index === popupStack.length-1 && closing;
27 | props.key = `popup-${index}`;
28 |
29 | try {
30 | return React.cloneElement(popup, props)
31 | } catch (e) {
32 | console.error('Error: This popup is broken: ', popup);
33 | return null;
34 | }
35 | });
36 | });
37 |
38 | class PopupContainer extends Component {
39 | constructor(props) {
40 | super(props);
41 | const { viewState } = props;
42 | const { popupStack } = viewState;
43 |
44 | this.state = {
45 | popupStack,
46 | closing: false,
47 | };
48 | }
49 |
50 | static getDerivedStateFromProps(nextProps, prevState) {
51 | const { viewState } = nextProps;
52 | const { popupStack } = viewState;
53 | const closing = popupStack.length < prevState.popupStack.length;
54 |
55 | return {
56 | popupStack: closing ? prevState.popupStack : popupStack,
57 | closing
58 | }
59 | }
60 |
61 | animateClose() {
62 | const { viewState } = this.props;
63 | const { popupStack } = viewState;
64 |
65 | setTimeout(() => {
66 | this.setState({
67 | popupStack,
68 | closing: false
69 | });
70 | }, 280);
71 | }
72 |
73 | componentDidUpdate(nextProps, prevState) {
74 | if (this.state.closing) {
75 | this.animateClose();
76 | }
77 | }
78 |
79 | render() {
80 | const { popupStack, closing } = this.state;
81 |
82 | return (
83 |
84 |
85 |
86 | );
87 | }
88 | }
89 |
90 | export default connect(mapStateToProps)(PopupContainer);
91 |
--------------------------------------------------------------------------------
/src/js/views/artist/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { fetchArtist } from '../../api/actions';
5 | import { pushView } from '../actions';
6 | import { AlbumButton, constants } from '../../toolbox';
7 |
8 | const { color } = constants;
9 |
10 | const Container = styled.div`
11 | margin-top: 48px;
12 | border-top: 1px solid ${color.gray[2]};
13 | `;
14 |
15 | const ButtonContainer = styled.div`
16 | display: flex;
17 | flex-wrap: wrap;
18 | margin-top: 16px;
19 | `;
20 |
21 | class ArtistView extends Component {
22 | viewAlbum = ({ artist, album }) => {
23 | this.props.pushView({
24 | name: 'Album',
25 | title: album,
26 | props: {
27 | hideTitle: true,
28 | artist,
29 | album
30 | }
31 | });
32 | }
33 |
34 | componentDidMount() {
35 | const { artist, apiState } = this.props;
36 | const { artistData } = apiState.data;
37 |
38 | if (artistData[artist].length === 0) {
39 | this.props.fetchArtist(artist);
40 | }
41 | }
42 |
43 | render() {
44 | const { artist, apiState } = this.props;
45 | const { artistData } = apiState.data;
46 | const albums = artistData[artist];
47 |
48 | return (
49 |
50 |
51 | {albums &&
52 | albums.map((item, index) => {
53 | const { album, artist, artwork } = item;
54 | const url = `https://tannerv.ddns.net/SpotiFree/${artwork}`;
55 |
56 | return (
57 | this.viewAlbum({ artist, album })}
63 | />
64 | );
65 | })}
66 |
67 |
68 | );
69 | }
70 | }
71 |
72 | const mapStateToProps = state => {
73 | return {
74 | viewState: state.viewState,
75 | apiState: state.apiState,
76 | };
77 | };
78 |
79 | const mapDispatchToProps = dispatch => {
80 | return {
81 | pushView: view => dispatch(pushView(view)),
82 | fetchArtist: artist => dispatch(fetchArtist(artist)),
83 | };
84 | };
85 |
86 | export default connect(mapStateToProps, mapDispatchToProps)(ArtistView);
87 |
--------------------------------------------------------------------------------
/src/js/components/bar/components/controls/track_buttons.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 | import { connect } from 'react-redux';
4 | import { resume, pause, nextSong, prevSong } from '../../../../audio/actions';
5 |
6 | const Container = styled.div`
7 | display: flex;
8 | justify-content: space-around;
9 | align-items: center;
10 | min-height: 14vh;
11 | margin-top: 16px;
12 | `;
13 |
14 | const Svg = styled.img`
15 | height: 32px;
16 | width: 32px;
17 | margin: 0 8px;
18 | cursor: pointer;
19 | `;
20 |
21 | const mapStateToProps = state => {
22 | return {
23 | audioState: state.audioState,
24 | navState: state.navState,
25 | };
26 | };
27 |
28 | const mapDispatchToProps = dispatch => {
29 | return {
30 | resume: () => dispatch(resume()),
31 | pause: () => dispatch(pause()),
32 | nextSong: () => dispatch(nextSong()),
33 | prevSong: () => dispatch(prevSong()),
34 | };
35 | };
36 |
37 | class TrackButtons extends Component {
38 | resume = () => {
39 | const { audioState } = this.props;
40 | const { hasAudio } = audioState;
41 |
42 | if (hasAudio) {
43 | this.props.resume();
44 | }
45 | }
46 |
47 | pause = () => {
48 | const { audioState } = this.props;
49 | const { hasAudio, isPlaying } = audioState;
50 |
51 | if (hasAudio && isPlaying) {
52 | this.props.pause();
53 | }
54 | }
55 |
56 | nextSong = () => {
57 | const { audioState } = this.props;
58 | const { hasAudio } = audioState;
59 |
60 | if (hasAudio) {
61 | this.props.nextSong();
62 | }
63 | };
64 |
65 | prevSong = () => {
66 | const { audioState } = this.props;
67 | const { hasAudio } = audioState;
68 |
69 | if (hasAudio) {
70 | this.props.prevSong();
71 | }
72 | };
73 |
74 | render() {
75 | const { audioState } = this.props;
76 | const { hasAudio, isPlaying } = audioState;
77 | const path = `images`;
78 |
79 | return (
80 |
81 |
82 | {!(!!hasAudio && !!isPlaying) && (
83 |
84 | )}
85 | {!!isPlaying && (
86 |
87 | )}
88 |
89 |
90 | );
91 | }
92 | }
93 |
94 | export default connect(mapStateToProps, mapDispatchToProps)(TrackButtons);
95 |
--------------------------------------------------------------------------------
/src/js/views/playlist_list/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { fetchPlaylists } from '../../api/actions';
5 | import { pushView, pushPopup } from '../actions';
6 | import { PlaylistButton } from '../../toolbox';
7 |
8 | const Container = styled.div`
9 | margin-top: 48px;
10 | `;
11 |
12 | const ButtonContainer = styled.div``;
13 |
14 | const mapStateToProps = state => {
15 | return {
16 | viewState: state.viewState,
17 | apiState: state.apiState,
18 | };
19 | };
20 |
21 | const mapDispatchToProps = dispatch => {
22 | return {
23 | pushView: view => dispatch(pushView(view)),
24 | pushPopup: popup => dispatch(pushPopup(popup)),
25 | fetchPlaylists: () => dispatch(fetchPlaylists()),
26 | };
27 | };
28 |
29 | class PlaylistListView extends Component {
30 | viewPlaylist = ({ playlist, index }) => {
31 | this.props.pushView({
32 | name: 'Playlist',
33 | title: playlist.title,
34 | props: {
35 | hideTitle: true,
36 | index,
37 | playlist,
38 | },
39 | });
40 | };
41 |
42 | newPlaylist = () => {
43 | this.props.pushPopup({
44 | name: 'Playlist Creator',
45 | props: {},
46 | });
47 | };
48 |
49 | componentDidMount() {
50 | this.props.fetchPlaylists();
51 | }
52 |
53 | getPlaylistButtons = () => {
54 | const playlists = localStorage.appleMusicPlaylists
55 | ? JSON.parse(localStorage.appleMusicPlaylists)
56 | : {};
57 | const playlistButtons = [];
58 |
59 | for (let [key, playlist] of Object.entries(playlists)) {
60 | playlistButtons.push(
61 | this.viewPlaylist({ playlist })}
67 | />,
68 | );
69 | }
70 | return playlistButtons;
71 | };
72 |
73 | render() {
74 | return (
75 |
76 |
77 |
84 | {this.getPlaylistButtons()}
85 |
86 |
87 | );
88 | }
89 | }
90 |
91 | export default connect(mapStateToProps, mapDispatchToProps)(PlaylistListView);
92 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/album_button/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 | import LazyLoad from 'react-lazy-load';
4 | import constants from '../../constants';
5 |
6 | const { color } = constants;
7 | const breakpointSm = `@media screen and (max-width: 750px)`;
8 |
9 | const Container = styled.div`
10 | margin: 0 16px 16px;
11 | width: 185px;
12 | cursor: pointer;
13 |
14 | ${breakpointSm} {
15 | margin: 16px 16px 0 0;
16 | width: auto;
17 | flex: 0 44%;
18 | }
19 |
20 | &:active {
21 | img {
22 | filter: brightness(65%);
23 | }
24 | }
25 | `;
26 |
27 | const ImgContainer = styled.div`
28 | position: relative;
29 | min-height: 185px;
30 | margin-bottom: 16px;
31 |
32 | ${breakpointSm} {
33 | height: 42vw;
34 | width: 42vw;
35 | margin-bottom: 0;
36 | }
37 | `;
38 |
39 | const Placeholder = styled.img`
40 | z-index: 1;
41 | position: absolute;
42 | top: 0;
43 | max-width: 100%;
44 | transition: all 0.3s;
45 | opacity: ${props => (props.isHidden ? 0 : 1)};
46 | `;
47 |
48 | const Artwork = styled.img`
49 | position: absolute;
50 | top: 0;
51 | border-radius: 4px;
52 | max-width: 100%;
53 | pointer-events: none;
54 | user-select: none;
55 | animation: {animation.fadeIn} 0.15s;
56 | `;
57 |
58 | const TextContainer = styled.div`
59 | margin: 4px 0;
60 | `;
61 |
62 | const Label = styled.h4`
63 | font-weight: normal;
64 | margin: 0 0 4px 0;
65 | user-select: none;
66 | `;
67 |
68 | const SubLabel = styled.h4`
69 | color: ${color.gray[5]};
70 | font-weight: normal;
71 | margin: 0;
72 | user-select: none;
73 | `;
74 |
75 | class AlbumButton extends Component {
76 | state = {
77 | loaded: false,
78 | };
79 |
80 | onLoad = () => {
81 | this.setState({ loaded: true });
82 | };
83 |
84 | render() {
85 | const { label, sublabel, artwork, onClick } = this.props;
86 | const { loaded } = this.state;
87 |
88 | return (
89 |
90 |
91 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | {sublabel}
102 |
103 |
104 | );
105 | }
106 | }
107 |
108 | export default AlbumButton;
109 |
--------------------------------------------------------------------------------
/src/js/toolbox/components/button/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import constants from '../../constants';
4 | import Icon from '../icon';
5 |
6 | const { color } = constants;
7 |
8 | const Container = styled.div`
9 | display: flex;
10 | min-height: 48px;
11 | border-bottom: 1px solid ${color.gray[3]};
12 | cursor: pointer;
13 |
14 | :first-of-type {
15 | border-top: 1px solid ${color.gray[3]};
16 | }
17 |
18 | &:active {
19 | background: ${color.gray[2]};
20 | }
21 | `;
22 |
23 | const TextContainer = styled.div`
24 | display: flex;
25 | flex: 1;
26 | flex-direction: column;
27 | justify-content: center;
28 | white-space: nowrap;
29 | overflow: hidden;
30 | text-overflow: ellipsis;
31 |
32 | h2,
33 | h4 {
34 | font-weight: normal;
35 | color: ${props =>
36 | props.theme && props.theme.length
37 | ? color[props.theme][4]
38 | : color.black};
39 | color: ${props => props.isPlaying && color.red[4]};
40 | user-select: none;
41 | }
42 | `;
43 |
44 | const Label = styled.h2`
45 | margin: 0;
46 | font-size: 1.3rem;
47 | `;
48 |
49 | const SubLabel = styled.h4`
50 | margin: 0;
51 | `;
52 |
53 | const OptionsContainer = styled.div`
54 | height: 3em;
55 | width: 3em;
56 | display: flex;
57 | justify-content: center;
58 | align-items: center;
59 | `;
60 |
61 | const ChevronContainer = styled.div`
62 | height: 3em;
63 | width: 3em;
64 | display: flex;
65 | justify-content: center;
66 | align-items: center;
67 |
68 | svg {
69 | color: ${color.gray[4]};
70 | height: 20px;
71 | width: 20px;
72 | }
73 | `;
74 |
75 | const Button = ({
76 | index,
77 | theme,
78 | label,
79 | sublabel,
80 | showIndex,
81 | isPlaying,
82 | OptionsMenu,
83 | onClick,
84 | chevron,
85 | onOptionsClick,
86 | }) => {
87 | const handleOptionsClick = e => {
88 | e.stopPropagation();
89 | onOptionsClick();
90 | };
91 |
92 | return (
93 |
94 |
95 |
96 | {sublabel}
97 |
98 | {OptionsMenu && (
99 |
100 |
101 |
102 | )}
103 | {chevron && (
104 |
105 |
106 |
107 | )}
108 |
109 | );
110 | };
111 |
112 | export default Button;
113 |
--------------------------------------------------------------------------------
/src/js/popups/options/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { constants, OptionsButton } from '../../toolbox';
5 | import { pushView, popPopup } from '../../views/actions';
6 |
7 | const { color, animation } = constants;
8 | const { slideInFromBottom, slideOutToBottom } = animation;
9 |
10 | const Container = styled.div`
11 | z-index: ${props => 100 + props.index};
12 | position: fixed;
13 | display: flex;
14 | justify-content: center;
15 | align-items: flex-end;
16 | top: 0;
17 | bottom: 0;
18 | left: 0;
19 | right: 0;
20 | `;
21 |
22 | const Cover = styled.div`
23 | z-index: 0;
24 | position: absolute;
25 | top: 0;
26 | bottom: 0;
27 | left: 0;
28 | right: 0;
29 | cursor: pointer;
30 | background: ${color.grayAlpha[5]};
31 | animation: ${props => (props.closing ? animation.fadeOut : animation.fadeIn)}
32 | 0.3s ease-in-out;
33 | `;
34 |
35 | const MenuContainer = styled.div`
36 | position: relative;
37 | width: 90%;
38 | max-width: 30em;
39 | margin-bottom: 16px;
40 | animation: ${props => (props.closing ? slideOutToBottom : slideInFromBottom)}
41 | 0.3s ease-in-out;
42 | overflow: hidden;
43 | `;
44 |
45 | const Section = styled.div`
46 | margin: 12px 0;
47 | background: white;
48 | border-radius: 20px;
49 | overflow: hidden;
50 |
51 | &:first-child {
52 | margin-top: 0;
53 | }
54 |
55 | &:last-child {
56 | margin-bottom: 0;
57 | }
58 | `;
59 |
60 | const mapStateToProps = state => {
61 | return {
62 | viewState: state.viewState,
63 | apiState: state.apiState,
64 | };
65 | };
66 |
67 | const mapDispatchToProps = dispatch => {
68 | return {
69 | pushView: view => dispatch(pushView(view)),
70 | popPopup: () => dispatch(popPopup()),
71 | };
72 | };
73 |
74 | class OptionsMenu extends Component {
75 | handleClick = onClick => {
76 | this.props.popPopup();
77 | setTimeout(() => {
78 | typeof onClick === 'function' && onClick();
79 | }, 250);
80 | };
81 |
82 | render() {
83 | const { index, closing, options } = this.props;
84 |
85 | return (
86 |
87 |
88 |
89 |
90 | {options &&
91 | options.map(option => (
92 | this.handleClick(option.onClick)}
97 | />
98 | ))}
99 |
100 |
108 |
109 |
110 | );
111 | }
112 | }
113 |
114 | export default connect(mapStateToProps, mapDispatchToProps)(OptionsMenu);
115 |
--------------------------------------------------------------------------------
/src/js/audio/reducer.js:
--------------------------------------------------------------------------------
1 | const initialState = {
2 | isPlaying: false,
3 | hasAudio: false,
4 | currentIndex: 0,
5 | playlist: [],
6 | inQueue: false,
7 | queue: [],
8 | prevQueue: [],
9 | time: {
10 | current: null,
11 | max: null
12 | },
13 | recents: localStorage.appleMusicRecents
14 | ? JSON.parse(localStorage.appleMusicRecents)
15 | : [],
16 | volume: 0.5,
17 | };
18 |
19 | const audioReducer = (state = initialState, action) => {
20 | switch (action.type) {
21 | case 'PLAY_SONG':
22 | let recents = state.recents.filter(
23 | (track, index) =>
24 | track.album !== action.playlist[action.index].album && index < 7,
25 | );
26 | recents.splice(0, 0, action.playlist[action.index]);
27 | localStorage.appleMusicRecents = JSON.stringify(recents);
28 |
29 | return {
30 | ...state,
31 | isPlaying: true,
32 | hasAudio: true,
33 | playlist: action.playlist,
34 | currentIndex: action.index,
35 | recents,
36 | };
37 | case 'RESUME':
38 | return {
39 | ...state,
40 | isPlaying: !!state.playlist.length || !!state.queue.length,
41 | };
42 | case 'PAUSE':
43 | return {
44 | ...state,
45 | isPlaying: false,
46 | };
47 | case 'NEXT_SONG':
48 | const newQueue = [...state.queue.slice(1, state.queue.length)];
49 |
50 | return {
51 | ...state,
52 | hasAudio: state.currentIndex + 1 < state.playlist.length,
53 | inQueue: state.queue.length,
54 | queue: state.inQueue ? newQueue : state.queue,
55 | isPlaying:
56 | !!state.isPlaying &&
57 | (state.currentIndex + 1 !== state.playlist.length ||
58 | !!state.queue.length),
59 | currentIndex:
60 | state.currentIndex +
61 | (state.inQueue && state.queue.length ? 0 : 1),
62 | };
63 | case 'PREV_SONG':
64 | return {
65 | ...state,
66 | inQueue: false,
67 | queue: state.inQueue
68 | ? [...state.queue.slice(1, state.queue.length)]
69 | : state.queue,
70 | currentIndex:
71 | state.currentIndex > 0
72 | ? state.currentIndex - 1
73 | : state.currentIndex,
74 | };
75 | case 'ADD_TO_QUEUE':
76 | return {
77 | ...state,
78 | hasAudio: true,
79 | inQueue: !state.playlist.length,
80 | prevQueue: state.queue,
81 | queue: state.inQueue
82 | ? [
83 | ...state.queue.slice(0, 1),
84 | action.track,
85 | ...state.queue.slice(2, state.length),
86 | ]
87 | : [action.track, ...state.queue],
88 | playlist: state.playlist,
89 | };
90 | case 'UPDATE_TIME':
91 | return {
92 | ...state,
93 | time: {
94 | ...action.info
95 | }
96 | }
97 | case 'CHANGE_VOLUME':
98 | return {
99 | ...state,
100 | volume: action.volume,
101 | };
102 | default:
103 | return state;
104 | }
105 | };
106 |
107 | export default audioReducer;
108 |
--------------------------------------------------------------------------------
/src/js/components/welcome/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 | import { constants } from '../../toolbox';
4 |
5 | const { animation } = constants;
6 | const breakpointSm = `@media screen and (max-width: 700px)`;
7 |
8 | const Container = styled.div`
9 | z-index: 200;
10 | position: fixed;
11 | display: ${props => props.isOpen ? 'flex' : 'none'};
12 | justify-content: center;
13 | align-items: center;
14 | top: 0;
15 | bottom: 0;
16 | left: 0;
17 | right: 0;
18 | background: white;
19 | animation: ${props => props.isClosing ? animation.fadeOut : null} 0.3s;
20 | `;
21 |
22 | const LogoContainer = styled.div`
23 | position: relative;
24 | display: flex;
25 | width: ${props => props.width};
26 | height: 3.5rem;
27 | padding-bottom: 1vh;
28 | overflow: hidden;
29 | transition: all 0.5s;
30 | animation: ${props => props.isClosing ? animation.scaleOut : animation.scale} 0.3s ease;
31 |
32 | ${breakpointSm} {
33 | height: 2rem;
34 | width: ${props => props.width === '18rem' && '10rem'};
35 | }
36 | `;
37 |
38 | const PartContainer = styled.div`
39 | position: relative;
40 | display: ${props => props.hidden ? 'none' : 'flex'};
41 | align-items: flex-end;
42 | height: 100%;
43 | background: white;
44 | `;
45 |
46 | const AppleLogo = styled.img`
47 | height: 100%;
48 | `;
49 |
50 | const MusicLogo = styled.img`
51 | width: 11rem;
52 | margin-left: 20px;
53 |
54 | ${breakpointSm} {
55 | width: 6rem;
56 | margin-left: 10px;
57 | }
58 | `;
59 |
60 | const JsLogo = styled.img`
61 | position: absolute;
62 | width: 2.25rem;
63 | top: 20px;
64 | animation: ${animation.scale} 0.3s ease;
65 |
66 | ${breakpointSm} {
67 | width: 1.5rem;
68 | top: 10px;
69 | }
70 | `;
71 |
72 | export default class WelcomScreen extends Component {
73 | state = {
74 | isOpen: true,
75 | isClosing: false,
76 | width: '4rem',
77 | showMusic: false,
78 | showExtension: false,
79 | }
80 |
81 | componentDidMount() {
82 | setTimeout(() => {
83 | this.setState({
84 | showMusic: true,
85 | width: '18rem'
86 | });
87 | }, 300);
88 | setTimeout(() => {
89 | this.setState({
90 | showExtension: true
91 | });
92 | }, 800);
93 | setTimeout(() => {
94 | this.setState({ isClosing: true });
95 | }, 1700);
96 | setTimeout(() => {
97 | this.setState({
98 | isClosing: false,
99 | isOpen: false,
100 | });
101 | }, 2000);
102 | }
103 |
104 | render() {
105 | const { isOpen, isClosing } = this.state;
106 |
107 | return (
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 | );
122 | }
123 | }
124 |
--------------------------------------------------------------------------------
/src/js/views/view_container.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { constants } from '../toolbox';
5 | import * as Views from './';
6 |
7 | const { animation } = constants;
8 | const { slideInFromRight, slideOutToRight } = animation;
9 |
10 | const Container = styled.div`
11 | position: relative;
12 | width: 100%;
13 | max-width: 1130px;
14 | flex: 1;
15 | overflow: auto;
16 | margin: 48px auto 64px auto;
17 | padding-left: 24px;
18 | overflow: hidden;
19 | `;
20 |
21 | const PageContainer = styled.div`
22 | z-index: ${props => (props.secondFromTop ? 0 : 1)};
23 | position: absolute;
24 | top: 0;
25 | bottom: 0;
26 | left: 0;
27 | right: 0;
28 | padding: 0 24px;
29 | background: white;
30 | transform: ${props =>
31 | props.secondFromTop && !props.becomingTop ? 'translateX(-20%)' : null};
32 | overflow: ${props => (props.secondFromTop ? 'hidden' : 'auto')};
33 | animation: ${props =>
34 | props.exiting ? slideOutToRight : slideInFromRight} 0.3s ease-in-out;
35 | transition: all 0.3s ease-in-out;
36 | -webkit-overflow-scrolling: touch;
37 | `;
38 |
39 | const mapStateToProps = state => {
40 | return {
41 | viewState: state.viewState,
42 | };
43 | };
44 |
45 | const ViewStack = connect(mapStateToProps)(({ stack, exiting }) => {
46 | return stack.map(({ name, props }, index) => {
47 | const View = Views[name];
48 | const secondFromTop = index !== stack.length - 1;
49 |
50 | try {
51 | return (
52 |
57 |
58 |
59 | );
60 | } catch (e) {
61 | console.error('Error: This view is empty: ', View);
62 | return null;
63 | }
64 | });
65 | });
66 |
67 | class ViewContainer extends Component {
68 | constructor(props) {
69 | super(props);
70 | const { viewState } = props;
71 | const { stack } = viewState;
72 |
73 | this.state = {
74 | stack,
75 | newStack: null,
76 | exiting: false,
77 | };
78 | }
79 |
80 | static getDerivedStateFromProps(nextProps, prevState) {
81 | const { viewState } = nextProps;
82 | const { stack } = viewState;
83 | const exiting = stack.length < prevState.stack.length;
84 |
85 | return {
86 | stack: exiting ? prevState.stack : stack,
87 | exiting,
88 | };
89 | }
90 |
91 | animateBack() {
92 | const { viewState } = this.props;
93 | const { stack } = viewState;
94 |
95 | setTimeout(() => {
96 | this.setState({
97 | stack,
98 | exiting: false,
99 | });
100 | }, 280);
101 | }
102 |
103 | componentDidUpdate(nextProps, prevState) {
104 | if (this.state.exiting) {
105 | this.animateBack();
106 | }
107 | }
108 |
109 | render() {
110 | const { stack, exiting } = this.state;
111 |
112 | return (
113 |
114 |
115 |
116 | );
117 | }
118 | }
119 |
120 | export default connect(mapStateToProps)(ViewContainer);
121 |
--------------------------------------------------------------------------------
/src/js/popups/playlist_selector/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { constants, PlaylistButton } from '../../toolbox';
5 | import { fetchPlaylists } from '../../api/actions';
6 | import { popPopup, pushPopup } from '../../views/actions';
7 | import Header from '../components/header';
8 |
9 | const { color, animation } = constants;
10 | const { slideInFromBottom, slideOutToBottom } = animation;
11 |
12 | const Container = styled.div`
13 | z-index: ${props => 100 + props.index};
14 | position: fixed;
15 | top: 0;
16 | bottom: 0;
17 | left: 0;
18 | right: 0;
19 | background: white;
20 | animation: ${props => (props.closing ? slideOutToBottom : slideInFromBottom)}
21 | 0.3s ease-in-out;
22 | `;
23 |
24 | const Button = styled.h3`
25 | margin: 0;
26 | color: ${color.red[4]};
27 | font-weight: ${props => props.bold && 'bold'};
28 | cursor: pointer;
29 | user-select: none;
30 | `;
31 |
32 | const Title = styled.h1`
33 | margin: 0 0 16px 0;
34 | padding: 0 0 16px 16px;
35 | border-bottom: 1px solid ${color.gray[2]};
36 | background: ${color.gray[1]};
37 | `;
38 |
39 | const ButtonContainer = styled.div`
40 | padding-left: 16px;
41 | `;
42 |
43 | const mapStateToProps = state => {
44 | return {
45 | apiState: state.apiState,
46 | };
47 | };
48 |
49 | const mapDispatchToProps = dispatch => {
50 | return {
51 | fetchPlaylists: () => dispatch(fetchPlaylists()),
52 | popPopup: () => dispatch(popPopup()),
53 | pushPopup: popup => dispatch(pushPopup(popup)),
54 | };
55 | };
56 |
57 | class PlaylistSelector extends Component {
58 | selectPlaylist = playlist => {
59 | this.props.onSelect(playlist);
60 | this.props.popPopup();
61 | };
62 |
63 | newPlaylist = () => {
64 | this.props.pushPopup({
65 | name: 'Playlist Creator',
66 | props: {},
67 | });
68 | };
69 |
70 | getPlaylistButtons = () => {
71 | const { playlists } = this.props.apiState.data;
72 | const playlistButtons = [];
73 |
74 | for (let [key, playlist] of Object.entries(playlists)) {
75 | playlistButtons.push(
76 | this.selectPlaylist(playlist)}
82 | />,
83 | );
84 | }
85 | return playlistButtons;
86 | };
87 |
88 | componentDidMount() {
89 | this.props.fetchPlaylists();
90 | }
91 |
92 | render() {
93 | const { index, closing } = this.props;
94 |
95 | return (
96 |
97 | Cancel}
100 | />
101 | Add to a Playlist
102 |
103 |
110 | {this.getPlaylistButtons()}
111 |
112 |
113 | );
114 | }
115 | }
116 |
117 | export default connect(mapStateToProps, mapDispatchToProps)(PlaylistSelector);
118 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | ---
3 | Built by **Tanner Villarete**
4 |
5 | Connect with me on [LinkedIn](https://linkedin.com/in/tvillarete)! I'll be graduating soon ;)
6 |
7 | # How far can JavaScript take us?
8 |
9 | Turns out, pretty dang far. This web app was my attempt at mimicking Apple's iOS music app, and I think I've come pretty close!
10 |
11 | Check out a live demo [here](http://tannerv.com/music)
12 |
13 | ---
14 | 
15 | ---
16 | 
17 | ---
18 | I'm in my fourth year of college, and it's been super cool to see how much I've improved and continue to improve all aspects of programming.
19 |
20 | ## Backend API
21 | The API is hosted on a Raspberry Pi, and it's kept private (but still accessible if you try) so that it doesn't get overloaded. If you're interested in building your own backend to plug into this tool, here's what my database and endpoints look like:
22 |
23 | ### Database
24 | There are six required columns:
25 | - `name`: The name of the song
26 | - `artist`: The artist name
27 | - `album`: The album name
28 | - `track`: The index of the song relative to the album (To order songs in an album)
29 | - `url`: A URL to the audio file
30 | - `artwork`: A URL to the album artwork image
31 | ```mysql
32 | mysql> use music;
33 |
34 | Database changed
35 | mysql> desc tracks;
36 | +------------+------------------+------+-----+---------+----------------+
37 | | Field | Type | Null | Key | Default | Extra |
38 | +------------+------------------+------+-----+---------+----------------+
39 | | id | int(10) unsigned | NO | PRI | NULL | auto_increment |
40 | | created_at | timestamp | YES | | NULL | |
41 | | updated_at | timestamp | YES | | NULL | |
42 | | name | varchar(255) | NO | | NULL | |
43 | | artist | varchar(255) | NO | | NULL | |
44 | | album | varchar(255) | NO | | NULL | |
45 | | track | int(11) | NO | | NULL | |
46 | | url | varchar(255) | NO | | NULL | |
47 | | artwork | varchar(255) | NO | | NULL | |
48 | +------------+------------------+------+-----+---------+----------------+
49 | 9 rows in set (0.05 sec)
50 | ```
51 |
52 | ### API Endpoints
53 | The backend is built with PHP using the Laravel ORM. I only needed a few API endpoints to get this working:
54 | - `/albums` - Returns a list of all album names with their corresponding artist
55 | - `/album/{album}` - Returns a list of songs from a specified album
56 | - `/artists` - Returns a list of all artists
57 | - `/artist/{artist}` - Returns a list of all albums from a specific artist
58 |
59 | Feel free to reach out if you have questions!
60 |
61 | ## Local Development
62 | To run this app locally, clone the project (or download it as a zip and unzip it to a directory), navigate to the root directory with a command prompt or terminal (where the package.json file is located), and run npm install to download the necessary dependencies onto your local machine.
63 |
64 | Inside the root directory, you can run some built-in commands:
65 |
66 | ```
67 | npm start
68 | ```
69 | Runs the app in development mode.
70 |
71 | Open http://localhost:3000 to view it in the browser.
72 |
73 | The page will automatically reload if you make changes to the code.
74 |
75 | You will see the build errors and lint warnings in the console.
76 |
77 | ```
78 | npm run build
79 | ```
80 | Builds the app for production to the build folder.
81 |
82 | It correctly bundles React in production mode and optimizes the build for the best performance.
83 |
84 | The build is minified and the filenames include the hashes.
85 |
86 | Your app is ready to be deployed.
87 |
--------------------------------------------------------------------------------
/src/js/popups/playlist_creator/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { constants, FileInput } from '../../toolbox';
5 | import { fetchPlaylists, createPlaylist } from '../../api/actions';
6 | import { pushView, popPopup } from '../../views/actions';
7 | import Header from '../components/header';
8 |
9 | const { color, animation } = constants;
10 | const { slideInFromBottom, slideOutToBottom } = animation;
11 |
12 | const Container = styled.div`
13 | z-index: ${props => 100 + props.index};
14 | position: fixed;
15 | top: 0;
16 | bottom: 0;
17 | left: 0;
18 | right: 0;
19 | background: white;
20 | animation: ${props => (props.closing ? slideOutToBottom : slideInFromBottom)}
21 | 0.3s ease-in-out;
22 | `;
23 |
24 | const Button = styled.h3`
25 | margin: 0;
26 | color: ${color.red[4]};
27 | font-weight: ${props => props.bold && 'bold'};
28 | cursor: pointer;
29 | user-select: none;
30 | `;
31 |
32 | const Title = styled.h3`
33 | margin: 0;
34 | `;
35 |
36 | const Section = styled.div`
37 | display: flex;
38 | padding: 0 16px;
39 | `;
40 |
41 | const TitleInput = styled.textarea`
42 | margin-left: 16px;
43 | -webkit-appearance: none;
44 | font-size: 24px;
45 | border: none;
46 | outline: none;
47 | flex: 1;
48 | resize: none;
49 | caret-color: ${color.red[4]};
50 | font-family: 'SF Pro Display';
51 | `;
52 |
53 | const DescriptionInput = styled.textarea`
54 | margin: 16px 0 16px 16px;
55 | -webkit-appearance: none;
56 | font-size: 24px;
57 | border: none;
58 | outline: none;
59 | flex: 1;
60 | resize: none;
61 | caret-color: ${color.red[4]};
62 | border-top: 1px solid ${color.gray[3]};
63 | border-bottom: 1px solid ${color.gray[3]};
64 | font-family: 'SF Pro Display';
65 | `;
66 |
67 | const mapStateToProps = state => {
68 | return {
69 | viewState: state.viewState,
70 | apiState: state.apiState,
71 | };
72 | };
73 |
74 | const mapDispatchToProps = dispatch => {
75 | return {
76 | pushView: view => dispatch(pushView(view)),
77 | popPopup: () => dispatch(popPopup()),
78 | fetchPlaylists: () => dispatch(fetchPlaylists()),
79 | createPlaylist: playlist => dispatch(createPlaylist(playlist)),
80 | };
81 | };
82 |
83 | class PlaylistCreator extends Component {
84 | state = {
85 | img: /* Base64 encoded string */ null,
86 | title: '',
87 | description: '',
88 | tracks: [],
89 | };
90 |
91 | handleImageUpload = img => {
92 | this.setState({ img });
93 | };
94 |
95 | createPlaylist = () => {
96 | const description = document.getElementById('playlist-description').value;
97 | const title = document.getElementById('playlist-title').value;
98 | const { img, tracks } = this.state;
99 |
100 | if (!title || !description) {
101 | alert("Make sure to add a title and description!");
102 | return;
103 | }
104 |
105 | const playlist = {
106 | title,
107 | description,
108 | img,
109 | tracks,
110 | };
111 |
112 | this.props.createPlaylist(playlist);
113 | this.props.popPopup();
114 | };
115 |
116 | render() {
117 | const { index, closing } = this.props;
118 |
119 | return (
120 |
121 | Cancel}
123 | center={New Playlist}
124 | right={
125 |
126 | Done
127 |
128 | }
129 | />
130 |
137 |
143 |
144 | );
145 | }
146 | }
147 |
148 | export default connect(mapStateToProps, mapDispatchToProps)(PlaylistCreator);
149 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (isLocalhost) {
36 | // This is running on localhost. Lets check if a service worker still exists or not.
37 | checkValidServiceWorker(swUrl);
38 |
39 | // Add some additional logging to localhost, pointing developers to the
40 | // service worker/PWA documentation.
41 | navigator.serviceWorker.ready.then(() => {
42 | console.log(
43 | 'This web app is being served cache-first by a service ' +
44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ'
45 | );
46 | });
47 | } else {
48 | // Is not local host. Just register service worker
49 | registerValidSW(swUrl);
50 | }
51 | });
52 | }
53 | }
54 |
55 | function registerValidSW(swUrl) {
56 | navigator.serviceWorker
57 | .register(swUrl)
58 | .then(registration => {
59 | registration.onupdatefound = () => {
60 | const installingWorker = registration.installing;
61 | installingWorker.onstatechange = () => {
62 | if (installingWorker.state === 'installed') {
63 | if (navigator.serviceWorker.controller) {
64 | // At this point, the old content will have been purged and
65 | // the fresh content will have been added to the cache.
66 | // It's the perfect time to display a "New content is
67 | // available; please refresh." message in your web app.
68 | console.log('New content is available; please refresh.');
69 | } else {
70 | // At this point, everything has been precached.
71 | // It's the perfect time to display a
72 | // "Content is cached for offline use." message.
73 | console.log('Content is cached for offline use.');
74 | }
75 | }
76 | };
77 | };
78 | })
79 | .catch(error => {
80 | console.error('Error during service worker registration:', error);
81 | });
82 | }
83 |
84 | function checkValidServiceWorker(swUrl) {
85 | // Check if the service worker can be found. If it can't reload the page.
86 | fetch(swUrl)
87 | .then(response => {
88 | // Ensure service worker exists, and that we really are getting a JS file.
89 | if (
90 | response.status === 404 ||
91 | response.headers.get('content-type').indexOf('javascript') === -1
92 | ) {
93 | // No service worker found. Probably a different app. Reload the page.
94 | navigator.serviceWorker.ready.then(registration => {
95 | registration.unregister().then(() => {
96 | window.location.reload();
97 | });
98 | });
99 | } else {
100 | // Service worker found. Proceed as normal.
101 | registerValidSW(swUrl);
102 | }
103 | })
104 | .catch(() => {
105 | console.log(
106 | 'No internet connection found. App is running in offline mode.'
107 | );
108 | });
109 | }
110 |
111 | export function unregister() {
112 | if ('serviceWorker' in navigator) {
113 | navigator.serviceWorker.ready.then(registration => {
114 | registration.unregister();
115 | });
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/js/api/actions.js:
--------------------------------------------------------------------------------
1 | import { Api } from '../toolbox';
2 |
3 | export const fetchArtists = () => {
4 | return dispatch => {
5 | return Api.fetchArtists()
6 | .then(artists => {
7 | // The albums of each artist
8 | const artistData = {};
9 |
10 | for (let i in artists) {
11 | const artist = artists[i].artist;
12 | artistData[artist] = [];
13 | }
14 |
15 | dispatch({
16 | type: 'FETCH_ARTIST_LIST_SUCCESS',
17 | artists,
18 | artistData,
19 | });
20 | })
21 | .catch(error => {
22 | console.log(error);
23 | });
24 | };
25 | };
26 |
27 | export const fetchArtist = artist => {
28 | return dispatch => {
29 | return Api.fetchArtist(artist)
30 | .then(albums => {
31 | const albumData = {};
32 |
33 | for (let i in albums) {
34 | const album = albums[i];
35 | albumData[album.album] = [];
36 | }
37 |
38 | dispatch({
39 | type: 'FETCH_ARTIST_SUCCESS',
40 | name: artist,
41 | albums,
42 | albumData,
43 | });
44 | })
45 | .catch(error => {
46 | console.log(error);
47 | });
48 | };
49 | };
50 |
51 | export const fetchAlbums = () => {
52 | return dispatch => {
53 | return Api.fetchAlbums()
54 | .then(albums => {
55 | const albumData = {};
56 |
57 | for (let album of albums) {
58 | const name = album.album;
59 | albumData[name] = [];
60 | }
61 |
62 | dispatch({
63 | type: 'FETCH_ALBUM_LIST_SUCCESS',
64 | albumData,
65 | albums,
66 | });
67 | })
68 | .catch(error => {
69 | console.log(error);
70 | });
71 | };
72 | };
73 |
74 | export const fetchAlbum = ({ artist, album }) => {
75 | return dispatch => {
76 | return Api.fetchAlbum({ artist, album })
77 | .then(tracks => {
78 | dispatch({
79 | type: 'FETCH_ALBUM_SUCCESS',
80 | album,
81 | tracks,
82 | });
83 | })
84 | .catch(error => {
85 | console.log(error);
86 | });
87 | };
88 | };
89 |
90 | export const fetchPlaylists = () => {
91 | return dispatch => {
92 | const playlists = localStorage.appleMusicPlaylists;
93 |
94 | dispatch({
95 | type: 'FETCH_PLAYLIST_LIST_SUCCESS',
96 | playlists: playlists ? JSON.parse(playlists) : [],
97 | });
98 | };
99 | };
100 |
101 | export const createPlaylist = playlist => {
102 | return dispatch => {
103 | let playlists = localStorage.appleMusicPlaylists
104 | ? JSON.parse(localStorage.appleMusicPlaylists)
105 | : {};
106 |
107 | playlists[playlist.title] = playlist;
108 | localStorage.appleMusicPlaylists = JSON.stringify(playlists);
109 |
110 | dispatch({
111 | type: 'CREATE_PLAYLIST',
112 | playlists,
113 | });
114 | };
115 | };
116 |
117 | export const addToPlaylist = (track, playlist) => {
118 | return dispatch => {
119 | let playlists = localStorage.appleMusicPlaylists;
120 | playlists = playlists ? JSON.parse(playlists) : playlists;
121 |
122 | // Add track to playlist
123 | playlist = {
124 | ...playlist,
125 | tracks: [...playlist.tracks, track],
126 | };
127 |
128 | // Update playlist in playlist list
129 | playlists = {
130 | ...playlists,
131 | [playlist.title]: playlist,
132 | };
133 |
134 | localStorage.appleMusicPlaylists = JSON.stringify(playlists);
135 |
136 | dispatch({
137 | type: 'UPDATE_PLAYLIST',
138 | playlists,
139 | });
140 | };
141 | };
142 |
143 | export const removeFromPlaylist = (index, playlist) => {
144 | return dispatch => {
145 | let playlists = localStorage.appleMusicPlaylists;
146 | playlists = playlists ? JSON.parse(playlists) : playlists;
147 |
148 | playlist = {
149 | ...playlist,
150 | tracks: [
151 | ...playlist.tracks.slice(0, index),
152 | ...playlist.tracks.slice(index + 1),
153 | ],
154 | };
155 |
156 | // Update playlist in playlist list
157 | playlists = {
158 | ...playlists,
159 | [playlist.title]: playlist,
160 | };
161 |
162 | localStorage.appleMusicPlaylists = JSON.stringify(playlists);
163 |
164 | dispatch({
165 | type: 'UPDATE_PLAYLIST',
166 | playlists,
167 | });
168 | };
169 | };
170 |
171 | export const deletePlaylist = playlist => {
172 | return dispatch => {
173 | let playlists = localStorage.appleMusicPlaylists;
174 | playlists = playlists ? JSON.parse(playlists) : playlists;
175 |
176 | delete playlists[playlist.title];
177 |
178 | localStorage.appleMusicPlaylists = JSON.stringify(playlists);
179 |
180 | dispatch({
181 | type: 'UPDATE_PLAYLIST',
182 | playlists,
183 | });
184 | };
185 | };
186 |
--------------------------------------------------------------------------------
/src/js/components/bar/components/controls/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled from 'styled-components';
3 | import { connect } from 'react-redux';
4 | import MiniControls from './mini_controls';
5 | import { toggleFullscreen } from '../../actions';
6 | import { nextSong, pause, updateTime } from '../../../../audio/actions';
7 | import TrackInfo from './track_info';
8 | import TrackButtons from './track_buttons';
9 | import VolumeSlider from './volume_slider';
10 | import Scrubber from './scrubber';
11 |
12 | const Container = styled.div`
13 | position: fixed;
14 | bottom: 0;
15 | right: 0;
16 | height: ${props => (props.isFullscreen ? '100%' : '64px')};
17 | width: 100%;
18 | max-width: 400px;
19 | background: #fff;
20 | transition: all 0.35s ease;
21 |
22 | @media screen and (max-width: 750px) {
23 | z-index: 0;
24 | max-width: none;
25 | }
26 | `;
27 |
28 | const Svg = styled.img``;
29 |
30 | const CloseControls = styled.div`
31 | display: flex;
32 | justify-content: center;
33 | align-items: center;
34 | height: ${props => (props.hidden ? '0' : '48px')};
35 | opacity: ${props => props.hidden && '0'};
36 | pointer-events: ${props => props.hidden && 'none'};
37 | transition: all 0.35s ease;
38 | cursor: pointer;
39 | `;
40 |
41 | const FullscreenControls = styled.div`
42 | opacity: ${props => props.hide && 0};
43 | transition: all 0.35s ease;
44 | margin-top: 5vh;
45 | `;
46 |
47 | const mapStateToProps = state => {
48 | return {
49 | audioState: state.audioState,
50 | navState: state.navState,
51 | };
52 | };
53 |
54 | const mapDispatchToProps = dispatch => {
55 | return {
56 | nextSong: () => dispatch(nextSong()),
57 | toggleFullscreen: () => dispatch(toggleFullscreen()),
58 | pause: () => dispatch(pause()),
59 | updateTime: info => dispatch(updateTime(info)),
60 | };
61 | };
62 |
63 | class Controls extends Component {
64 | constructor(props) {
65 | super(props);
66 | this.state = {
67 | volume: props.audioState.volume,
68 | duration: null,
69 | };
70 | }
71 |
72 | static getDerivedStateFromProps(nextProps, prevState) {
73 | return {
74 | volume: nextProps.volume,
75 | };
76 | }
77 |
78 | handlePlay = () => {
79 | if (this.audio.paused) {
80 | clearInterval(this.playInterval);
81 | const playPromise = this.audio.play();
82 | playPromise.then(() => {
83 | this.createTimeInterval();
84 | });
85 | }
86 | };
87 |
88 | handleSliderChange = e => {
89 | clearInterval(this.playInterval);
90 | this.audio.currentTime = e.target.value;
91 | this.createTimeInterval();
92 | };
93 |
94 | createTimeInterval() {
95 | this.playInterval = setInterval(() => {
96 | if (this.audio) {
97 | this.props.updateTime({
98 | current: this.audio.currentTime,
99 | max: this.audio.duration,
100 | });
101 | }
102 | }, 1000);
103 | }
104 |
105 | handlePause() {
106 | if (!this.audio.paused) {
107 | this.audio.pause();
108 | clearInterval(this.playInterval);
109 | }
110 | }
111 |
112 | nextSong = () => {
113 | this.props.nextSong();
114 | };
115 |
116 | changeVolume = val => {
117 | console.log(val);
118 | };
119 |
120 | componentDidUpdate(nextProps) {
121 | const { audioState, volume } = this.props;
122 | const { isPlaying } = audioState;
123 |
124 | if (this.audio && this.audio.volume !== volume) {
125 | this.audio.volume = nextProps.audioState.volume;
126 | }
127 |
128 | if (isPlaying && this.audio) {
129 | this.handlePlay();
130 | } else if (!isPlaying && this.audio && this.audio.src) {
131 | this.handlePause();
132 | }
133 | }
134 |
135 | render() {
136 | const { navState, audioState } = this.props;
137 | const { isFullscreen } = navState;
138 | const { queue, inQueue, playlist, currentIndex, volume } = audioState;
139 | const track =
140 | queue.length && inQueue
141 | ? queue[0]
142 | : !!playlist.length
143 | ? playlist[currentIndex]
144 | : null;
145 |
146 | return (
147 |
148 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 | {track && (
161 |
172 | );
173 | }
174 | }
175 |
176 | export default connect(mapStateToProps, mapDispatchToProps)(Controls);
177 |
--------------------------------------------------------------------------------
/src/js/components/bar/components/controls/scrubber.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import { connect } from 'react-redux';
4 | import { updateTime } from '../../../../audio/actions';
5 | import { constants } from '../../../../toolbox';
6 | import Slider from 'react-rangeslider';
7 | import 'react-rangeslider/lib/index.css';
8 |
9 | const { color } = constants;
10 |
11 | const Container = styled.div`
12 | position: relative;
13 | display: flex;
14 | flex-direction: column;
15 | width: 90%;
16 | margin: 3vh auto 1vh auto;
17 | height: 5vh;
18 |
19 | .rangeslider-horizontal.time-slider,
20 | .scrubber {
21 | height: 4px;
22 | width: 90%;
23 | margin: auto;
24 | box-shadow: none;
25 |
26 | .rangeslider__fill {
27 | background: ${props =>
28 | props.isChanging ? color.red[4] : color.gray[6]};
29 | box-shadow: none;
30 | transition: all 0.05s ease;
31 | }
32 |
33 | .rangeslider__handle {
34 | width: 7px;
35 | height: 7px;
36 | box-shadow: none;
37 | background: ${color.gray[6]};
38 | border: none;
39 | outline: none;
40 |
41 | &::active {
42 | height: 30px;
43 | width: 30px;
44 | }
45 |
46 | &::after {
47 | display: none;
48 | }
49 | }
50 | }
51 |
52 | .scrubber .rangeslider__handle {
53 | width: 8px;
54 | height: 8px;
55 | background: #757778;
56 | border: 2px solid transparent;
57 | transition: all 0.05s ease;
58 |
59 | ${props =>
60 | props.isChanging &&
61 | css`
62 | height: 30px;
63 | width: 30px;
64 | background: ${color.red[4]};
65 | border: 2px solid white;
66 | `};
67 | }
68 | `;
69 |
70 | const TimeContainer = styled.div`
71 | display: flex;
72 | justify-content: space-between;
73 | width: 90%;
74 | margin: auto;
75 | `;
76 |
77 | const Time = styled.h5`
78 | font-weight: normal;
79 | margin: 8px 0;
80 | color: ${props => (props.isChanging ? color.red[4] : color.gray[6])};
81 | transform: ${props => props.shift && 'translateY(10px)'};
82 | transition: all 0.15s ease;
83 | `;
84 |
85 | export function formatTime(seconds = 0, guide = seconds) {
86 | let s = Math.floor(seconds % 60);
87 | let m = Math.floor((seconds / 60) % 60);
88 | let h = Math.floor(seconds / 3600);
89 | const gm = Math.floor((guide / 60) % 60);
90 | const gh = Math.floor(guide / 3600);
91 |
92 | if (isNaN(seconds) || seconds === Infinity) {
93 | h = m = s = '-';
94 | }
95 |
96 | h = h > 0 || gh > 0 ? `${h}:` : '';
97 | m = `${(h || gm >= 10) && m < 10 ? `0${m}` : m}:`;
98 | s = s < 10 ? `0${s}` : s;
99 |
100 | return h + m + s;
101 | }
102 |
103 | const mapStateToProps = state => {
104 | return {
105 | audioState: state.audioState,
106 | };
107 | };
108 |
109 | const mapDispatchToProps = dispatch => {
110 | return {
111 | updateTime: info => dispatch(updateTime(info)),
112 | };
113 | };
114 |
115 | class Scrubber extends Component {
116 | state = {
117 | isChanging: false,
118 | time: {
119 | current: 0,
120 | max: 0,
121 | },
122 | };
123 |
124 | startChange = () => {
125 | const { audioState } = this.props;
126 | const { time } = audioState;
127 |
128 | this.setState({
129 | isChanging: true,
130 | time: {
131 | current: time.current || 0,
132 | max: time.max || 0,
133 | },
134 | });
135 | };
136 |
137 | handleChange = val => {
138 | this.setState({
139 | time: {
140 | ...this.state.time,
141 | current: val,
142 | },
143 | });
144 | };
145 |
146 | endChange = val => {
147 | this.setState({
148 | transitioning: true,
149 | isChanging: false,
150 | });
151 | const audio = document.getElementById('audio');
152 | if (audio) {
153 | audio.currentTime = this.state.time.current;
154 | }
155 | setTimeout(() => {
156 | this.setState({
157 | transitioning: false,
158 | });
159 | this.props.updateTime(this.state.time);
160 | }, 1000);
161 | };
162 |
163 | componentDidUpdate() {}
164 |
165 | render() {
166 | const { audioState } = this.props;
167 | const { isChanging, transitioning } = this.state;
168 | const propTime = audioState.time;
169 | const stateTime = this.state.time;
170 | const time = isChanging || transitioning ? stateTime : propTime;
171 | const range = Math.round(time.current / time.max * 100);
172 |
173 | return (
174 |
175 |
184 |
185 |
188 |
191 |
192 |
193 | );
194 | }
195 | }
196 |
197 | export default connect(mapStateToProps, mapDispatchToProps)(Scrubber);
198 |
--------------------------------------------------------------------------------
/src/js/components/header/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { Icon, constants } from '../../toolbox';
5 | import { popView } from '../../views/actions';
6 |
7 | const { color, animation } = constants;
8 | const { slideInFromRight, slideOutToRight } = animation;
9 |
10 | const Container = styled.div`
11 | z-index: 2;
12 | position: fixed;
13 | display: flex;
14 | top: 0;
15 | left: 0;
16 | right: 0;
17 | max-width: 1200px;
18 | margin: 0 auto;
19 | height: ${props => (props.hideTitle ? '48px' : '90px')};
20 | padding-left: 48px;
21 | background: white;
22 | overflow: hidden;
23 | transition: all 0.3s ease-in-out;
24 |
25 | @media screen and (max-width: 768px) {
26 | padding-left: 0;
27 | }
28 | `;
29 |
30 | const ChevronContainer = styled.div`
31 | cursor: pointer;
32 | width: 40px;
33 | margin-left: -6px;
34 | opacity: ${props => (props.isShown ? 1 : 0)};
35 | transform: ${props =>
36 | props.isShown ? 'scale(1) translateX(0)' : 'scale(0) translateX(20px)'};
37 | transition: all 0.3s;
38 |
39 | &:active {
40 | svg: {
41 | color: ${props => props.isBackButton && color.redAlpha[2]};
42 | }
43 | }
44 | `;
45 |
46 | const TitleContainer = styled.div`
47 | position: absolute;
48 | margin-top: ${props =>
49 | (props.isBackButton && !props.exiting) || props.isHidden
50 | ? '5px'
51 | : '48px'};
52 | margin-left: ${props =>
53 | props.isHidden && !props.exiting ? '-100%' : '24px'};
54 | margin-left: ${props => props.isTitle && props.exiting && '100vw'};
55 | animation: ${props => (props.isLeaving ? slideOutToRight : slideInFromRight)}
56 | 0.3s ease-in-out;
57 | transition: all 0.3s ease-in-out;
58 |
59 | h1 {
60 | color: ${props =>
61 | (props.isBackButton && !props.exiting) || props.isHidden
62 | ? color.red[4]
63 | : color.black};
64 | color: ${props => props.isTitle && props.exiting && 'white'};
65 | font-weight: ${props =>
66 | (props.isBackButton && !props.exiting) || props.isHidden
67 | ? 'normal'
68 | : 'bold'};
69 | font-size: ${props =>
70 | (props.isBackButton && !props.exiting) || props.isHidden
71 | ? '20px'
72 | : null};
73 | opacity: ${props => (props.isHidden && !props.exiting ? 0 : 1)};
74 | cursor: ${props => props.isBackButton && 'pointer'};
75 |
76 | &:active {
77 | color: ${props => props.isBackButton && color.redAlpha[2]};
78 | }
79 | }
80 | `;
81 |
82 | const Title = styled.h1`
83 | margin: 0;
84 | transition: all 0.3s ease-in-out;
85 | `;
86 |
87 | const mapStateToProps = state => {
88 | return {
89 | viewState: state.viewState,
90 | };
91 | };
92 |
93 | const mapDispatchToProps = dispatch => {
94 | return {
95 | popView: () => dispatch(popView()),
96 | };
97 | };
98 |
99 | const TitleStack = connect(mapStateToProps)(({ stack, exiting, onClick }) => {
100 | return stack.map(({ name, title, props }, index) => {
101 | const isHidden = index < stack.length - 2;
102 | const isBackButton = index === stack.length - 2;
103 | const isTitle = index === stack.length - 1;
104 |
105 | return (
106 | index >= stack.length - 3 && (
107 |
113 | {!props.hideTitle && (
114 | (isBackButton ? onClick() : null)}>
115 | {title || name}
116 |
117 | )}
118 |
119 | )
120 | );
121 | });
122 | });
123 |
124 | const BackButton = connect(
125 | mapStateToProps,
126 | mapDispatchToProps,
127 | )(({ viewState, popView }) => {
128 | const { stack } = viewState;
129 | const showChevron = stack.length > 1;
130 |
131 | return (
132 |
133 |
139 |
140 | );
141 | });
142 |
143 | class Header extends Component {
144 | constructor(props) {
145 | super(props);
146 | const { viewState } = props;
147 | const { stack } = viewState;
148 |
149 | this.state = {
150 | stack,
151 | hideTitle: props.hideTitle,
152 | newStack: null,
153 | exiting: false,
154 | };
155 | }
156 |
157 | static getDerivedStateFromProps(nextProps, prevState) {
158 | const { viewState } = nextProps;
159 | const { stack } = viewState;
160 | const exiting = stack.length < prevState.stack.length;
161 |
162 | return {
163 | stack: exiting ? prevState.stack : stack,
164 | exiting,
165 | };
166 | }
167 |
168 | animateBack() {
169 | const { viewState } = this.props;
170 | const { stack } = viewState;
171 |
172 | setTimeout(() => {
173 | this.setState({
174 | stack,
175 | exiting: false,
176 | });
177 | }, 280);
178 | }
179 |
180 | componentDidUpdate(nextProps, prevState) {
181 | if (this.state.exiting) {
182 | this.animateBack();
183 | }
184 | }
185 |
186 | render() {
187 | const { stack, exiting } = this.state;
188 | const currentView = stack[stack.length - 1];
189 | const { hideTitle } = currentView.props;
190 |
191 | return (
192 |
193 |
194 |
199 |
200 | );
201 | }
202 | }
203 |
204 | export default connect(
205 | mapStateToProps,
206 | mapDispatchToProps,
207 | )(Header);
208 |
--------------------------------------------------------------------------------
/src/js/components/bar/components/controls/mini_controls.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styled, { css } from 'styled-components';
3 | import { connect } from 'react-redux';
4 | import { constants } from '../../../../toolbox';
5 | import { toggleFullscreen } from '../../actions';
6 | import { resume, pause, nextSong } from '../../../../audio/actions';
7 |
8 | const { color } = constants;
9 |
10 | const Container = styled.div`
11 | position: relative;
12 | height: 64px;
13 | width: 100%;
14 | text-align: center;
15 | border-left: 1px solid ${color.gray[2]};
16 | border-top: 1px solid #E9E9E9;
17 | border-bottom: 1px solid #E9E9E9;
18 | background: #F9F9F9;
19 | box-sizing: border-box;
20 | transition: all 0.35s ease;
21 | cursor: pointer;
22 |
23 | ${props =>
24 | props.isFullscreen &&
25 | css`
26 | height: 40vh;
27 | border-left: 1px solid transparent;
28 | border-top: none;
29 | border-bottom: none;
30 | width: 100%;
31 | cursor: default;
32 | background: #fff;
33 | `};
34 | `;
35 |
36 | const ArtworkContainer = styled.div`
37 | position: relative;
38 | height: 64px;
39 | max-height: 100%;
40 | width: 64px;
41 | padding: 8px 0;
42 | text-align: center;
43 | transition: all 0.35s ease;
44 |
45 | ${props =>
46 | props.isFullscreen &&
47 | css`
48 | height: 46vh;
49 | min-height: 8em;
50 | width: 100%;
51 | `};
52 | `;
53 |
54 | const Artwork = styled.img`
55 | height: ${props => (props.isFullscreen ? '18em' : '50px')};
56 | width: auto;
57 | max-height: ${props => (props.isFullscreen ? '90%' : '100%')};
58 | max-width: 100%;
59 | margin-top: ${props => props.isPlaying && '8px'};
60 | transform: ${props => props.isPlaying && 'scale(1.1)'};
61 | pointer-events: none;
62 | user-select: none;
63 | border-radius: ${props => props.isFullscreen && '4px'};
64 | transition: all 0.35s ease;
65 | box-shadow: ${props => props.isPlaying && '0 10px 30px #a5a5a5'};
66 | `;
67 |
68 | const InfoContainer = styled.div`
69 | position: absolute;
70 | top: 0;
71 | left: 0;
72 | right: 0;
73 | height: 64px;
74 | display: flex;
75 | justify-content: space-between;
76 | align-items: center;
77 | padding-left: 80px;
78 | opacity: ${props => props.isFullscreen && 0};
79 | pointer-events: ${props => props.isFullscreen && 'none'};
80 | transition: all 0.35s ease;
81 | `;
82 |
83 | const SongTitle = styled.h3`
84 | margin: 0;
85 | font-weight: normal;
86 | color: #646464;
87 | font-size: 13px;
88 | `;
89 |
90 | const ButtonContainer = styled.div`
91 | display: flex;
92 | align-items: center;
93 | justify-content: flex-end;
94 | padding-right: 16px;
95 | `;
96 |
97 | const Svg = styled.img`
98 | height: 24px;
99 | width: 24px;
100 | margin: 0 8px;
101 | `;
102 |
103 | const mapStateToProps = state => {
104 | return {
105 | audioState: state.audioState,
106 | navState: state.navState,
107 | };
108 | };
109 |
110 | const mapDispatchToProps = dispatch => {
111 | return {
112 | toggleFullscreen: () => dispatch(toggleFullscreen()),
113 | resume: () => dispatch(resume()),
114 | pause: () => dispatch(pause()),
115 | nextSong: () => dispatch(nextSong()),
116 | };
117 | };
118 |
119 | class MiniControls extends Component {
120 | resume = e => {
121 | e.stopPropagation();
122 | const { audioState } = this.props;
123 | const { hasAudio, isPlaying } = audioState;
124 |
125 | if (hasAudio && !isPlaying) {
126 | this.props.resume();
127 | }
128 | };
129 |
130 | pause = e => {
131 | e.stopPropagation();
132 | const { audioState } = this.props;
133 | const { hasAudio, isPlaying } = audioState;
134 |
135 | if (hasAudio && isPlaying) {
136 | this.props.pause();
137 | }
138 | };
139 |
140 | nextSong = e => {
141 | e.stopPropagation();
142 | const { audioState } = this.props;
143 | const { hasAudio } = audioState;
144 |
145 | if (hasAudio) {
146 | this.props.nextSong();
147 | }
148 | };
149 |
150 | render() {
151 | const { navState, audioState } = this.props;
152 | const { isFullscreen } = navState;
153 | const {
154 | queue,
155 | inQueue,
156 | hasAudio,
157 | isPlaying,
158 | playlist,
159 | currentIndex,
160 | } = audioState;
161 | const path = 'images';
162 | const track =
163 | queue.length && inQueue
164 | ? queue[0]
165 | : hasAudio && currentIndex < playlist.length
166 | ? playlist[currentIndex]
167 | : {
168 | name: ' Music.js',
169 | artist: 'Tanner Villarete',
170 | album: 'My App',
171 | artwork: 'hi.com',
172 | track: '1',
173 | };
174 | const artwork = hasAudio
175 | ? `https://tannerv.ddns.net/SpotiFree/${track.artwork}`
176 | : `images/default_artwork.svg`;
177 |
178 | return (
179 | !isFullscreen && this.props.toggleFullscreen()}>
182 |
183 |
188 |
189 |
190 | {track.name}
191 |
192 | {!(hasAudio && isPlaying) && (
193 |
194 | )}
195 | {isPlaying && (
196 |
197 | )}
198 |
199 |
200 |
201 |
202 | );
203 | }
204 | }
205 |
206 | export default connect(mapStateToProps, mapDispatchToProps)(MiniControls);
207 |
--------------------------------------------------------------------------------
/src/js/views/album/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { playSong, addToQueue } from '../../audio/actions';
5 | import { fetchAlbums, fetchAlbum, addToPlaylist } from '../../api/actions';
6 | import { pushView, pushPopup } from '../actions';
7 | import { Button, constants } from '../../toolbox';
8 |
9 | const { color } = constants;
10 | const breakpointSm = `@media screen and (max-width: 750px)`;
11 |
12 | const Container = styled.div`
13 | display: flex;
14 |
15 | ${breakpointSm} {
16 | display: block;
17 | }
18 | `;
19 |
20 | const ArtworkContainer = styled.div`
21 | position: relative;
22 | height: 300px;
23 | width: 300px;
24 | margin-right: 32px;
25 |
26 | ${breakpointSm} {
27 | height: 36vw;
28 | width: 36vw;
29 | display: block;
30 | margin-right: 8px;
31 | max-height: 100%;
32 | }
33 | `;
34 |
35 | const Artwork = styled.img`
36 | border: 1px solid ${color.gray[2]};
37 | box-sizing: border-box;
38 | border-radius: 6px;
39 | pointer-events: none;
40 | user-select: none;
41 | max-height: 100%;
42 | `;
43 |
44 | const Placeholder = styled.div`
45 | z-index: 1;
46 | position: absolute;
47 | top: 0;
48 | bottom: 0;
49 | left: 0;
50 | right: 0;
51 | background: url('images/default_artwork.svg');
52 | background-size: cover;
53 | transition: all 0.3s;
54 | opacity: ${props => (props.isHidden ? 0 : 1)};
55 | `;
56 |
57 | const ButtonContainer = styled.div`
58 | flex: 1;
59 | `;
60 |
61 | const MobileHeader = styled.div`
62 | position: relative;
63 | display: flex;
64 | height: 20vh;
65 | margin-bottom: 16px;
66 |
67 | @media screen and (min-width: 750px) {
68 | display: none;
69 | }
70 | `;
71 |
72 | const TitleContainer = styled.div`
73 | display: flex;
74 | flex-direction: column;
75 | `;
76 |
77 | const Title = styled.h1`
78 | margin: 0 0 8px;
79 |
80 | ${breakpointSm} {
81 | font-size: 3.5vh;
82 | }
83 | `;
84 |
85 | const Subtitle = styled.h2`
86 | color: ${color.red[4]};
87 | font-weight: normal;
88 | margin: 0 0 16px 0;
89 |
90 | ${breakpointSm} {
91 | font-size: 3vh;
92 | }
93 | `;
94 |
95 | const VisibleDesktop = styled.div`
96 | @media screen and (max-width: 750px) {
97 | display: none;
98 | }
99 | `;
100 |
101 | const mapStateToProps = state => {
102 | return {
103 | viewState: state.viewState,
104 | apiState: state.apiState,
105 | audioState: state.audioState,
106 | };
107 | };
108 |
109 | const mapDispatchToProps = dispatch => {
110 | return {
111 | pushView: view => dispatch(pushView(view)),
112 | pushPopup: popup => dispatch(pushPopup(popup)),
113 | playSong: ({ playlist, index }) =>
114 | dispatch(playSong({ playlist, index })),
115 | addToQueue: track => dispatch(addToQueue(track)),
116 | addToPlaylist: (track, playlist) =>
117 | dispatch(addToPlaylist(track, playlist)),
118 | fetchAlbums: () => dispatch(fetchAlbums()),
119 | fetchAlbum: ({ album }) => dispatch(fetchAlbum({ album })),
120 | };
121 | };
122 |
123 | class AlbumView extends Component {
124 | state = {
125 | isLoaded: false,
126 | };
127 |
128 | playSong = ({ playlist, index }) => {
129 | this.props.playSong({ playlist, index });
130 | };
131 |
132 | setupOptionsMenu = track => {
133 | this.props.pushPopup({
134 | name: 'Options',
135 | props: {
136 | options: [
137 | {
138 | label: 'Play Next',
139 | image: 'play_next.svg',
140 | onClick: () => this.props.addToQueue(track),
141 | },
142 | {
143 | label: 'Add to a Playlist',
144 | image: 'add_to_playlist.svg',
145 | onClick: () =>
146 | this.props.pushPopup({
147 | name: 'Playlist Selector',
148 | props: {
149 | onSelect: playlist =>
150 | this.props.addToPlaylist(track, playlist),
151 | },
152 | }),
153 | },
154 | ],
155 | },
156 | });
157 | };
158 |
159 | onArtworkLoaded = () => {
160 | this.setState({ isLoaded: true });
161 | };
162 |
163 | componentDidMount() {
164 | const { album, apiState } = this.props;
165 | const { albums, albumData } = apiState.data;
166 |
167 | if (albums.length === 0) {
168 | this.props.fetchAlbums();
169 | this.props.fetchAlbum({ album });
170 | }
171 |
172 | if (!albumData[album] || !albumData[album].length) {
173 | this.props.fetchAlbum({ album });
174 | }
175 | }
176 |
177 | render() {
178 | const { album, apiState, audioState } = this.props;
179 | const { playlist, currentIndex } = audioState;
180 | const { albumData } = apiState.data;
181 | const { isLoaded } = this.state;
182 | const tracks = albumData[album];
183 | const artwork = tracks ? tracks[0] && tracks[0].artwork : null;
184 | const artist = tracks ? tracks[0] && tracks[0].artist : 'Loading';
185 | const currentTrack = playlist.length && playlist[currentIndex];
186 | const url = `https://tannerv.ddns.net/SpotiFree/${artwork}`;
187 |
188 | return (
189 |
190 |
191 |
192 |
193 | {artwork && }
194 |
195 |
196 | {album}
197 | {artist}
198 |
199 |
200 |
201 |
202 |
203 | {artwork && (
204 |
205 | )}
206 |
207 |
208 |
209 |
210 | {album}
211 | {artist}
212 |
213 | {tracks &&
214 | tracks.map((item, index) => {
215 | return (
216 | this.setupOptionsMenu(item)}
227 | onClick={() =>
228 | this.playSong({ playlist: tracks, index })
229 | }
230 | />
231 | );
232 | })}
233 |
234 |
235 | );
236 | }
237 | }
238 |
239 | export default connect(
240 | mapStateToProps,
241 | mapDispatchToProps,
242 | )(AlbumView);
243 |
--------------------------------------------------------------------------------
/src/js/views/playlist/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux';
3 | import styled from 'styled-components';
4 | import { playSong, addToQueue } from '../../audio/actions';
5 | import {
6 | fetchPlaylists,
7 | addToPlaylist,
8 | removeFromPlaylist,
9 | deletePlaylist,
10 | } from '../../api/actions';
11 | import { pushView, popView, pushPopup } from '../actions';
12 | import { Button, constants } from '../../toolbox';
13 |
14 | const { color } = constants;
15 | const breakpointSm = `@media screen and (max-width: 750px)`;
16 |
17 | const Container = styled.div`
18 | display: flex;
19 |
20 | ${breakpointSm} {
21 | display: block;
22 | }
23 | `;
24 |
25 | const ArtworkContainer = styled.div`
26 | margin-right: 32px;
27 |
28 | ${breakpointSm} {
29 | display: block;
30 | margin-right: 8px;
31 | height: 100%;
32 | }
33 | `;
34 |
35 | const Artwork = styled.img`
36 | border: 1px solid ${color.gray[2]};
37 | border-radius: 6px;
38 | pointer-events: none;
39 | user-select: none;
40 | max-height: 100%;
41 | `;
42 |
43 | const ButtonContainer = styled.div`
44 | flex: 1;
45 | margin-top: 16px;
46 | `;
47 |
48 | const MobileHeader = styled.div`
49 | position: relative;
50 | display: flex;
51 | height: 20vh;
52 | margin-bottom: 16px;
53 |
54 | @media screen and (min-width: 750px) {
55 | display: none;
56 | }
57 | `;
58 |
59 | const TitleContainer = styled.div`
60 | display: flex;
61 | flex-direction: column;
62 | flex: 1;
63 | margin-bottom: 16px;
64 | `;
65 |
66 | const Title = styled.h1`
67 | margin: 0 0 8px;
68 |
69 | ${breakpointSm} {
70 | font-size: 1.5rem;
71 | }
72 | `;
73 |
74 | const Subtitle = styled.h2`
75 | color: ${color.red[4]};
76 | font-weight: normal;
77 | margin: 0 0 16px 0;
78 |
79 | ${breakpointSm} {
80 | font-size: 1.25rem;
81 | }
82 | `;
83 |
84 | const ActionContainer = styled.div`
85 | flex: 1;
86 | display: flex;
87 | flex-direction: column;
88 | justify-content: center;
89 | align-items: flex-end;
90 | padding: 0 9px;
91 |
92 | svg, img {
93 | cursor: pointer;
94 | }
95 | `;
96 |
97 | const Svg = styled.img`
98 | height: ${props => props.size || 30}px;
99 | width: ${props => props.size || 30}px;
100 | `;
101 |
102 | const VisibleDesktop = styled.div`
103 | @media screen and (max-width: 750px) {
104 | display: none;
105 | }
106 | `;
107 |
108 | const mapStateToProps = state => {
109 | return {
110 | viewState: state.viewState,
111 | apiState: state.apiState,
112 | audioState: state.audioState,
113 | };
114 | };
115 |
116 | const mapDispatchToProps = dispatch => {
117 | return {
118 | pushView: view => dispatch(pushView(view)),
119 | popView: () => dispatch(popView()),
120 | playSong: ({ playlist, index }) =>
121 | dispatch(playSong({ playlist, index })),
122 | addToPlaylist: (track, playlist) =>
123 | dispatch(addToPlaylist(track, playlist)),
124 | addToQueue: track => dispatch(addToQueue(track)),
125 | pushPopup: popup => dispatch(pushPopup(popup)),
126 | removeFromPlaylist: (track, index) =>
127 | dispatch(removeFromPlaylist(track, index)),
128 | deletePlaylist: playlist => dispatch(deletePlaylist(playlist)),
129 | fetchPlaylists: () => dispatch(fetchPlaylists()),
130 | };
131 | };
132 |
133 | class PlaylistView extends Component {
134 | constructor(props) {
135 | super(props);
136 |
137 | this.state = {
138 | playlists: props.apiState.data.playlists,
139 | };
140 | }
141 |
142 | static getDerivedStateFromProps(nextProps) {
143 | const { playlists } = nextProps.apiState.data;
144 |
145 | return {
146 | playlists,
147 | };
148 | }
149 |
150 | playSong = ({ playlist, index }) => {
151 | this.props.playSong({ playlist, index });
152 | };
153 |
154 | deletePlaylist = () => {
155 | const { playlist } = this.props;
156 |
157 | this.props.popView();
158 | setTimeout(() => {
159 | this.props.deletePlaylist(playlist);
160 | }, 300);
161 | };
162 |
163 | setupTrackOptionsMenu = (track, index) => {
164 | const { playlist } = this.props;
165 |
166 | this.props.pushPopup({
167 | name: 'Options',
168 | props: {
169 | options: [
170 | {
171 | label: 'Play Next',
172 | image: 'play_next.svg',
173 | onClick: () => this.props.addToQueue(track),
174 | },
175 | {
176 | label: 'Add to a Playlist',
177 | image: 'add_to_playlist.svg',
178 | onClick: () =>
179 | this.props.pushPopup({
180 | name: 'Playlist Selector',
181 | props: {
182 | onSelect: playlist =>
183 | this.props.addToPlaylist(track, playlist),
184 | },
185 | }),
186 | },
187 | {
188 | label: 'Delete from Playlist',
189 | image: 'trash.svg',
190 | onClick: () => this.props.removeFromPlaylist(index, playlist),
191 | },
192 | ],
193 | },
194 | });
195 | };
196 |
197 | setupPlaylistOptionsMenu = () => {
198 | this.props.pushPopup({
199 | name: 'Options',
200 | props: {
201 | options: [
202 | {
203 | label: 'Delete from Library',
204 | image: 'trash.svg',
205 | onClick: this.deletePlaylist,
206 | },
207 | ],
208 | },
209 | });
210 | };
211 |
212 | componentDidMount() {
213 | this.props.fetchPlaylists();
214 | }
215 |
216 | render() {
217 | const { playlist, apiState } = this.props;
218 | const playlists = JSON.parse(localStorage.appleMusicPlaylists);
219 | if (!playlists) {
220 | return null;
221 | }
222 | let { tracks, img, description, title } = playlists[playlist.title];
223 | const { currentTrack } = apiState.data;
224 | img = img || 'images/music.jpg';
225 |
226 | return (
227 |
228 |
229 |
230 |
231 |
232 |
233 | {title}
234 | {description}
235 |
236 |
240 |
241 |
242 |
243 |
244 |
245 |
246 |
247 |
248 |
249 |
250 |
251 | {title}
252 | {description}
253 |
254 |
258 |
259 |
260 |
261 | {tracks &&
262 | tracks.map((item, index) => {
263 | return (
264 |
275 | this.setupTrackOptionsMenu(item, index)
276 | }
277 | onClick={() =>
278 | this.playSong({ playlist: tracks, index })
279 | }
280 | />
281 | );
282 | })}
283 |
284 |
285 | );
286 | }
287 | }
288 |
289 | export default connect(mapStateToProps, mapDispatchToProps)(PlaylistView);
290 |
--------------------------------------------------------------------------------