├── .gitignore ├── README.md ├── TASKS.md ├── babel.config.json ├── craco.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── AudioControls ├── AudioControls.js └── AudioControls.scss ├── AudioPlayer.scss ├── AudioProgress ├── AudioProgress.js └── AudioProgress.scss ├── AudioVolume ├── AudioVolume.js └── AudioVolume.scss ├── PlayerIcon ├── Icons │ ├── Backward.js │ ├── Forward.js │ ├── Pause.js │ ├── Play.js │ ├── Random.js │ ├── Sync.js │ ├── VolumeDown.js │ ├── VolumeOff.js │ └── VolumeUp.js ├── PlayerIcon.js └── PlayerIcon.scss ├── SongInfo ├── SongInfo.js └── SongInfo.scss ├── index.js ├── store ├── actions │ ├── setAudio.js │ ├── setControls.js │ └── setProgress.js ├── reactions.js └── store.js ├── styles ├── _base.scss ├── _mixins.scss ├── _normalize.scss ├── _variables.scss └── index.scss └── utils ├── formatTime.js └── objectUtils.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | dist 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > https://www.patreon.com/champipatreon 2 | 3 | # react-playlist-player 4 | 5 | [Open live demo](https://react-playlist-player.firebaseapp.com/) 6 | 7 | ## Install 8 | 9 | ```javascript 10 | npm install react-playlist-player mobx mobx-react --save 11 | ``` 12 | 13 | You'll also need the following devDependencies: 14 | 15 | ```json 16 | "devDependencies": { 17 | "@babel/cli": "^7.14.5", 18 | "@babel/core": "^7.14.6", 19 | "@babel/plugin-proposal-decorators": "^7.14.5", 20 | "@babel/plugin-syntax-jsx": "^7.14.5", 21 | "@babel/polyfill": "^7.12.1", 22 | "@babel/preset-env": "^7.14.7", 23 | "@craco/craco": "^5.6.4", 24 | "node-sass": "^6.0.1" 25 | } 26 | ``` 27 | 28 | then update the scripts: 29 | 30 | ```json 31 | "scripts": { 32 | "start": "craco start", 33 | "build": "craco build" 34 | } 35 | ``` 36 | 37 | and add a craco.config.js at the root of your project: 38 | 39 | ```javascript 40 | module.exports = { 41 | reactScriptsVersion: "react-scripts", 42 | babel: { 43 | plugins: [["@babel/plugin-proposal-decorators", { legacy: true }]] 44 | } 45 | }; 46 | ``` 47 | 48 | ## Usage 49 | 50 | ```javascript 51 | import React, { Component } from 'react' 52 | import { render } from 'react-dom' 53 | import AudioPlayer from 'react-playlist-player' 54 | 55 | class Demo extends Component { 56 | state = { 57 | currentPlayList: {} 58 | } 59 | 60 | loadPlayList = () => 61 | this.setState({ 62 | currentPlayList: { 63 | playlistCoverUrl: 'path/to/coverUrl', 64 | playlistName: 'playlist name', 65 | bandName: 'band name', 66 | songs: [ 67 | { 68 | position: '1', 69 | songName: 'foo', 70 | songUrl: 'path/to/songUrl' 71 | }, 72 | { 73 | position: '2', 74 | songName: 'bar', 75 | songUrl: 'path/to/songUrl' 76 | }, 77 | { 78 | position: '3', 79 | songName: 'baz', 80 | songUrl: 'path/to/songUrl' 81 | } 82 | ], 83 | type: 'album' 84 | } 85 | }) 86 | 87 | render() { 88 | return ( 89 |
90 | 93 | console.log({audioPlaying})} 95 | onSongChanged={({currentSong}) => {console.log(currentSong)}} 96 | /> 97 |
98 | ) 99 | } 100 | } 101 | 102 | render(, document.querySelector('#demo')) 103 | ``` 104 | 105 | ## Props 106 | 107 | | Prop | Type | Required | Description | 108 | | --------------- | :----: | -------- | -------------------------------------------------------------- | 109 | | onToggle | Function | | A function to be excuted on audio toggle. It'll get passed {audioPlaying} as an argument | 110 | | onSongChanged | Function | | A function that is called when a song changes, receives {currentSong} as param | 111 | | currentPlayList | Object | * | An object containing the playlist data | 112 | | playlistCoverUrl | String | * | A path to the cover image (prop of currentPlayList) | 113 | | playlistName | String | * | Playlist name (prop of currentPlayList) | 114 | | bandName | String | * | Band name (prop of currentPlayList) | 115 | | songs | Array | * | Array of songs(objects) to be played (prop of currentPlayList) | 116 | | position | String | | Song's position in playlist (prop of songs) | 117 | | songName | String | * | Song name (prop of songs) | 118 | | songUrl | String | * | A path to the song (prop of songs) | 119 | 120 | ## Exposed api 121 | 122 | ### toggleAudio 123 | 124 | ```javascript 125 | import { toggleAudio } from 'react-playlist-player' 126 | 127 | // Plays / pauses the audio 128 | toggleAudio() 129 | ``` 130 | -------------------------------------------------------------------------------- /TASKS.md: -------------------------------------------------------------------------------- 1 | [TODO]: 2 | - [x] Expose a variable/getter that holds the index of the song that's being played 3 | - [x] Adjust/review npm build and publish steps -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", 5 | { 6 | "targets": { 7 | "edge": "17", 8 | "firefox": "60", 9 | "chrome": "67", 10 | "safari": "11.1" 11 | }, 12 | "useBuiltIns": "usage", 13 | "corejs": "3.6.5" 14 | } 15 | ], 16 | "@babel/preset-react" 17 | ], 18 | "plugins": [["@babel/plugin-proposal-decorators", { "legacy": true }]] 19 | } -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactScriptsVersion: "react-scripts", 3 | babel: { 4 | plugins: [["@babel/plugin-proposal-decorators", { legacy: true }]] 5 | } 6 | }; -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "experimentalDecorators": true 6 | }, 7 | "exclude": ["node_modules"] 8 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-playlist-player", 3 | "version": "1.2.6", 4 | "description": "A React component for playing playlists", 5 | "author": "daniel sarmiento cordero", 6 | "email": "champi@champi.io", 7 | "license": "MIT", 8 | "keywords": [ 9 | "react", 10 | "components", 11 | "ui", 12 | "music" 13 | ], 14 | "main": "dist/index.js", 15 | "module": "dist/index.js", 16 | "files": [ 17 | "dist", 18 | "README.md" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/champi-dev/react-playlist-player" 23 | }, 24 | "private": false, 25 | "dependencies": { 26 | "mobx": "^5.15.4", 27 | "mobx-react": "^6.2.2", 28 | "react": "^16.13.1", 29 | "react-dom": "^16.13.1", 30 | "react-scripts": "^4.0.3" 31 | }, 32 | "scripts": { 33 | "start": "craco start", 34 | "build": "rm -rf dist && NODE_ENV=production babel src/ --out-dir dist --copy-files" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "devDependencies": { 52 | "@babel/plugin-proposal-decorators": "^7.14.5", 53 | "@craco/craco": "^5.6.4", 54 | "node-sass": "^6.0.1", 55 | "@babel/cli": "^7.14.5", 56 | "@babel/core": "^7.14.6", 57 | "@babel/polyfill": "^7.12.1", 58 | "@babel/preset-env": "^7.14.7" 59 | } 60 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/champi-dev/react-playlist-player/829ae4e45ed17d29fc0fe03be533f9ab66b0578c/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/champi-dev/react-playlist-player/829ae4e45ed17d29fc0fe03be533f9ab66b0578c/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/champi-dev/react-playlist-player/829ae4e45ed17d29fc0fe03be533f9ab66b0578c/public/logo512.png -------------------------------------------------------------------------------- /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 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/AudioControls/AudioControls.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { observer } from 'mobx-react' 3 | import { objectHasProps } from '../utils/objectUtils' 4 | import store from '../store/store' 5 | import './AudioControls.scss' 6 | import PlayerIcon from '../PlayerIcon/PlayerIcon' 7 | import AudioVolume from '../AudioVolume/AudioVolume' 8 | 9 | export const randomClasses = ({ randomize }) => (randomize ? 'random active' : 'random') 10 | export const playClasses = ({ audioPlaying }) => (audioPlaying ? 'pause' : 'play') 11 | export const repeatClasses = ({ repeat }) => { 12 | switch (repeat) { 13 | case 'off': 14 | return 'sync' 15 | case 'all': 16 | return 'sync active' 17 | case 'one': 18 | return 'sync active active--twice' 19 | default: 20 | break 21 | } 22 | } 23 | 24 | const toggleHandler = () => { 25 | if (objectHasProps(store.state.currentPlayList)) { 26 | store.setAudio().toggle() 27 | } 28 | } 29 | 30 | const AudioControls = observer((props) => ( 31 |
32 | store.setControls().toggleRandomize()} 36 | /> 37 | store.setControls().skipBackward()} /> 38 | 39 | toggleHandler(props)} 43 | /> 44 | 45 | store.setControls().skipForward()} /> 46 | store.setControls().toggleRepeat()} /> 47 | 48 |
49 | )) 50 | 51 | export default AudioControls 52 | -------------------------------------------------------------------------------- /src/AudioControls/AudioControls.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/_variables.scss'; 2 | 3 | .icons { 4 | position: absolute; 5 | top: 50%; 6 | left: 50%; 7 | transform: translate(-50%, -50%); 8 | @media screen and (min-width: $mediumBreakPoint) { 9 | position: unset; 10 | top: unset; 11 | left: unset; 12 | transform: unset; 13 | text-align: center; 14 | margin-bottom: 1rem; 15 | } 16 | } -------------------------------------------------------------------------------- /src/AudioPlayer.scss: -------------------------------------------------------------------------------- 1 | @import "./styles/_variables.scss"; 2 | $player-bg: lighten($bg-primary, 5%); 3 | 4 | .audio__player { 5 | position: fixed; 6 | bottom: 0; 7 | left: 0; 8 | width: 100%; 9 | background-color: $player-bg; 10 | 11 | & .audio__controls { 12 | position: relative; 13 | height: 2.5rem; 14 | @media screen and (min-width: $mediumBreakPoint) { 15 | height: 5rem; 16 | } 17 | } 18 | 19 | & .group { 20 | @media screen and (min-width: $mediumBreakPoint) { 21 | position: absolute; 22 | width: 100%; 23 | top: 50%; 24 | left: 50%; 25 | transform: translate(-50%, -50%); 26 | } 27 | 28 | & .play, 29 | & .pause { 30 | svg { 31 | width: 1.7rem; 32 | height: 1.7rem; 33 | transition: transform $transition-primary; 34 | @media screen and (min-width: $mediumBreakPoint) { 35 | &:hover { 36 | transform: scale(1.1); 37 | } 38 | } 39 | } 40 | } 41 | & .backward, 42 | & .forward { 43 | svg { 44 | width: 1rem; 45 | height: 1rem; 46 | } 47 | } 48 | 49 | & .random, 50 | & .sync { 51 | svg { 52 | width: 0.8rem; 53 | height: 0.8rem; 54 | } 55 | } 56 | 57 | & .volumeup, 58 | & .volumedown, 59 | & .volumeoff { 60 | svg { 61 | width: 1rem; 62 | height: 1rem; 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/AudioProgress/AudioProgress.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { observer } from 'mobx-react' 3 | import store from '../store/store' 4 | import './AudioProgress.scss' 5 | import { formatTime } from '../utils/formatTime' 6 | 7 | export const seekPlaying = (e, store, testConfig = {}) => { 8 | if (!store.canPlay) return undefined 9 | 10 | const desiredPos = e.clientX 11 | const playerWidth = getPlayerWidth(testConfig) 12 | const progressEl = getProgressEl(testConfig) 13 | const progressWidth = progressEl.offsetWidth 14 | const progressOffsetLeft = progressEl.offsetLeft 15 | let positionToSet = 0 16 | 17 | if (playerWidth === progressWidth) { 18 | positionToSet = Math.round( 19 | (desiredPos / progressWidth) * store.setAudio().getDuration() 20 | ) 21 | } else if (playerWidth > progressWidth) { 22 | positionToSet = Math.round( 23 | ((desiredPos - progressOffsetLeft) / progressWidth) * 24 | store.setAudio().getDuration() 25 | ) 26 | } 27 | 28 | return positionToSet 29 | } 30 | 31 | const getPlayerWidth = ({ player }) => { 32 | if (player) return player.width 33 | return document.getElementById('audio__player').offsetWidth 34 | } 35 | 36 | const getProgressEl = ({ progress }) => { 37 | if (progress) 38 | return { offsetWidth: progress.width, offsetLeft: progress.left } 39 | return document.getElementById('progress') 40 | } 41 | 42 | const AudioProgress = observer(() => { 43 | return ( 44 |
store.setAudio().setCurrentTime(seekPlaying(e, store))} 48 | > 49 | 50 | {formatTime(store.setAudio().getCurrentTime())} 51 | 52 |
56 | 57 | {formatTime(store.setAudio().getDuration())} 58 | 59 |
60 | ) 61 | }) 62 | 63 | export default AudioProgress 64 | -------------------------------------------------------------------------------- /src/AudioProgress/AudioProgress.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/_variables.scss'; 2 | @import '../styles/_mixins.scss'; 3 | 4 | .progress { 5 | backface-visibility: hidden; 6 | position: absolute; 7 | width: 100%; 8 | height: 2px; 9 | top: -2px; 10 | background: lighten($bg-primary, 15%); 11 | @media screen and (min-width: $mediumBreakPoint) { 12 | position: relative; 13 | width: 35%; 14 | max-width: 40rem; 15 | margin: 0 auto; 16 | height: 4px; 17 | border-radius: 4px; 18 | &:hover { 19 | .progress__fill { 20 | background-color: $primary; 21 | } 22 | } 23 | } 24 | 25 | &__fill { 26 | @include bar-fill; 27 | } 28 | 29 | & .time { 30 | user-select: none; 31 | cursor: default; 32 | position: absolute; 33 | top: 50%; 34 | transform: translateY(-50%); 35 | color: $text-secondary; 36 | font-size: 0.6rem; 37 | &.current-time { 38 | left: -1.5rem; 39 | } 40 | &.total-time { 41 | right: -1.5rem; 42 | } 43 | } 44 | } 45 | 46 | -------------------------------------------------------------------------------- /src/AudioVolume/AudioVolume.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { observer } from 'mobx-react' 3 | import store from '../store/store' 4 | import './AudioVolume.scss' 5 | import PlayerIcon from '../PlayerIcon/PlayerIcon' 6 | 7 | export const volumeClass = state => { 8 | if (state.audioVolume >= 0.5) { 9 | return 'volumeup' 10 | } else if (state.audioVolume < 0.5 && state.audioVolume > 0) { 11 | return 'volumedown' 12 | } else { 13 | return 'volumeoff' 14 | } 15 | } 16 | 17 | export const toggleVolume = state => (state.audioVolume > 0.0 ? 0.0 : 1.0) 18 | 19 | export const seekVolume = (e, testConfig = {}) => { 20 | const desiredPos = e.clientX 21 | const volumeOffsetLeft = getVolumeOffsetLeft(testConfig) 22 | const volumeBarOffsetLeft = getVolumeBarOffsetLeft(testConfig) 23 | const volumeBarWidth = getVolumeBarWidth(testConfig) 24 | 25 | return Math.round(((desiredPos - volumeOffsetLeft - volumeBarOffsetLeft) / volumeBarWidth) * 10) / 10 26 | } 27 | 28 | const getVolumeOffsetLeft = ({ volume }) => { 29 | if (volume) return volume.offsetLeft 30 | return document.getElementById('audio-volume').offsetLeft 31 | } 32 | 33 | const getVolumeBarOffsetLeft = ({ volumeBar }) => { 34 | if (volumeBar) return volumeBar.offsetLeft 35 | return document.getElementsByClassName('audio-volume__level')[0].offsetLeft 36 | } 37 | 38 | const getVolumeBarWidth = ({ volumeBar }) => { 39 | if (volumeBar) return volumeBar.width 40 | return document.getElementsByClassName('audio-volume__level')[0].offsetWidth 41 | } 42 | 43 | const AudioVolume = observer(() => { 44 | return ( 45 |
46 | 50 | store 51 | .setAudio() 52 | .setVolume(toggleVolume(store.state)) 53 | .visual() 54 | } 55 | /> 56 |
59 | store 60 | .setAudio() 61 | .setVolume(seekVolume(e)) 62 | .visual() 63 | } 64 | > 65 |
66 |
67 |
68 | ) 69 | }) 70 | 71 | export default AudioVolume 72 | -------------------------------------------------------------------------------- /src/AudioVolume/AudioVolume.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/_variables.scss'; 2 | @import '../styles/_mixins.scss'; 3 | 4 | .audio-volume { 5 | display: none; 6 | position: absolute; 7 | top: 50%; 8 | right: $horizontalSpace; 9 | transform: translateY(-50%); 10 | @media screen and (min-width: $mediumBreakPoint) { 11 | display: block; 12 | right: $horizontalSpaceLarge; 13 | } 14 | 15 | &__level { 16 | width: 4rem; 17 | height: 4px; 18 | border-radius: 4px; 19 | background-color: lighten($bg-primary, 15%); 20 | display: inline-block; 21 | vertical-align: middle; 22 | &:hover { 23 | .audio-volume__fill { 24 | background-color: $primary; 25 | } 26 | } 27 | } 28 | 29 | &__fill { 30 | @include bar-fill; 31 | } 32 | 33 | .icon { 34 | position: relative; 35 | display: inline-block; 36 | vertical-align: middle; 37 | margin-right: 1.3rem; 38 | svg { 39 | position: absolute; 40 | top: 50%; 41 | transform: translateY(-50%); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/Backward.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const BackWard = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default BackWard 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/Forward.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Forward = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default Forward 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/Pause.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Pause = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default Pause 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/Play.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Play = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default Play 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/Random.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Random = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default Random 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/Sync.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Sync = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default Sync 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/VolumeDown.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const VolumeDown = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default VolumeDown 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/VolumeOff.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const VolumeOff = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default VolumeOff 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/Icons/VolumeUp.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const VolumeUp = props => { 4 | return ( 5 | 6 | 10 | 11 | ) 12 | } 13 | 14 | export default VolumeUp 15 | -------------------------------------------------------------------------------- /src/PlayerIcon/PlayerIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './PlayerIcon.scss' 3 | import Random from './Icons/Random' 4 | import Sync from './Icons/Sync' 5 | import Play from './Icons/Play' 6 | import Pause from './Icons/Pause' 7 | import Forward from './Icons/Forward' 8 | import Backward from './Icons/Backward' 9 | import VolumeUp from './Icons/VolumeUp' 10 | import VolumeDown from './Icons/VolumeDown' 11 | import VolumeOff from './Icons/VolumeOff' 12 | 13 | export const iconToRender = props => { 14 | const requestedIcon = props.icon 15 | 16 | switch (requestedIcon) { 17 | case 'random': 18 | return 19 | case 'sync': 20 | return 21 | case 'play': 22 | return 23 | case 'pause': 24 | return 25 | case 'forward': 26 | return 27 | case 'backward': 28 | return 29 | case 'volumeup': 30 | return 31 | case 'volumedown': 32 | return 33 | case 'volumeoff': 34 | return 35 | default: 36 | break 37 | } 38 | } 39 | 40 | const PlayerIcon = props => { 41 | return ( 42 |
43 | {iconToRender(props)} 44 |
45 | ) 46 | } 47 | 48 | export default PlayerIcon 49 | -------------------------------------------------------------------------------- /src/PlayerIcon/PlayerIcon.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/_variables.scss"; 2 | 3 | .icon { 4 | position: relative; 5 | display: inline-block; 6 | vertical-align: middle; 7 | transition: transform $transition-primary; 8 | backface-visibility: hidden; 9 | &:not(:nth-child(5)) { 10 | margin-right: 1rem; 11 | } 12 | &:nth-child(1), 13 | &:nth-child(4) { 14 | margin-right: 1.3rem; 15 | } 16 | 17 | &:hover { 18 | #iconPath { 19 | fill: $text; 20 | } 21 | } 22 | &:active { 23 | #iconPath { 24 | fill: darken($text-secondary, 5%); 25 | } 26 | } 27 | 28 | #iconPath { 29 | fill: $text-secondary; 30 | } 31 | 32 | &.active { 33 | #iconPath { 34 | fill: $primary; 35 | } 36 | &::after { 37 | position: absolute; 38 | bottom: -8px; 39 | left: 50%; 40 | transform: translateX(-50%); 41 | content: ""; 42 | width: 4px; 43 | height: 4px; 44 | background: $primary; 45 | display: block; 46 | border-radius: 50%; 47 | } 48 | &.active--twice { 49 | &::before { 50 | content: "1"; 51 | position: absolute; 52 | top: 0px; 53 | right: -10px; 54 | font-size: 8px; 55 | -webkit-transform: translateX(-50%); 56 | transform: translateX(-50%); 57 | width: 10px; 58 | background: $primary; 59 | display: block; 60 | border-radius: 50%; 61 | } 62 | } 63 | } 64 | 65 | svg { 66 | display: inline-block; 67 | vertical-align: middle; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/SongInfo/SongInfo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { observer } from 'mobx-react' 3 | import store from '../store/store' 4 | import './SongInfo.scss' 5 | 6 | const SongInfo = observer(() => { 7 | return ( 8 |
9 | cover 10 |
11 | {store.state.currentSong.songName} 12 | {store.state.currentPlayList.bandName} 13 |
14 |
15 | ) 16 | }) 17 | 18 | export default SongInfo 19 | -------------------------------------------------------------------------------- /src/SongInfo/SongInfo.scss: -------------------------------------------------------------------------------- 1 | @import "../styles/_variables.scss"; 2 | 3 | .song-info { 4 | z-index: 2; 5 | display: none; 6 | position: absolute; 7 | top: 50%; 8 | left: $horizontalSpace; 9 | transform: translateY(-50%); 10 | @media screen and (min-width: $mediumBreakPoint) { 11 | display: block; 12 | left: $horizontalSpaceLarge; 13 | } 14 | &__cover { 15 | height: 3.5rem; 16 | width: 3.5rem; 17 | box-shadow: $shadowMainBox; 18 | display: inline-block; 19 | vertical-align: middle; 20 | object-fit: cover; 21 | } 22 | &__text { 23 | display: inline-block; 24 | vertical-align: middle; 25 | padding-left: 0.5rem; 26 | & .title, 27 | & .subtitle { 28 | cursor: default; 29 | display: block; 30 | text-transform: capitalize; 31 | &:hover { 32 | color: $text; 33 | text-decoration: underline; 34 | } 35 | } 36 | & .title { 37 | color: $text; 38 | font-size: 0.8rem; 39 | line-height: 1.7; 40 | } 41 | & .subtitle { 42 | color: $text-secondary; 43 | font-size: 0.65rem; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { observer } from 'mobx-react' 3 | import store from './store/store' 4 | import './AudioPlayer.scss' 5 | import SongInfo from './SongInfo/SongInfo' 6 | import AudioControls from './AudioControls/AudioControls' 7 | import AudioProgress from './AudioProgress/AudioProgress' 8 | import isEqual from 'lodash/isEqual' 9 | import { testCond } from './utils/objectUtils' 10 | 11 | export const exportProps = { 12 | onToggle: () => {}, 13 | onSongChanged: () => {} 14 | } 15 | 16 | @observer 17 | class AudioPlayer extends Component { 18 | componentDidMount() { 19 | const {onToggle} = this.props 20 | if (onToggle) exportProps.onToggle = onToggle 21 | 22 | const {onSongChanged} = this.props 23 | if (onSongChanged) exportProps.onSongChanged = onSongChanged 24 | } 25 | 26 | componentDidUpdate(prevProps) { 27 | this.onPlaylistChanged(prevProps) 28 | } 29 | 30 | onPlaylistChanged = (prevProps, testConfig = {}) => { 31 | if ( 32 | testCond( 33 | testConfig, 34 | 'ifBool', 35 | !isEqual(prevProps.currentPlayList, this.props.currentPlayList) 36 | ) 37 | ) { 38 | store.setAudio().setPlaylist(this.props.currentPlayList) 39 | } 40 | } 41 | 42 | render() { 43 | const SongInfoComponent = store.canPlay && 44 | const AudioElement = ( 45 | 52 | ) 53 | 54 | return ( 55 |
56 |
57 | {AudioElement} 58 |
59 | {SongInfoComponent} 60 |
61 | 62 | 63 |
64 |
65 |
66 |
67 | ) 68 | } 69 | } 70 | 71 | export const toggleAudio = store.setAudio().toggle 72 | export default AudioPlayer 73 | -------------------------------------------------------------------------------- /src/store/actions/setAudio.js: -------------------------------------------------------------------------------- 1 | import { objectHasProps } from '../../utils/objectUtils' 2 | import { exportProps } from '../../index' 3 | 4 | const setAudio = () => 5 | function() { 6 | return { 7 | toggle: ({ shouldLoad } = {}) => { 8 | if (this.state.audioElement) { 9 | if (shouldLoad) this.setAudio().load() 10 | 11 | this.state.audioPlaying 12 | ? this.setAudio().pause() 13 | : this.setAudio().play() 14 | 15 | exportProps.onToggle({audioPlaying: this.state.audioPlaying}) 16 | } 17 | }, 18 | 19 | play: () => { 20 | this.state.audioElement.play() 21 | this.state.audioPlaying = true 22 | }, 23 | 24 | pause: () => { 25 | this.state.audioElement.pause() 26 | this.state.audioPlaying = false 27 | }, 28 | 29 | resetPlay: () => { 30 | this.setAudio().toggle() 31 | this.setAudio().setCurrentTime(0) 32 | this.setProgress().set('0%') 33 | }, 34 | 35 | playFromTop: ({ auto } = {}) => { 36 | this.state.playedIndexes = [] 37 | this.setAudio().resetPlay() 38 | this.setAudio().setSong({ arrIndex: 0 }) 39 | if (auto) this.setAudio().setAndPlay({ shouldLoad: true }) 40 | }, 41 | 42 | load: () => this.state.audioElement.load(), 43 | 44 | getVolume: () => this.state.audioElement.volume, 45 | 46 | getDuration: () => 47 | this.state.audioElement ? this.state.audioElement.duration : undefined, 48 | 49 | getCurrentTime: () => 50 | this.state.audioElement 51 | ? this.state.audioElement.currentTime 52 | : undefined, 53 | 54 | setCurrentTime: pos => 55 | this.state.audioElement 56 | ? (this.state.audioElement.currentTime = pos) 57 | : '', 58 | 59 | setElement: () => 60 | Promise.resolve( 61 | (this.state.audioElement = document.getElementById('audio')) 62 | ), 63 | 64 | setPlaylist: playlist => (this.state.currentPlayList = { ...playlist }), 65 | 66 | setSong: ({ arrIndex }) => 67 | objectHasProps(this.state.currentPlayList) && 68 | (this.state.currentSong = { 69 | ...this.state.currentPlayList.songs[arrIndex], 70 | arrIndex 71 | }), 72 | 73 | setSongBy: by => { 74 | this.setAudio().setSong({ 75 | arrIndex: this.state.currentSong.arrIndex + by 76 | }) 77 | }, 78 | 79 | setVolume: volume => ({ 80 | visual: () => (this.state.audioVolume = volume), 81 | element: () => (this.state.audioElement.volume = volume) 82 | }), 83 | 84 | setAndPlay: ({ shouldLoad } = {}) => { 85 | const saveIndex = () => { 86 | const foundIndex = this.state.playedIndexes.find( 87 | i => i === this.state.currentSong.arrIndex 88 | ) 89 | if (foundIndex === undefined) 90 | this.state.playedIndexes.push(this.state.currentSong.arrIndex) 91 | } 92 | 93 | if (this.canPlay) { 94 | this.setAudio() 95 | .setElement() 96 | .then(() => { 97 | saveIndex() 98 | this.setAudio().toggle({ shouldLoad }) 99 | }) 100 | .catch(e => e) 101 | } 102 | } 103 | } 104 | } 105 | 106 | export default setAudio 107 | -------------------------------------------------------------------------------- /src/store/actions/setControls.js: -------------------------------------------------------------------------------- 1 | import { exportProps } from '../../index' 2 | 3 | const setControls = () => 4 | function() { 5 | return { 6 | toggleRandomize: () => { 7 | this.state.randomize = !this.state.randomize 8 | }, 9 | 10 | toggleRepeat: () => { 11 | const setRepeat = repeat => { 12 | this.state.repeat = repeat 13 | } 14 | 15 | switch (this.state.repeat) { 16 | case 'off': 17 | setRepeat('all') 18 | break 19 | case 'all': 20 | setRepeat('one') 21 | break 22 | case 'one': 23 | setRepeat('off') 24 | break 25 | default: 26 | break 27 | } 28 | }, 29 | 30 | skipBackward: () => { 31 | if (this.state.playedIndexes.length > 1) { 32 | this.state.backwardTimes += 1 33 | this.setControls().skipSong({ 34 | to: this.state.playedIndexes[this.state.playedIndexes.length - this.state.backwardTimes - 1] 35 | }) 36 | if (this.state.backwardTimes === this.state.playedIndexes.length - 1) { 37 | this.state.backwardTimes = 0 38 | this.state.playedIndexes = [] 39 | } 40 | } 41 | }, 42 | 43 | skipForward: () => { 44 | if (this.state.randomize) { 45 | const index = this.setControls().findRandomIndex() 46 | if (!index && this.setControls().playedAllSongs()) { 47 | this.state.repeat === 'all' 48 | ? this.setAudio().playFromTop({ auto: true }) 49 | : this.setAudio().playFromTop({ auto: null }) 50 | } else { 51 | this.setControls().skipSong({ to: index }) 52 | } 53 | } else { 54 | this.setControls().skipSong({ by: 1 }) 55 | } 56 | }, 57 | 58 | skipSong: ({ by, to } = {}) => { 59 | const skipper = skipFn => { 60 | this.setAudio().resetPlay() 61 | skipFn() 62 | this.setAudio().setAndPlay({ shouldLoad: true }) 63 | exportProps.onSongChanged({currentSong: this.state.currentSong}) 64 | } 65 | 66 | if (this.songAndPlaylistAreSetted) { 67 | if (this.state.repeat === 'all' && this.setControls().playedAllSongs() && by > 0) { 68 | this.setAudio().playFromTop({ auto: true }) 69 | return 70 | } 71 | if (typeof by === 'number') { 72 | const nextIndex = this.state.currentSong.arrIndex + by 73 | if (nextIndex >= 0 && nextIndex < this.state.currentPlayList.songs.length) { 74 | skipper(() => this.setAudio().setSongBy(by)) 75 | } 76 | } else if (typeof to === 'number' && to >= 0) { 77 | skipper(() => this.setAudio().setSong({ songs: this.state.currentPlayList.songs, arrIndex: to })) 78 | } 79 | } 80 | }, 81 | 82 | findRandomIndex: () => { 83 | const plLength = this.state.currentPlayList.songs.length - 1 84 | const randomIndex = () => Math.round((Math.random() * plLength * 10) / 10) 85 | const generatedIndex = randomIndex() 86 | 87 | if (this.state.playedIndexes.find(i => i === generatedIndex) === undefined) { 88 | return generatedIndex 89 | } else if (this.setControls().playedAllSongs()) { 90 | return 91 | } 92 | return this.setControls().findRandomIndex() 93 | }, 94 | 95 | playedAllSongs: () => this.state.currentPlayList.songs.length === this.state.playedIndexes.length 96 | } 97 | } 98 | 99 | export default setControls 100 | -------------------------------------------------------------------------------- /src/store/actions/setProgress.js: -------------------------------------------------------------------------------- 1 | const setProgress = () => 2 | function() { 3 | return { 4 | completed: () => this.state.audioProgress === '100%', 5 | 6 | get: () => this.state.audioProgress, 7 | 8 | set: manualProgress => { 9 | this.state.audioProgress = 10 | manualProgress || 11 | `${Math.round((this.setAudio().getCurrentTime() / this.setAudio().getDuration()) * 100 * 10) / 10}%` 12 | } 13 | } 14 | } 15 | 16 | export default setProgress 17 | -------------------------------------------------------------------------------- /src/store/reactions.js: -------------------------------------------------------------------------------- 1 | const reactions = store => [ 2 | { 3 | data: () => store.state.currentPlayList, 4 | effect: () => { 5 | store.setAudio().playFromTop({ auto: true }) 6 | } 7 | }, 8 | 9 | { 10 | data: () => store.state.repeat === 'one' && store.setProgress().completed(), 11 | effect: shouldRepeatOne => 12 | shouldRepeatOne && store.setControls().skipSong({ by: 0 }) 13 | }, 14 | 15 | { 16 | data: () => 17 | store.state.repeat === 'all' && 18 | store.setProgress().completed() && 19 | store.setControls().playedAllSongs(), 20 | effect: shouldRepeatPlayList => 21 | shouldRepeatPlayList && store.setAudio().playFromTop({ auto: true }) 22 | }, 23 | 24 | { 25 | data: () => store.state.randomize && store.setProgress().completed(), 26 | effect: shouldPlayRandom => { 27 | if (shouldPlayRandom) { 28 | const index = store.setControls().findRandomIndex() 29 | index && store.setControls().skipSong({ to: index }) 30 | } 31 | } 32 | }, 33 | 34 | { 35 | data: () => store.setProgress().completed() && store.state.repeat === 'off', 36 | effect: shouldPlayNextSong => { 37 | if (shouldPlayNextSong) { 38 | if ( 39 | store.state.currentSong.arrIndex + 1 < 40 | store.state.currentPlayList.songs.length 41 | ) { 42 | store.setControls().skipSong({ by: 1 }) 43 | } else { 44 | store.setAudio().resetPlay() 45 | } 46 | } 47 | } 48 | }, 49 | 50 | { 51 | data: () => store.shouldSyncVolume, 52 | effect: ({ elementIsSetted, volumeVisual }) => 53 | elementIsSetted && 54 | store 55 | .setAudio() 56 | .setVolume(volumeVisual) 57 | .element() 58 | } 59 | ] 60 | 61 | export default reactions 62 | -------------------------------------------------------------------------------- /src/store/store.js: -------------------------------------------------------------------------------- 1 | import { observable, action, computed, reaction } from 'mobx' 2 | import { objectHasProps } from '../utils/objectUtils' 3 | import reactions from './reactions' 4 | import setAudio from './actions/setAudio' 5 | import setControls from './actions/setControls' 6 | import setProgress from './actions/setProgress' 7 | 8 | class Store { 9 | constructor() { 10 | reactions(this).forEach(r => reaction(r.data, r.effect)) 11 | } 12 | 13 | @observable state = { 14 | audioElement: undefined, 15 | audioVolume: 1, 16 | audioProgress: '0%', 17 | audioPlaying: false, 18 | randomize: false, 19 | repeat: 'off', 20 | playedIndexes: [], 21 | backwardTimes: 0, 22 | currentSong: {}, 23 | currentPlayList: {} 24 | } 25 | 26 | @action setAudio = setAudio().bind(this) 27 | @action setControls = setControls().bind(this) 28 | @action setProgress = setProgress().bind(this) 29 | 30 | @computed get canPlay() { 31 | return objectHasProps(this.state.currentPlayList) 32 | } 33 | @computed get shouldSyncVolume() { 34 | return { 35 | elementIsSetted: !!this.state.audioElement, 36 | volumeVisual: this.state.audioVolume 37 | } 38 | } 39 | @computed get songAndPlaylistAreSetted() { 40 | return ( 41 | objectHasProps(this.state.currentSong) && 42 | objectHasProps(this.state.currentPlayList) 43 | ) 44 | } 45 | } 46 | 47 | export default new Store() 48 | -------------------------------------------------------------------------------- /src/styles/_base.scss: -------------------------------------------------------------------------------- 1 | @import "./_variables.scss"; 2 | 3 | * { 4 | padding: 0; 5 | margin: 0; 6 | box-sizing: border-box; 7 | -webkit-tap-highlight-color: rgba(255, 255, 255, 0); 8 | } 9 | 10 | html { 11 | font-size: 100%; 12 | @media screen and (min-width: $mediumBreakPoint) { 13 | font-size: 115%; 14 | } 15 | } 16 | 17 | body { 18 | margin: 0; 19 | padding: 0; 20 | font-family: "Roboto", sans-serif; 21 | font-weight: 400; 22 | -webkit-font-smoothing: antialiased; 23 | -moz-osx-font-smoothing: grayscale; 24 | } 25 | -------------------------------------------------------------------------------- /src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin entire-screen { 2 | width: 100vw; 3 | height: 100vh; 4 | } 5 | 6 | @mixin bar-fill { 7 | width: 0%; 8 | height: 100%; 9 | background-color: $text-secondary; 10 | transition: background-color $transition-primary; 11 | backface-visibility: hidden; 12 | @media screen and (min-width: $mediumBreakPoint) { 13 | border-radius: 4px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/styles/_normalize.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { 178 | /* 1 */ 179 | overflow: visible; 180 | } 181 | 182 | /** 183 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 184 | * 1. Remove the inheritance of text transform in Firefox. 185 | */ 186 | 187 | button, 188 | select { 189 | /* 1 */ 190 | text-transform: none; 191 | } 192 | 193 | /** 194 | * Correct the inability to style clickable types in iOS and Safari. 195 | */ 196 | 197 | button, 198 | [type="button"], 199 | [type="reset"], 200 | [type="submit"] { 201 | -webkit-appearance: button; 202 | } 203 | 204 | /** 205 | * Remove the inner border and padding in Firefox. 206 | */ 207 | 208 | button::-moz-focus-inner, 209 | [type="button"]::-moz-focus-inner, 210 | [type="reset"]::-moz-focus-inner, 211 | [type="submit"]::-moz-focus-inner { 212 | border-style: none; 213 | padding: 0; 214 | } 215 | 216 | /** 217 | * Restore the focus styles unset by the previous rule. 218 | */ 219 | 220 | button:-moz-focusring, 221 | [type="button"]:-moz-focusring, 222 | [type="reset"]:-moz-focusring, 223 | [type="submit"]:-moz-focusring { 224 | outline: 1px dotted ButtonText; 225 | } 226 | 227 | /** 228 | * Correct the padding in Firefox. 229 | */ 230 | 231 | fieldset { 232 | padding: 0.35em 0.75em 0.625em; 233 | } 234 | 235 | /** 236 | * 1. Correct the text wrapping in Edge and IE. 237 | * 2. Correct the color inheritance from `fieldset` elements in IE. 238 | * 3. Remove the padding so developers are not caught out when they zero out 239 | * `fieldset` elements in all browsers. 240 | */ 241 | 242 | legend { 243 | box-sizing: border-box; /* 1 */ 244 | color: inherit; /* 2 */ 245 | display: table; /* 1 */ 246 | max-width: 100%; /* 1 */ 247 | padding: 0; /* 3 */ 248 | white-space: normal; /* 1 */ 249 | } 250 | 251 | /** 252 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 253 | */ 254 | 255 | progress { 256 | vertical-align: baseline; 257 | } 258 | 259 | /** 260 | * Remove the default vertical scrollbar in IE 10+. 261 | */ 262 | 263 | textarea { 264 | overflow: auto; 265 | } 266 | 267 | /** 268 | * 1. Add the correct box sizing in IE 10. 269 | * 2. Remove the padding in IE 10. 270 | */ 271 | 272 | [type="checkbox"], 273 | [type="radio"] { 274 | box-sizing: border-box; /* 1 */ 275 | padding: 0; /* 2 */ 276 | } 277 | 278 | /** 279 | * Correct the cursor style of increment and decrement buttons in Chrome. 280 | */ 281 | 282 | [type="number"]::-webkit-inner-spin-button, 283 | [type="number"]::-webkit-outer-spin-button { 284 | height: auto; 285 | } 286 | 287 | /** 288 | * 1. Correct the odd appearance in Chrome and Safari. 289 | * 2. Correct the outline style in Safari. 290 | */ 291 | 292 | [type="search"] { 293 | -webkit-appearance: textfield; /* 1 */ 294 | outline-offset: -2px; /* 2 */ 295 | } 296 | 297 | /** 298 | * Remove the inner padding in Chrome and Safari on macOS. 299 | */ 300 | 301 | [type="search"]::-webkit-search-decoration { 302 | -webkit-appearance: none; 303 | } 304 | 305 | /** 306 | * 1. Correct the inability to style clickable types in iOS and Safari. 307 | * 2. Change font properties to `inherit` in Safari. 308 | */ 309 | 310 | ::-webkit-file-upload-button { 311 | -webkit-appearance: button; /* 1 */ 312 | font: inherit; /* 2 */ 313 | } 314 | 315 | /* Interactive 316 | ========================================================================== */ 317 | 318 | /* 319 | * Add the correct display in Edge, IE 10+, and Firefox. 320 | */ 321 | 322 | details { 323 | display: block; 324 | } 325 | 326 | /* 327 | * Add the correct display in all browsers. 328 | */ 329 | 330 | summary { 331 | display: list-item; 332 | } 333 | 334 | /* Misc 335 | ========================================================================== */ 336 | 337 | /** 338 | * Add the correct display in IE 10+. 339 | */ 340 | 341 | template { 342 | display: none; 343 | } 344 | 345 | /** 346 | * Add the correct display in IE 10. 347 | */ 348 | 349 | [hidden] { 350 | display: none; 351 | } 352 | -------------------------------------------------------------------------------- /src/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | // SIZES 2 | $smallBreakPoint: 30rem; 3 | $mediumBreakPoint: 48rem; 4 | $largeBreakPoint: 64rem; 5 | $extraLargeBreakPoint: 80rem; 6 | 7 | $horizontalSpace: 0.5rem; 8 | $horizontalSpaceLarge: 1rem; 9 | 10 | // COLOR ROLES 11 | $primary: #ed0000; 12 | $bg-primary: #181818; 13 | $text: #fcfcfc; 14 | $text-secondary: #e0e0e0; 15 | $border: #eee; 16 | 17 | // SHADOWS 18 | $shadowMainBox: 3px 3px 17px 0 rgba($bg-primary, 0.7); 19 | 20 | // TRANSITIONS 21 | $transition-primary: 0.2s ease-in-out; -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './_normalize.scss'; 2 | @import './_base.scss'; -------------------------------------------------------------------------------- /src/utils/formatTime.js: -------------------------------------------------------------------------------- 1 | export const formatTime = timestamp => { 2 | if (isNaN(timestamp)) return '' 3 | let minutes = Math.floor(timestamp / 60) 4 | let seconds = timestamp - minutes * 60 5 | if (seconds < 10) { 6 | seconds = '0' + seconds 7 | } 8 | timestamp = minutes + ':' + seconds 9 | timestamp = timestamp.split('.')[0] 10 | return timestamp 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/objectUtils.js: -------------------------------------------------------------------------------- 1 | export const objectHasProps = obj => { 2 | const shouldEval = !!obj && typeof obj === 'object' 3 | if (shouldEval) { 4 | return Object.keys(obj).length > 0 5 | } 6 | return false 7 | } 8 | 9 | export const testCond = (testObj, property, evaluation) => { 10 | return objectHasProps(testObj) ? testObj[property] : evaluation 11 | } 12 | --------------------------------------------------------------------------------