├── .eslintrc ├── .eslintrc-base.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── cypress-install.js ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── integration │ ├── home.js │ └── play-button.js ├── plugins │ └── index.js └── support │ ├── commands.js │ └── index.js ├── netlify.toml ├── package-lock.json ├── package.json ├── public ├── _redirects └── index.html ├── renovate.json ├── sample.env └── src ├── assets ├── Saron3.webm ├── pause.svg └── play.svg ├── components ├── App.js ├── App.test.js ├── CurrentSong.js ├── Footer.js ├── Main.js ├── Nav.js ├── Nav.test.js ├── PlayPauseButton.js ├── Slider.js ├── SongHistory.js └── Visualizer.js ├── css └── App.css ├── index.js ├── setupTests.js └── utils └── buildEventSource.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "react-app", 4 | "./.eslintrc-base.json", 5 | "plugin:prettier/recommended" 6 | ], 7 | "globals": { 8 | "Promise": true, 9 | "window": true, 10 | "$": true, 11 | "ga": true, 12 | "jQuery": true, 13 | "router": true 14 | }, 15 | "settings": { 16 | "import/ignore": ["node_modules", "\\.json$"], 17 | "import/extensions": [".js", ".jsx"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.eslintrc-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "max-len": [ 4 | "error", 5 | { "code": 80, "ignoreUrls": true, "ignoreTemplateLiterals": true } 6 | ], 7 | "block-scoped-var": 0, 8 | "brace-style": [2, "1tbs", { "allowSingleLine": true }], 9 | "camelcase": 2, 10 | "comma-dangle": 2, 11 | "comma-spacing": [2, { "before": false, "after": true }], 12 | "comma-style": [2, "last"], 13 | "complexity": 0, 14 | "consistent-return": 2, 15 | "consistent-this": 0, 16 | "curly": 2, 17 | "default-case": 2, 18 | "dot-notation": 0, 19 | "eol-last": 2, 20 | "eqeqeq": 2, 21 | "func-call-spacing": 2, 22 | "func-names": 0, 23 | "func-style": 0, 24 | "guard-for-in": 2, 25 | "handle-callback-err": 2, 26 | "import/default": 2, 27 | "import/export": 2, 28 | "import/extensions": [0, "always"], 29 | "import/first": 2, 30 | "import/named": 2, 31 | "import/namespace": 2, 32 | "import/newline-after-import": 2, 33 | "import/no-duplicates": 2, 34 | "import/no-unresolved": 2, 35 | "import/unambiguous": 2, 36 | "jsx-quotes": [2, "prefer-single"], 37 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }], 38 | "keyword-spacing": [2], 39 | "max-depth": 0, 40 | "max-nested-callbacks": 0, 41 | "max-params": 0, 42 | "max-statements": 0, 43 | "new-cap": 0, 44 | "new-parens": 2, 45 | "no-alert": 2, 46 | "no-array-constructor": 2, 47 | "no-bitwise": 2, 48 | "no-caller": 2, 49 | "no-cond-assign": 2, 50 | "no-console": 0, 51 | "no-constant-condition": 2, 52 | "no-control-regex": 2, 53 | "no-debugger": 2, 54 | "no-delete-var": 2, 55 | "no-div-regex": 2, 56 | "no-dupe-keys": 2, 57 | "no-else-return": 0, 58 | "no-empty": 2, 59 | "no-empty-character-class": 2, 60 | "no-eq-null": 2, 61 | "no-eval": 2, 62 | "no-ex-assign": 2, 63 | "no-extend-native": 2, 64 | "no-extra-bind": 2, 65 | "no-extra-boolean-cast": 2, 66 | "no-extra-parens": 0, 67 | "no-extra-semi": 2, 68 | "no-fallthrough": 2, 69 | "no-floating-decimal": 2, 70 | "no-func-assign": 2, 71 | "no-global-assign": 2, 72 | "no-implied-eval": 2, 73 | "no-inline-comments": 2, 74 | "no-inner-declarations": 2, 75 | "no-invalid-regexp": 2, 76 | "no-irregular-whitespace": 2, 77 | "no-iterator": 2, 78 | "no-label-var": 2, 79 | "no-labels": 2, 80 | "no-lone-blocks": 2, 81 | "no-lonely-if": 2, 82 | "no-loop-func": 2, 83 | "no-mixed-requires": 0, 84 | "no-mixed-spaces-and-tabs": 2, 85 | "no-multi-spaces": 2, 86 | "no-multi-str": 2, 87 | "no-multiple-empty-lines": [2, { "max": 2 }], 88 | "no-nested-ternary": 2, 89 | "no-new": 2, 90 | "no-new-func": 2, 91 | "no-new-object": 2, 92 | "no-new-require": 2, 93 | "no-new-wrappers": 2, 94 | "no-obj-calls": 2, 95 | "no-octal": 2, 96 | "no-octal-escape": 2, 97 | "no-path-concat": 2, 98 | "no-plusplus": 0, 99 | "no-process-env": 0, 100 | "no-process-exit": 2, 101 | "no-proto": 2, 102 | "no-regex-spaces": 2, 103 | "no-reserved-keys": 0, 104 | "no-restricted-modules": 0, 105 | "no-return-assign": 2, 106 | "no-script-url": 2, 107 | "no-self-compare": 2, 108 | "no-sequences": 2, 109 | "no-shadow": 0, 110 | "no-shadow-restricted-names": 2, 111 | "no-sparse-arrays": 2, 112 | "no-sync": 0, 113 | "no-ternary": 0, 114 | "no-trailing-spaces": 2, 115 | "no-undef": 2, 116 | "no-undef-init": 2, 117 | "no-undefined": 2, 118 | "no-underscore-dangle": 0, 119 | "no-unreachable": 2, 120 | "no-unsafe-negation": 2, 121 | "no-unused-expressions": 2, 122 | "no-unused-vars": 2, 123 | "no-use-before-define": 0, 124 | "no-void": 0, 125 | "no-warning-comments": [2, { "terms": ["fixme"], "location": "start" }], 126 | "no-with": 2, 127 | "one-var": 0, 128 | "operator-assignment": 0, 129 | "padded-blocks": 0, 130 | "prettier/prettier": "error", 131 | "quote-props": [2, "as-needed"], 132 | "quotes": [2, "single", "avoid-escape"], 133 | "radix": 2, 134 | "react/display-name": 2, 135 | "react/jsx-boolean-value": [2, "always"], 136 | "react/jsx-closing-bracket-location": [ 137 | 2, 138 | { "selfClosing": "line-aligned", "nonEmpty": "props-aligned" } 139 | ], 140 | "react/jsx-no-undef": 2, 141 | "react/jsx-sort-props": [2, { "ignoreCase": true }], 142 | "react/jsx-uses-react": 2, 143 | "react/jsx-uses-vars": 2, 144 | "react/jsx-wrap-multilines": 2, 145 | "react/no-did-mount-set-state": 2, 146 | "react/no-did-update-set-state": 2, 147 | "react/no-multi-comp": [2, { "ignoreStateless": true }], 148 | "react/no-unescaped-entities": 0, 149 | "react/no-unknown-property": 2, 150 | "react/prop-types": 2, 151 | "react/react-in-jsx-scope": 2, 152 | "react/self-closing-comp": 2, 153 | "react/sort-prop-types": 2, 154 | "react-hooks/rules-of-hooks": "error", 155 | "react-hooks/exhaustive-deps": "error", 156 | "semi": [2, "always"], 157 | "semi-spacing": [2, { "before": false, "after": true }], 158 | "sort-vars": 0, 159 | "space-before-blocks": [2, "always"], 160 | "space-before-function-paren": [2, "never"], 161 | "space-in-brackets": 0, 162 | "space-in-parens": 0, 163 | "space-infix-ops": 2, 164 | "space-unary-ops": [2, { "words": true, "nonwords": false }], 165 | "spaced-comment": [2, "always", { "exceptions": ["-"] }], 166 | "strict": 0, 167 | "use-isnan": 2, 168 | "valid-jsdoc": 0, 169 | "valid-typeof": 2, 170 | "vars-on-top": 0, 171 | "wrap-iife": [2, "any"], 172 | "wrap-regex": 2, 173 | "yoda": 0 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Coderadio-client ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | name: Lint 8 | runs-on: ubuntu-20.04 9 | 10 | strategy: 11 | matrix: 12 | node-version: [20.x] 13 | 14 | steps: 15 | - name: Checkout Source Files 16 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 17 | 18 | - name: Install modules 19 | run: npm ci 20 | 21 | - name: Run ESLint 22 | run: npm run lint 23 | 24 | cypress-run: 25 | name: Cypress Test 26 | # Netlify deploys onto Ubuntu 20.04, so we should test on that os: 27 | runs-on: ubuntu-20.04 28 | strategy: 29 | matrix: 30 | browsers: [chrome, firefox] 31 | node-version: [20.x] 32 | steps: 33 | - name: Set Action Environment Variables 34 | run: | 35 | echo "CYPRESS_RECORD_KEY=${{ secrets.CYPRESS_RECORD_KEY }}" >> $GITHUB_ENV 36 | echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV 37 | echo "CYPRESS_INSTALL_BINARY=6.0.0" >> $GITHUB_ENV 38 | 39 | - name: Checkout 40 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 41 | 42 | - name: Cypress run 43 | uses: cypress-io/github-action@v2 44 | with: 45 | browser: ${{ matrix.browsers }} 46 | build: npm run build 47 | start: npm start 48 | wait-on: http://localhost:3001 49 | wait-on-timeout: 1200 50 | 51 | unit-test: 52 | name: Unit Test 53 | runs-on: ubuntu-20.04 54 | 55 | strategy: 56 | matrix: 57 | node-version: [20.x] 58 | 59 | steps: 60 | - name: Checkout Source Files 61 | uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 62 | 63 | - name: Install modules 64 | run: npm ci 65 | 66 | - name: Run tests 67 | run: npm test 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /cypress/videos 11 | 12 | # dotenv environment variables file 13 | .env 14 | .env.test 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | .vscode 26 | 27 | # Logs 28 | logs 29 | *.log 30 | npm-debug.log* 31 | yarn-debug.log* 32 | yarn-error.log* 33 | 34 | # Optional eslint cache 35 | .eslintcache 36 | 37 | ### Netlify ### 38 | .netlify 39 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | CYPRESS_INSTALL_BINARY=0 2 | engine-strict=true 3 | enable-pre-post-scripts=true 4 | package-manager-strict=false 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.19.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine":"auto", 3 | "semi": true, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "none", 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, freeCodeCamp.org 4 | 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | * Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | * Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | * Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![freeCodeCamp.org Social Banner](https://s3.amazonaws.com/freecodecamp/wide-social-banner.png) 2 | 3 | ## Coderadio Client UI 4 | 5 | This repository powers the current client application for the Code Radio at: . 6 | Eventually we will move this over to our Gatsby based client application for our curriculum and user profiles. 7 | 8 | You can learn more about the coderadio here: 9 | 10 | ### Local setup 11 | 12 | `npm ci` then `npm start` will open the app. 13 | 14 | To send errors to Sentry: `cp sample.env .env.local` and fill in the Sentry DSN from the project settings -------------------------------------------------------------------------------- /cypress-install.js: -------------------------------------------------------------------------------- 1 | const util = require('cypress/lib/util'); 2 | const execa = require('execa'); 3 | 4 | const pkg = util.pkgVersion(); 5 | 6 | (async () => { 7 | console.log('Installing Cypress ' + pkg); 8 | await execa('npm', ['run', 'cypress:install'], { 9 | env: { CYPRESS_INSTALL_BINARY: pkg } 10 | }); 11 | console.log('Cypress installed'); 12 | })(); 13 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "projectId": "kqzjwp" 3 | } -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/integration/home.js: -------------------------------------------------------------------------------- 1 | /* global cy */ 2 | describe('Landing page', () => { 3 | it('Should render', () => { 4 | cy.visit('http://localhost:3001'); 5 | cy.title().should('eq', 'freeCodeCamp.org Code Radio'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /cypress/integration/play-button.js: -------------------------------------------------------------------------------- 1 | describe('Stop and play the music', () => { 2 | beforeEach(() => { 3 | cy.visit('http://localhost:3001'); 4 | }); 5 | 6 | it('Click play button', () => { 7 | cy.get('audio') 8 | .invoke('attr', 'src') 9 | .should('contain', '.mp3') 10 | .then(() => { 11 | cy.get('#toggle-play-pause').should('be.visible').click(); 12 | cy.get('audio').should(audioElements => { 13 | const audioIsPaused = audioElements[0].paused; 14 | expect(audioIsPaused).to.eq(false); 15 | }); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | // / 3 | // *********************************************************** 4 | // This example plugins/index.js can be used to load plugins 5 | // 6 | // You can change the location of this file or turn off loading 7 | // the plugins file with the 'pluginsFile' configuration option. 8 | // 9 | // You can read more here: 10 | // https://on.cypress.io/plugins-guide 11 | // *********************************************************** 12 | 13 | // This function is called when a project is opened or re-opened (e.g. due to 14 | // the project's config changing) 15 | 16 | /** 17 | * @type {Cypress.PluginConfig} 18 | */ 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | }; 23 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | 2 | [build] 3 | base = "" 4 | publish = "/build" 5 | command = "npm run build" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coderadio", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-svg-core": "6.7.2", 7 | "@fortawesome/free-solid-svg-icons": "6.7.2", 8 | "@fortawesome/react-fontawesome": "0.2.2", 9 | "@sentry/react": "8.55.0", 10 | "@sentry/tracing": "7.120.3", 11 | "react": "18.3.1", 12 | "react-device-detect": "2.2.3", 13 | "react-dom": "18.3.1", 14 | "react-page-visibility": "7.0.0", 15 | "react-scripts": "5.0.1", 16 | "store": "2.0.12" 17 | }, 18 | "scripts": { 19 | "start": "PORT=3001 react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test --watchAll=false", 22 | "test:watch": "react-scripts test", 23 | "eject": "react-scripts eject", 24 | "precypress": "node cypress-install.js", 25 | "cypress": "cypress", 26 | "cypress:open": "npm run cypress open", 27 | "cypress:install": "cypress install && echo 'for use with ./cypress-install.js'", 28 | "lint": "prettier --check \"src/**/*.{md,js}\"", 29 | "lint:fix": "prettier --write \"src/**/*.{md,js}\"", 30 | "prepare": "husky" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "@testing-library/jest-dom": "6.6.3", 46 | "@testing-library/react": "16.3.0", 47 | "cypress": "13.17.0", 48 | "eslint-config-prettier": "9.1.0", 49 | "eslint-plugin-prettier": "5.2.6", 50 | "execa": "9.5.2", 51 | "husky": "9.1.7", 52 | "lint-staged": "15.5.1", 53 | "prettier": "3.5.3" 54 | }, 55 | "lint-staged": { 56 | "*.js": "npm run lint:fix" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | # Optional: Redirect default Netlify subdomain to primary domain 2 | https://freecodecamp-code-radio.netlify.com/* https://coderadio.freecodecamp.org/:splat 301! 3 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 24 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 48 | 49 | 50 | 54 | 59 | 64 | 68 | 73 | 78 | 83 | 87 | freeCodeCamp.org Code Radio 88 | 92 | 100 | 101 | 102 |
103 | 104 | 119 | 120 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>freecodecamp/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # Sentry DSN - a public id that identifies your app to Sentry 2 | REACT_APP_SENTRY_DSN= -------------------------------------------------------------------------------- /src/assets/Saron3.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/coderadio-client/646023e42b90430a0b95778bfbf1c7df3998d427/src/assets/Saron3.webm -------------------------------------------------------------------------------- /src/assets/pause.svg: -------------------------------------------------------------------------------- 1 | 2 | Pause Button 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/assets/play.svg: -------------------------------------------------------------------------------- 1 | 2 | Play Button 3 | 4 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as Sentry from '@sentry/react'; 3 | import store from 'store'; 4 | import { isIOS, isDesktop } from 'react-device-detect'; 5 | 6 | import Nav from './Nav'; 7 | import Main from './Main'; 8 | import Footer from './Footer'; 9 | import { buildEventSource } from '../utils/buildEventSource'; 10 | 11 | import '../css/App.css'; 12 | 13 | const sseUri = 14 | 'https://coderadio-admin-v2.freecodecamp.org/api/live/nowplaying/sse?cf_connect=%7B%22subs%22%3A%7B%22station%3Acoderadio%22%3A%7B%22recover%22%3Atrue%7D%7D%7D'; 15 | const jsonUri = `https://coderadio-admin-v2.freecodecamp.org/api/nowplaying_static/coderadio.json`; 16 | 17 | let sse = buildEventSource(sseUri); 18 | 19 | const CODERADIO_VOLUME = 'coderadio-volume'; 20 | 21 | sse.onerror = ({ message, error }) => { 22 | Sentry.addBreadcrumb({ 23 | message: 'WebSocket error: ' + message 24 | }); 25 | Sentry.captureException(error); 26 | }; 27 | 28 | export default class App extends React.Component { 29 | constructor(props) { 30 | super(props); 31 | this.state = { 32 | // General configuration options 33 | config: { 34 | metadataTimer: 1000 35 | }, 36 | fastConnection: navigator.connection 37 | ? navigator.connection.downlink > 1.5 38 | : false, 39 | 40 | /** 41 | * The equalizer data is held as a separate data set 42 | * to allow for easy implementation of visualizers. 43 | * With the ultimate goal of this allowing plug and 44 | * play visualizers. 45 | */ 46 | eq: {}, 47 | 48 | /** 49 | * Potentially removing the visualizer from this class 50 | * to build it as a stand alone element that can be 51 | * replaced by community submissions. 52 | */ 53 | visualizer: {}, 54 | 55 | /** 56 | * Some basic configuration for nicer audio transitions 57 | * (Used in earlier projects and just maintained). 58 | */ 59 | audioConfig: { 60 | targetVolume: 0, 61 | maxVolume: 0.5, 62 | volumeSteps: 0.05, 63 | fadeSteps: 0.01, 64 | currentVolume: 0.5, 65 | volumeTransitionSpeed: 10 66 | }, 67 | 68 | /** 69 | * This is where all the audio is pumped through. Due 70 | * to it being a single audio element, there should be 71 | * no memory leaks of extra floating audio elements. 72 | */ 73 | url: '', 74 | mounts: [], 75 | remotes: [], 76 | playing: null, 77 | captions: null, 78 | pausing: null, 79 | pullMeta: false, 80 | erroredStreams: [], 81 | 82 | // Note: the crossOrigin is needed to fix a CORS JavaScript requirement 83 | 84 | // There are a few *private* variables used 85 | currentSong: {}, 86 | songStartedAt: 0, 87 | songDuration: 0, 88 | listeners: 0, 89 | songHistory: [] 90 | }; 91 | 92 | this.togglePlay = this.togglePlay.bind(this); 93 | this.setUrl = this.setUrl.bind(this); 94 | this.setTargetVolume = this.setTargetVolume.bind(this); 95 | this.getNowPlaying = this.getNowPlaying.bind(this); 96 | this.updateVolume = this.updateVolume.bind(this); 97 | this.increaseVolume = this.increaseVolume.bind(this); 98 | this.decreaseVolume = this.decreaseVolume.bind(this); 99 | 100 | // Keyboard handlers 101 | this.addKeyboardHotKeysListener = 102 | this.addKeyboardHotKeysListener.bind(this); 103 | this.removeKeyboardHotKeysListener = 104 | this.removeKeyboardHotKeysListener.bind(this); 105 | this.handleKeyboardHotKeys = this.handleKeyboardHotKeys.bind(this); 106 | } 107 | 108 | isSpacePressed(event) { 109 | return event.key === ' '; 110 | } 111 | 112 | canTogglePlayPause() { 113 | // Prevent play/pause toggle when elements with ids in the following list are pressed. 114 | const disallowedIds = [ 115 | 'recent-song-history', 116 | 'toggle-play-pause', 117 | 'stream-select', 118 | 'keyboard-controls', 119 | 'toggle-button-nav' 120 | ]; 121 | return !disallowedIds.includes(document.activeElement.id); 122 | } 123 | 124 | isUpDownArrowPressed(event) { 125 | return event.key === 'ArrowUp' || event.key === 'ArrowDown'; 126 | } 127 | 128 | canAdjustVolume() { 129 | // Ignore arrow hot keys if focus is on volume slider or stream selector. 130 | const disallowedIds = ['volume-input', 'stream-select']; 131 | return !disallowedIds.includes(document.activeElement.id); 132 | } 133 | 134 | handleKeyboardHotKeys(event) { 135 | const keyMap = new Map(); 136 | keyMap.set(' ', this.togglePlay); 137 | keyMap.set('k', this.togglePlay); 138 | keyMap.set('ArrowUp', this.increaseVolume); 139 | keyMap.set('ArrowDown', this.decreaseVolume); 140 | 141 | if (!keyMap.has(event.key)) return; 142 | 143 | if (this.isSpacePressed(event) && !this.canTogglePlayPause()) return; 144 | 145 | if (this.isUpDownArrowPressed(event) && !this.canAdjustVolume()) return; 146 | 147 | try { 148 | keyMap.get(event.key)(); 149 | } catch (err) { 150 | console.log(`Bad callback for hotkey '${event.key}': ${err.message}`); 151 | } 152 | } 153 | 154 | addKeyboardHotKeysListener() { 155 | window.addEventListener('keydown', this.handleKeyboardHotKeys); 156 | } 157 | 158 | removeKeyboardHotKeysListener() { 159 | window.removeEventListener('keydown', this.handleKeyboardHotKeys); 160 | } 161 | 162 | // Set the players initial vol and crossOrigin 163 | setPlayerInitial() { 164 | /** 165 | * Get user volume level from local storage 166 | * if not available set to default 0.5. 167 | */ 168 | const maxVolume = 169 | store.get(CODERADIO_VOLUME) || this.state.audioConfig.maxVolume; 170 | this.setState( 171 | { 172 | audioConfig: { 173 | ...this.state.audioConfig, 174 | maxVolume, 175 | currentVolume: maxVolume 176 | } 177 | }, 178 | () => { 179 | this._player.volume = maxVolume; 180 | } 181 | ); 182 | } 183 | 184 | componentDidMount() { 185 | this.setPlayerInitial(); 186 | this.getNowPlaying(); 187 | if (isDesktop) { 188 | this.addKeyboardHotKeysListener(); 189 | } 190 | } 191 | 192 | componentWillUnmount() { 193 | if (isDesktop) { 194 | this.removeKeyboardHotKeysListener(); 195 | } 196 | sse.close(); 197 | } 198 | 199 | /** 200 | * If we ever change the URL, we need to update the player 201 | * and begin playing it again. This can happen if the server 202 | * resets the URL. 203 | */ 204 | async setUrl(url = false) { 205 | if (!url) return; 206 | 207 | if (this.state.playing) await this.pause(); 208 | 209 | this._player.src = url; 210 | this.setState({ 211 | url 212 | }); 213 | 214 | /** 215 | * Since the `playing` state is initially `null` when the app first loads 216 | * and is set to boolean when there is an user interaction, 217 | * we prevent the app from auto-playing the music 218 | * by only calling `this.play()` if the `playing` state is not `null`. 219 | */ 220 | if (this.state.playing !== null) { 221 | this.play(); 222 | } 223 | } 224 | 225 | play() { 226 | const { mounts, remotes } = this.state; 227 | 228 | let streamUrls = Array.from([...mounts, ...remotes], stream => stream.url); 229 | 230 | // Check if the url has been reset by pause 231 | if (!streamUrls.includes(this._player.src)) { 232 | this._player.src = this.state.url; 233 | this._player.load(); 234 | } 235 | 236 | this._player.volume = 0; 237 | this._player.play().then(() => { 238 | this.setState(state => { 239 | return { 240 | audioConfig: { ...state.audioConfig, currentVolume: 0 }, 241 | playing: true, 242 | pullMeta: true 243 | }; 244 | }); 245 | 246 | this.fadeUp(); 247 | }); 248 | } 249 | 250 | pause() { 251 | // Completely stop the audio element 252 | if (!this.state.playing) return Promise.resolve(); 253 | 254 | return new Promise(resolve => { 255 | this._player.pause(); 256 | this._player.load(); 257 | 258 | this.setState( 259 | { 260 | playing: false, 261 | pausing: false 262 | }, 263 | () => { 264 | // socket.close(); 265 | resolve(); 266 | } 267 | ); 268 | }); 269 | } 270 | 271 | /** 272 | * Very basic method that acts like the play/pause button 273 | * of a standard player. It loads in a new song if there 274 | * isn't already one loaded. 275 | */ 276 | togglePlay() { 277 | // If there already is a source, confirm it's playing or not 278 | if (this._player.src) { 279 | // If the player is paused, set the volume to 0 and fade up 280 | if (!this.state.playing) { 281 | this.play(); 282 | } 283 | // If it is already playing, fade the music out (resulting in a pause) 284 | else { 285 | this.fadeDown(); 286 | } 287 | } 288 | } 289 | 290 | setTargetVolume(volume) { 291 | let audioConfig = { ...this.state.audioConfig }; 292 | let maxVolume = parseFloat(Math.max(0, Math.min(1, volume).toFixed(2))); 293 | audioConfig.maxVolume = maxVolume; 294 | audioConfig.currentVolume = maxVolume; 295 | this._player.volume = audioConfig.maxVolume; 296 | this.setState( 297 | { 298 | audioConfig 299 | }, 300 | () => { 301 | // Save user volume to local storage 302 | store.set(CODERADIO_VOLUME, maxVolume); 303 | } 304 | ); 305 | } 306 | 307 | /** 308 | * Simple fade command to initiate the playing and pausing 309 | * in a more fluid method. 310 | */ 311 | fade(direction) { 312 | let audioConfig = { ...this.state.audioConfig }; 313 | audioConfig.targetVolume = 314 | direction.toLowerCase() === 'up' ? this.state.audioConfig.maxVolume : 0; 315 | this.setState( 316 | { 317 | audioConfig, 318 | pausing: direction === 'down' 319 | }, 320 | this.updateVolume 321 | ); 322 | } 323 | 324 | fadeUp() { 325 | this.fade('up'); 326 | } 327 | 328 | fadeDown() { 329 | this.fade('down'); 330 | } 331 | 332 | /** 333 | * In order to have nice fading, 334 | * this method adjusts the volume dynamically over time. 335 | */ 336 | updateVolume() { 337 | /** 338 | * In order to fix floating math issues, 339 | * we set the toFixed in order to avoid 0.999999999999 increments. 340 | */ 341 | let currentVolume = parseFloat(this._player.volume.toFixed(2)); 342 | /** 343 | * If the volume is correctly set to the target, no need to change it 344 | * 345 | * Note: On iOS devices, volume level is totally under user's control and cannot be programmatically set. 346 | * We pause the music immediately in this case. 347 | * (https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html) 348 | */ 349 | if (currentVolume === this.state.audioConfig.targetVolume || isIOS) { 350 | // If the audio is set to 0 and it’s been met, pause the audio 351 | if (this.state.audioConfig.targetVolume === 0 && this.state.pausing) 352 | this.pause(); 353 | 354 | // Unmet audio volume settings require it to be changed 355 | } else { 356 | /** 357 | * We capture the value of the next increment by either the configuration 358 | * or the difference between the current and target 359 | * if it's smaller than the increment. 360 | */ 361 | let volumeNextIncrement = Math.min( 362 | this.state.audioConfig.fadeSteps, 363 | Math.abs(this.state.audioConfig.targetVolume - this._player.volume) 364 | ); 365 | 366 | /** 367 | * Adjust the audio based on if the target is 368 | * higher or lower than the current. 369 | */ 370 | let volumeAdjust = 371 | this.state.audioConfig.targetVolume > this._player.volume 372 | ? volumeNextIncrement 373 | : -volumeNextIncrement; 374 | 375 | this._player.volume += volumeAdjust; 376 | 377 | let audioConfig = this.state.audioConfig; 378 | audioConfig.currentVolume += volumeAdjust; 379 | 380 | this.setState({ 381 | audioConfig 382 | }); 383 | // The speed at which the audio lowers is also controlled. 384 | setTimeout( 385 | this.updateVolume, 386 | this.state.audioConfig.volumeTransitionSpeed 387 | ); 388 | } 389 | } 390 | 391 | sortStreams = (streams, lowBitrate = false, shuffle = false) => { 392 | if (shuffle) { 393 | /** 394 | * Shuffling should only happen among streams with similar bitrates 395 | * since each relay displays listener numbers across relays. Shuffling 396 | * should be used to spread the load on initial stream selection. 397 | */ 398 | let bitrates = streams.map(stream => stream.bitrate); 399 | let maxBitrate = Math.max(...bitrates); 400 | return streams 401 | .filter(stream => { 402 | if (!lowBitrate) return stream.bitrate === maxBitrate; 403 | else return stream.bitrate !== maxBitrate; 404 | }) 405 | .sort(() => Math.random() - 0.5); 406 | } else { 407 | return streams.sort((a, b) => { 408 | if (lowBitrate) { 409 | // Sort by bitrate from low to high 410 | if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return -1; 411 | if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return 1; 412 | } else { 413 | // Sort by bitrate, from high to low 414 | if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return 1; 415 | if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return -1; 416 | } 417 | 418 | // If both items have the same bitrate, sort by listeners from low to high 419 | if (a.listeners.current < b.listeners.current) return -1; 420 | if (a.listeners.current > b.listeners.current) return 1; 421 | return 0; 422 | }); 423 | } 424 | }; 425 | 426 | getStreamUrl = (streams, lowBitrate) => { 427 | const sorted = this.sortStreams(streams, lowBitrate, true); 428 | return sorted[0].url; 429 | }; 430 | 431 | // Choose the stream based on the connection and availability of relay(remotes) 432 | setMountToConnection(mounts = [], remotes = []) { 433 | let url = null; 434 | if (this.state.fastConnection === false && remotes.length > 0) { 435 | url = this.getStreamUrl(remotes, true); 436 | } else if (this.state.fastConnection && remotes.length > 0) { 437 | url = this.getStreamUrl(remotes); 438 | } else if (this.state.fastConnection === false) { 439 | url = this.getStreamUrl(mounts, true); 440 | } else { 441 | url = this.getStreamUrl(mounts); 442 | } 443 | this._player.src = url; 444 | this.setState({ 445 | url 446 | }); 447 | } 448 | 449 | fetchJSON() { 450 | fetch(jsonUri) 451 | .then(response => { 452 | return response.json(); 453 | }) 454 | .then(np => { 455 | this.setState({ 456 | mounts: np.station.mounts, 457 | remotes: np.station.remotes, 458 | listeners: np.listeners.current, 459 | currentSong: np.now_playing.song, 460 | songStartedAt: np.now_playing.played_at * 1000, 461 | songDuration: np.now_playing.duration, 462 | pullMeta: false, 463 | songHistory: np.song_history 464 | }); 465 | this.setMountToConnection(np.station.mounts, np.station.remotes); 466 | }) 467 | .catch(() => {}); 468 | } 469 | 470 | getNowPlaying() { 471 | // Since json recives data faster than sse, set the data initially 472 | this.fetchJSON(); 473 | 474 | // Reconnect Timeout needs to be added 475 | sse.onmessage = event => { 476 | const data = JSON.parse(event.data); 477 | const np = data?.pub?.data?.np || null; 478 | if (np) { 479 | // Process Now Playing data in `np` var. 480 | // We look through the available mounts to find the default mount 481 | if (this.state.url === '') { 482 | this.setState({ 483 | mounts: np.station.mounts, 484 | remotes: np.station.remotes 485 | }); 486 | this.setMountToConnection(np.station.mounts, np.station.remotes); 487 | } 488 | if (this.state.listeners !== np.listeners.current) { 489 | this.setState({ 490 | listeners: np.listeners.current 491 | }); 492 | } 493 | // We only need to update the metadata if the song has been changed 494 | if ( 495 | np.now_playing.song.id !== this.state.currentSong.id || 496 | this.state.pullMeta 497 | ) { 498 | this.setState({ 499 | currentSong: np.now_playing.song, 500 | songStartedAt: np.now_playing.played_at * 1000, 501 | songDuration: np.now_playing.duration, 502 | pullMeta: false, 503 | songHistory: np.song_history 504 | }); 505 | } 506 | } 507 | }; 508 | } 509 | 510 | increaseVolume = () => 511 | this.setTargetVolume( 512 | Math.min( 513 | this.state.audioConfig.maxVolume + this.state.audioConfig.volumeSteps, 514 | 1 515 | ) 516 | ); 517 | 518 | decreaseVolume = () => 519 | this.setTargetVolume( 520 | Math.max( 521 | this.state.audioConfig.maxVolume - this.state.audioConfig.volumeSteps, 522 | 0 523 | ) 524 | ); 525 | 526 | onPlayerError = async () => { 527 | /** 528 | * This error handler works as follows: 529 | * - When the player cannot play the url: 530 | * - If the player's src is falsy and the `playing` state is being false, 531 | * return early. (It means the user has paused the player and 532 | * the src has been reset to an empty string). 533 | * - If the url is already in the `erroredStreams` list: Try another url. 534 | * - If the url is not in `erroredStreams`: Add the url to the list and 535 | * try another url. 536 | * - If `erroredStreams` has as many items as the list of available streams: 537 | * Pause the player because this means all of our urls are having issues. 538 | */ 539 | if (!this.state.playing && !this._player.src) return; 540 | 541 | const { mounts, remotes, erroredStreams, url } = this.state; 542 | const sortedStreams = this.sortStreams([...remotes, ...mounts]); 543 | const currentStream = sortedStreams.find(stream => stream.url === url); 544 | const isStreamInErroredList = erroredStreams.some( 545 | stream => stream.url === url 546 | ); 547 | const newErroredStreams = isStreamInErroredList 548 | ? erroredStreams 549 | : [...erroredStreams, currentStream]; 550 | 551 | // Pause if all streams are in the errored list 552 | if (newErroredStreams.length === sortedStreams.length) { 553 | await this.pause(); 554 | return; 555 | } 556 | 557 | /** 558 | * Available streams are those in `sortedStreams` 559 | * that don't exist in the errored list. 560 | */ 561 | const availableUrls = sortedStreams 562 | .filter( 563 | stream => 564 | !newErroredStreams.some( 565 | erroredStream => erroredStream.url === stream.url 566 | ) 567 | ) 568 | .map(({ url }) => url); 569 | 570 | // If the url is already in the errored list, use another url 571 | if (isStreamInErroredList) { 572 | this.setUrl(availableUrls[0]); 573 | } else { 574 | // Otherwise, add the url to the errored list, then use another url 575 | this.setState({ erroredStreams: newErroredStreams }, () => 576 | this.setUrl(availableUrls[0]) 577 | ); 578 | } 579 | }; 580 | 581 | render() { 582 | return ( 583 |
584 |
615 | ); 616 | } 617 | } 618 | -------------------------------------------------------------------------------- /src/components/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/components/CurrentSong.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const DEFAULT_ART = 5 | 'https://cdn-media-1.freecodecamp.org/code-radio/cover_placeholder.gif'; 6 | 7 | const CurrentSong = props => ( 8 |
15 | album art 20 |
21 |
22 |
29 |
{props.currentSong.title}
30 |
{props.currentSong.artist}
31 |
{props.currentSong.album}
32 |
Listeners: {props.listeners}
33 | {props.mountOptions} 34 |
35 |
36 | ); 37 | 38 | CurrentSong.propTypes = { 39 | currentSong: PropTypes.object, 40 | fastConnection: PropTypes.bool, 41 | listeners: PropTypes.number, 42 | mountOptions: PropTypes.node, 43 | playing: PropTypes.bool, 44 | progressVal: PropTypes.number, 45 | songDuration: PropTypes.number 46 | }; 47 | 48 | export default CurrentSong; 49 | -------------------------------------------------------------------------------- /src/components/Footer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-sort-props */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import PageVisibility from 'react-page-visibility'; 5 | import CurrentSong from './CurrentSong'; 6 | import Slider from './Slider'; 7 | import PlayPauseButton from './PlayPauseButton'; 8 | import SongHistory from './SongHistory'; 9 | 10 | export default class Footer extends React.PureComponent { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | progressVal: 0, 15 | currentSong: {}, 16 | progressInterval: null, 17 | alternativeMounts: null, 18 | isTabVisible: true 19 | }; 20 | this.updateProgress = this.updateProgress.bind(this); 21 | } 22 | 23 | componentDidUpdate(prevProps) { 24 | /** 25 | * If the song is new and we have all required props, 26 | * reset setInterval and currentSong. 27 | */ 28 | if ( 29 | this.state.currentSong.id !== prevProps.currentSong.id && 30 | this.props.songStartedAt && 31 | this.props.playing 32 | ) { 33 | // eslint-disable-next-line react/no-did-update-set-state 34 | this.setState({ 35 | currentSong: this.props.currentSong, 36 | alternativeMounts: [].concat(this.props.remotes, this.props.mounts) 37 | }); 38 | this.toggleInterval(); 39 | } else if (prevProps.playing !== this.props.playing) { 40 | this.toggleInterval(); 41 | } 42 | } 43 | 44 | componentWillUnmount() { 45 | this.stopCurrentInterval(); 46 | } 47 | 48 | startInterval() { 49 | this.stopCurrentInterval(); 50 | this.setState({ 51 | progressInterval: setInterval(this.updateProgress, 100) 52 | }); 53 | } 54 | 55 | stopCurrentInterval() { 56 | if (this.state.progressInterval) { 57 | clearInterval(this.state.progressInterval); 58 | } 59 | } 60 | 61 | toggleInterval() { 62 | if (this.props.playing && this.state.isTabVisible) this.startInterval(); 63 | else this.stopCurrentInterval(); 64 | } 65 | 66 | updateProgress() { 67 | let progressVal = parseInt( 68 | ((new Date().valueOf() - this.props.songStartedAt) / 1000).toFixed(2), 69 | 10 70 | ); 71 | this.setState({ progressVal }); 72 | } 73 | 74 | handleChange(event) { 75 | let { value } = event.target; 76 | this.props.setUrl(value); 77 | } 78 | 79 | handleVisibilityChange = isTabVisible => { 80 | this.setState({ isTabVisible }, () => { 81 | this.toggleInterval(); 82 | }); 83 | }; 84 | 85 | getMountOptions() { 86 | let mountOptions = ''; 87 | let { alternativeMounts } = this.state; 88 | if (alternativeMounts && this.props.url) { 89 | mountOptions = ( 90 | 103 | ); 104 | } 105 | return mountOptions; 106 | } 107 | 108 | render() { 109 | let { progressVal, currentSong, isTabVisible } = this.state; 110 | let { 111 | playing, 112 | songDuration, 113 | togglePlay, 114 | currentVolume, 115 | setTargetVolume, 116 | listeners, 117 | fastConnection, 118 | url 119 | } = this.props; 120 | 121 | return ( 122 | 123 |
124 | {isTabVisible && ( 125 | 129 | )} 130 | 139 | 144 | 148 |
149 |
150 | ); 151 | } 152 | } 153 | 154 | Footer.propTypes = { 155 | currentSong: PropTypes.object, 156 | currentVolume: PropTypes.number, 157 | fastConnection: PropTypes.bool, 158 | listeners: PropTypes.number, 159 | mounts: PropTypes.array, 160 | playing: PropTypes.bool, 161 | remotes: PropTypes.array, 162 | setTargetVolume: PropTypes.func, 163 | setUrl: PropTypes.func, 164 | songDuration: PropTypes.number, 165 | songHistory: PropTypes.array, 166 | songStartedAt: PropTypes.number, 167 | togglePlay: PropTypes.func, 168 | url: PropTypes.string 169 | }; 170 | -------------------------------------------------------------------------------- /src/components/Main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { isBrowser } from 'react-device-detect'; 4 | 5 | import Visualizer from './Visualizer'; 6 | import Video from '../assets/Saron3.webm'; 7 | 8 | const Main = props => { 9 | return ( 10 |
11 |
12 |

Welcome to Code Radio.

13 |

24/7 music designed for coding.

14 |
15 | {isBrowser && ( 16 | <> 17 |
18 | 27 |
28 | 29 |
30 | Keyboard Controls 31 |
32 |
Play/Pause:
33 |
Spacebar or "k"
34 |
Volume:
35 |
Up Arrow / Down Arrow
36 |
37 |
38 | 39 | )} 40 |
41 | ); 42 | }; 43 | 44 | Main.propTypes = { 45 | fastConnection: PropTypes.bool, 46 | player: PropTypes.object, 47 | playing: PropTypes.bool 48 | }; 49 | 50 | export default Main; 51 | -------------------------------------------------------------------------------- /src/components/Nav.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | export default function Nav() { 4 | const [isOpen, setIsOpen] = useState(false); 5 | 6 | const toggleSidenav = () => { 7 | setIsOpen(!isOpen); 8 | }; 9 | 10 | const links = [ 11 | { href: 'https://www.freecodecamp.org/news/', text: 'News' }, 12 | { href: 'https://www.freecodecamp.org/forum/', text: 'Forum' }, 13 | { href: 'https://www.freecodecamp.org/learn/', text: 'Learn' } 14 | ]; 15 | 16 | return ( 17 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/components/Nav.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | 4 | import Nav from './Nav'; 5 | 6 | describe('