├── 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 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | 2 | play 3 | Created using Figma 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 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 | 2 | Pause 3 | Created using Figma 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 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 | 2 | 3 | 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 |
{left}
30 |
{center}
31 |
{right}
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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /public/images/add_to_playlist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 | 2 | Chevron Down 3 | Created using Figma 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/images/skip_next.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 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 | 2 | 3 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /public/images/logo_apple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 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 | 2 | 3 | 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 | } 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 | ![og](https://user-images.githubusercontent.com/21055469/43682115-2b5a1682-981e-11e8-973d-fe316aa3a49b.png) 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 | ![screen shot 2018-08-12 at 9 49 19 am](https://user-images.githubusercontent.com/21055469/44004287-0a541a80-9e15-11e8-93e8-ff3606dd4db1.png) 15 | --- 16 | ![screen shot 2018-08-12 at 9 49 27 am](https://user-images.githubusercontent.com/21055469/44004289-0df0907e-9e15-11e8-9bcf-ec5e62bcd70a.png) 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 | 128 | } 129 | /> 130 |
131 | 135 | 136 |
137 |
138 | 142 |
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 | 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 |