Repository: freeCodeCamp/coderadio-client Branch: main Commit: 646023e42b90 Files: 41 Total size: 71.3 KB Directory structure: gitextract_91wetxtl/ ├── .eslintrc ├── .eslintrc-base.json ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .husky/ │ ├── .gitignore │ └── pre-commit ├── .npmrc ├── .nvmrc ├── .prettierrc ├── LICENSE ├── README.md ├── cypress/ │ ├── fixtures/ │ │ └── example.json │ ├── integration/ │ │ ├── home.js │ │ └── play-button.js │ ├── plugins/ │ │ └── index.js │ └── support/ │ ├── commands.js │ └── index.js ├── cypress-install.js ├── cypress.json ├── netlify.toml ├── package.json ├── public/ │ ├── _redirects │ └── index.html ├── renovate.json ├── sample.env └── src/ ├── assets/ │ └── Saron3.webm ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc ================================================ { "extends": [ "react-app", "./.eslintrc-base.json", "plugin:prettier/recommended" ], "globals": { "Promise": true, "window": true, "$": true, "ga": true, "jQuery": true, "router": true }, "settings": { "import/ignore": ["node_modules", "\\.json$"], "import/extensions": [".js", ".jsx"] } } ================================================ FILE: .eslintrc-base.json ================================================ { "rules": { "max-len": [ "error", { "code": 80, "ignoreUrls": true, "ignoreTemplateLiterals": true } ], "block-scoped-var": 0, "brace-style": [2, "1tbs", { "allowSingleLine": true }], "camelcase": 2, "comma-dangle": 2, "comma-spacing": [2, { "before": false, "after": true }], "comma-style": [2, "last"], "complexity": 0, "consistent-return": 2, "consistent-this": 0, "curly": 2, "default-case": 2, "dot-notation": 0, "eol-last": 2, "eqeqeq": 2, "func-call-spacing": 2, "func-names": 0, "func-style": 0, "guard-for-in": 2, "handle-callback-err": 2, "import/default": 2, "import/export": 2, "import/extensions": [0, "always"], "import/first": 2, "import/named": 2, "import/namespace": 2, "import/newline-after-import": 2, "import/no-duplicates": 2, "import/no-unresolved": 2, "import/unambiguous": 2, "jsx-quotes": [2, "prefer-single"], "key-spacing": [2, { "beforeColon": false, "afterColon": true }], "keyword-spacing": [2], "max-depth": 0, "max-nested-callbacks": 0, "max-params": 0, "max-statements": 0, "new-cap": 0, "new-parens": 2, "no-alert": 2, "no-array-constructor": 2, "no-bitwise": 2, "no-caller": 2, "no-cond-assign": 2, "no-console": 0, "no-constant-condition": 2, "no-control-regex": 2, "no-debugger": 2, "no-delete-var": 2, "no-div-regex": 2, "no-dupe-keys": 2, "no-else-return": 0, "no-empty": 2, "no-empty-character-class": 2, "no-eq-null": 2, "no-eval": 2, "no-ex-assign": 2, "no-extend-native": 2, "no-extra-bind": 2, "no-extra-boolean-cast": 2, "no-extra-parens": 0, "no-extra-semi": 2, "no-fallthrough": 2, "no-floating-decimal": 2, "no-func-assign": 2, "no-global-assign": 2, "no-implied-eval": 2, "no-inline-comments": 2, "no-inner-declarations": 2, "no-invalid-regexp": 2, "no-irregular-whitespace": 2, "no-iterator": 2, "no-label-var": 2, "no-labels": 2, "no-lone-blocks": 2, "no-lonely-if": 2, "no-loop-func": 2, "no-mixed-requires": 0, "no-mixed-spaces-and-tabs": 2, "no-multi-spaces": 2, "no-multi-str": 2, "no-multiple-empty-lines": [2, { "max": 2 }], "no-nested-ternary": 2, "no-new": 2, "no-new-func": 2, "no-new-object": 2, "no-new-require": 2, "no-new-wrappers": 2, "no-obj-calls": 2, "no-octal": 2, "no-octal-escape": 2, "no-path-concat": 2, "no-plusplus": 0, "no-process-env": 0, "no-process-exit": 2, "no-proto": 2, "no-regex-spaces": 2, "no-reserved-keys": 0, "no-restricted-modules": 0, "no-return-assign": 2, "no-script-url": 2, "no-self-compare": 2, "no-sequences": 2, "no-shadow": 0, "no-shadow-restricted-names": 2, "no-sparse-arrays": 2, "no-sync": 0, "no-ternary": 0, "no-trailing-spaces": 2, "no-undef": 2, "no-undef-init": 2, "no-undefined": 2, "no-underscore-dangle": 0, "no-unreachable": 2, "no-unsafe-negation": 2, "no-unused-expressions": 2, "no-unused-vars": 2, "no-use-before-define": 0, "no-void": 0, "no-warning-comments": [2, { "terms": ["fixme"], "location": "start" }], "no-with": 2, "one-var": 0, "operator-assignment": 0, "padded-blocks": 0, "prettier/prettier": "error", "quote-props": [2, "as-needed"], "quotes": [2, "single", "avoid-escape"], "radix": 2, "react/display-name": 2, "react/jsx-boolean-value": [2, "always"], "react/jsx-closing-bracket-location": [ 2, { "selfClosing": "line-aligned", "nonEmpty": "props-aligned" } ], "react/jsx-no-undef": 2, "react/jsx-sort-props": [2, { "ignoreCase": true }], "react/jsx-uses-react": 2, "react/jsx-uses-vars": 2, "react/jsx-wrap-multilines": 2, "react/no-did-mount-set-state": 2, "react/no-did-update-set-state": 2, "react/no-multi-comp": [2, { "ignoreStateless": true }], "react/no-unescaped-entities": 0, "react/no-unknown-property": 2, "react/prop-types": 2, "react/react-in-jsx-scope": 2, "react/self-closing-comp": 2, "react/sort-prop-types": 2, "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", "semi": [2, "always"], "semi-spacing": [2, { "before": false, "after": true }], "sort-vars": 0, "space-before-blocks": [2, "always"], "space-before-function-paren": [2, "never"], "space-in-brackets": 0, "space-in-parens": 0, "space-infix-ops": 2, "space-unary-ops": [2, { "words": true, "nonwords": false }], "spaced-comment": [2, "always", { "exceptions": ["-"] }], "strict": 0, "use-isnan": 2, "valid-jsdoc": 0, "valid-typeof": 2, "vars-on-top": 0, "wrap-iife": [2, "any"], "wrap-regex": 2, "yoda": 0 } } ================================================ FILE: .github/workflows/ci.yml ================================================ name: Coderadio-client ci on: [push, pull_request] jobs: lint: name: Lint runs-on: ubuntu-20.04 strategy: matrix: node-version: [20.x] steps: - name: Checkout Source Files uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - name: Install modules run: npm ci - name: Run ESLint run: npm run lint cypress-run: name: Cypress Test # Netlify deploys onto Ubuntu 20.04, so we should test on that os: runs-on: ubuntu-20.04 strategy: matrix: browsers: [chrome, firefox] node-version: [20.x] steps: - name: Set Action Environment Variables run: | echo "CYPRESS_RECORD_KEY=${{ secrets.CYPRESS_RECORD_KEY }}" >> $GITHUB_ENV echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> $GITHUB_ENV echo "CYPRESS_INSTALL_BINARY=6.0.0" >> $GITHUB_ENV - name: Checkout uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - name: Cypress run uses: cypress-io/github-action@v2 with: browser: ${{ matrix.browsers }} build: npm run build start: npm start wait-on: http://localhost:3001 wait-on-timeout: 1200 unit-test: name: Unit Test runs-on: ubuntu-20.04 strategy: matrix: node-version: [20.x] steps: - name: Checkout Source Files uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2 - name: Install modules run: npm ci - name: Run tests run: npm test ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage /cypress/videos # dotenv environment variables file .env .env.test .env.local .env.development.local .env.test.local .env.production.local # production /build # misc .DS_Store .vscode # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Optional eslint cache .eslintcache ### Netlify ### .netlify ================================================ FILE: .husky/.gitignore ================================================ _ ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .npmrc ================================================ CYPRESS_INSTALL_BINARY=0 engine-strict=true enable-pre-post-scripts=true package-manager-strict=false ================================================ FILE: .nvmrc ================================================ 20.19.0 ================================================ FILE: .prettierrc ================================================ { "endOfLine":"auto", "semi": true, "singleQuote": true, "jsxSingleQuote": true, "tabWidth": 2, "trailingComma": "none", "arrowParens": "avoid" } ================================================ FILE: LICENSE ================================================ BSD 3-Clause License Copyright (c) 2018, freeCodeCamp.org All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: README.md ================================================ ![freeCodeCamp.org Social Banner](https://s3.amazonaws.com/freecodecamp/wide-social-banner.png) ## Coderadio Client UI This repository powers the current client application for the Code Radio at: . Eventually we will move this over to our Gatsby based client application for our curriculum and user profiles. You can learn more about the coderadio here: ### Local setup `npm ci` then `npm start` will open the app. To send errors to Sentry: `cp sample.env .env.local` and fill in the Sentry DSN from the project settings ================================================ FILE: cypress/fixtures/example.json ================================================ { "name": "Using fixtures to represent data", "email": "hello@cypress.io", "body": "Fixtures are a great way to mock data for responses to routes" } ================================================ FILE: cypress/integration/home.js ================================================ /* global cy */ describe('Landing page', () => { it('Should render', () => { cy.visit('http://localhost:3001'); cy.title().should('eq', 'freeCodeCamp.org Code Radio'); }); }); ================================================ FILE: cypress/integration/play-button.js ================================================ describe('Stop and play the music', () => { beforeEach(() => { cy.visit('http://localhost:3001'); }); it('Click play button', () => { cy.get('audio') .invoke('attr', 'src') .should('contain', '.mp3') .then(() => { cy.get('#toggle-play-pause').should('be.visible').click(); cy.get('audio').should(audioElements => { const audioIsPaused = audioElements[0].paused; expect(audioIsPaused).to.eq(false); }); }); }); }); ================================================ FILE: cypress/plugins/index.js ================================================ /* eslint-disable no-unused-vars */ // / // *********************************************************** // This example plugins/index.js can be used to load plugins // // You can change the location of this file or turn off loading // the plugins file with the 'pluginsFile' configuration option. // // You can read more here: // https://on.cypress.io/plugins-guide // *********************************************************** // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) /** * @type {Cypress.PluginConfig} */ module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config }; ================================================ FILE: cypress/support/commands.js ================================================ // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite // existing commands. // // For more comprehensive examples of custom // commands please read more here: // https://on.cypress.io/custom-commands // *********************************************** // // // -- This is a parent command -- // Cypress.Commands.add("login", (email, password) => { ... }) // // // -- This is a child command -- // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) // // // -- This is a dual command -- // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) // // // -- This will overwrite an existing command -- // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) ================================================ FILE: cypress/support/index.js ================================================ // *********************************************************** // This example support/index.js is processed and // loaded automatically before your test files. // // This is a great place to put global configuration and // behavior that modifies Cypress. // // You can change the location of this file or turn off // automatically serving support files with the // 'supportFile' configuration option. // // You can read more here: // https://on.cypress.io/configuration // *********************************************************** // Import commands.js using ES2015 syntax: import './commands'; // Alternatively you can use CommonJS syntax: // require('./commands') ================================================ FILE: cypress-install.js ================================================ const util = require('cypress/lib/util'); const execa = require('execa'); const pkg = util.pkgVersion(); (async () => { console.log('Installing Cypress ' + pkg); await execa('npm', ['run', 'cypress:install'], { env: { CYPRESS_INSTALL_BINARY: pkg } }); console.log('Cypress installed'); })(); ================================================ FILE: cypress.json ================================================ { "projectId": "kqzjwp" } ================================================ FILE: netlify.toml ================================================ [build] base = "" publish = "/build" command = "npm run build" ================================================ FILE: package.json ================================================ { "name": "coderadio", "version": "0.1.0", "private": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "6.7.2", "@fortawesome/free-solid-svg-icons": "6.7.2", "@fortawesome/react-fontawesome": "0.2.2", "@sentry/react": "8.55.0", "@sentry/tracing": "7.120.3", "react": "18.3.1", "react-device-detect": "2.2.3", "react-dom": "18.3.1", "react-page-visibility": "7.0.0", "react-scripts": "5.0.1", "store": "2.0.12" }, "scripts": { "start": "PORT=3001 react-scripts start", "build": "react-scripts build", "test": "react-scripts test --watchAll=false", "test:watch": "react-scripts test", "eject": "react-scripts eject", "precypress": "node cypress-install.js", "cypress": "cypress", "cypress:open": "npm run cypress open", "cypress:install": "cypress install && echo 'for use with ./cypress-install.js'", "lint": "prettier --check \"src/**/*.{md,js}\"", "lint:fix": "prettier --write \"src/**/*.{md,js}\"", "prepare": "husky" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.3.0", "cypress": "13.17.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-prettier": "5.2.6", "execa": "9.5.2", "husky": "9.1.7", "lint-staged": "15.5.1", "prettier": "3.5.3" }, "lint-staged": { "*.js": "npm run lint:fix" } } ================================================ FILE: public/_redirects ================================================ # Optional: Redirect default Netlify subdomain to primary domain https://freecodecamp-code-radio.netlify.com/* https://coderadio.freecodecamp.org/:splat 301! ================================================ FILE: public/index.html ================================================ freeCodeCamp.org Code Radio
================================================ FILE: renovate.json ================================================ { "extends": ["github>freecodecamp/renovate-config"] } ================================================ FILE: sample.env ================================================ # Sentry DSN - a public id that identifies your app to Sentry REACT_APP_SENTRY_DSN= ================================================ FILE: src/components/App.js ================================================ import React from 'react'; import * as Sentry from '@sentry/react'; import store from 'store'; import { isIOS, isDesktop } from 'react-device-detect'; import Nav from './Nav'; import Main from './Main'; import Footer from './Footer'; import { buildEventSource } from '../utils/buildEventSource'; import '../css/App.css'; const sseUri = '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'; const jsonUri = `https://coderadio-admin-v2.freecodecamp.org/api/nowplaying_static/coderadio.json`; let sse = buildEventSource(sseUri); const CODERADIO_VOLUME = 'coderadio-volume'; sse.onerror = ({ message, error }) => { Sentry.addBreadcrumb({ message: 'WebSocket error: ' + message }); Sentry.captureException(error); }; export default class App extends React.Component { constructor(props) { super(props); this.state = { // General configuration options config: { metadataTimer: 1000 }, fastConnection: navigator.connection ? navigator.connection.downlink > 1.5 : false, /** * The equalizer data is held as a separate data set * to allow for easy implementation of visualizers. * With the ultimate goal of this allowing plug and * play visualizers. */ eq: {}, /** * Potentially removing the visualizer from this class * to build it as a stand alone element that can be * replaced by community submissions. */ visualizer: {}, /** * Some basic configuration for nicer audio transitions * (Used in earlier projects and just maintained). */ audioConfig: { targetVolume: 0, maxVolume: 0.5, volumeSteps: 0.05, fadeSteps: 0.01, currentVolume: 0.5, volumeTransitionSpeed: 10 }, /** * This is where all the audio is pumped through. Due * to it being a single audio element, there should be * no memory leaks of extra floating audio elements. */ url: '', mounts: [], remotes: [], playing: null, captions: null, pausing: null, pullMeta: false, erroredStreams: [], // Note: the crossOrigin is needed to fix a CORS JavaScript requirement // There are a few *private* variables used currentSong: {}, songStartedAt: 0, songDuration: 0, listeners: 0, songHistory: [] }; this.togglePlay = this.togglePlay.bind(this); this.setUrl = this.setUrl.bind(this); this.setTargetVolume = this.setTargetVolume.bind(this); this.getNowPlaying = this.getNowPlaying.bind(this); this.updateVolume = this.updateVolume.bind(this); this.increaseVolume = this.increaseVolume.bind(this); this.decreaseVolume = this.decreaseVolume.bind(this); // Keyboard handlers this.addKeyboardHotKeysListener = this.addKeyboardHotKeysListener.bind(this); this.removeKeyboardHotKeysListener = this.removeKeyboardHotKeysListener.bind(this); this.handleKeyboardHotKeys = this.handleKeyboardHotKeys.bind(this); } isSpacePressed(event) { return event.key === ' '; } canTogglePlayPause() { // Prevent play/pause toggle when elements with ids in the following list are pressed. const disallowedIds = [ 'recent-song-history', 'toggle-play-pause', 'stream-select', 'keyboard-controls', 'toggle-button-nav' ]; return !disallowedIds.includes(document.activeElement.id); } isUpDownArrowPressed(event) { return event.key === 'ArrowUp' || event.key === 'ArrowDown'; } canAdjustVolume() { // Ignore arrow hot keys if focus is on volume slider or stream selector. const disallowedIds = ['volume-input', 'stream-select']; return !disallowedIds.includes(document.activeElement.id); } handleKeyboardHotKeys(event) { const keyMap = new Map(); keyMap.set(' ', this.togglePlay); keyMap.set('k', this.togglePlay); keyMap.set('ArrowUp', this.increaseVolume); keyMap.set('ArrowDown', this.decreaseVolume); if (!keyMap.has(event.key)) return; if (this.isSpacePressed(event) && !this.canTogglePlayPause()) return; if (this.isUpDownArrowPressed(event) && !this.canAdjustVolume()) return; try { keyMap.get(event.key)(); } catch (err) { console.log(`Bad callback for hotkey '${event.key}': ${err.message}`); } } addKeyboardHotKeysListener() { window.addEventListener('keydown', this.handleKeyboardHotKeys); } removeKeyboardHotKeysListener() { window.removeEventListener('keydown', this.handleKeyboardHotKeys); } // Set the players initial vol and crossOrigin setPlayerInitial() { /** * Get user volume level from local storage * if not available set to default 0.5. */ const maxVolume = store.get(CODERADIO_VOLUME) || this.state.audioConfig.maxVolume; this.setState( { audioConfig: { ...this.state.audioConfig, maxVolume, currentVolume: maxVolume } }, () => { this._player.volume = maxVolume; } ); } componentDidMount() { this.setPlayerInitial(); this.getNowPlaying(); if (isDesktop) { this.addKeyboardHotKeysListener(); } } componentWillUnmount() { if (isDesktop) { this.removeKeyboardHotKeysListener(); } sse.close(); } /** * If we ever change the URL, we need to update the player * and begin playing it again. This can happen if the server * resets the URL. */ async setUrl(url = false) { if (!url) return; if (this.state.playing) await this.pause(); this._player.src = url; this.setState({ url }); /** * Since the `playing` state is initially `null` when the app first loads * and is set to boolean when there is an user interaction, * we prevent the app from auto-playing the music * by only calling `this.play()` if the `playing` state is not `null`. */ if (this.state.playing !== null) { this.play(); } } play() { const { mounts, remotes } = this.state; let streamUrls = Array.from([...mounts, ...remotes], stream => stream.url); // Check if the url has been reset by pause if (!streamUrls.includes(this._player.src)) { this._player.src = this.state.url; this._player.load(); } this._player.volume = 0; this._player.play().then(() => { this.setState(state => { return { audioConfig: { ...state.audioConfig, currentVolume: 0 }, playing: true, pullMeta: true }; }); this.fadeUp(); }); } pause() { // Completely stop the audio element if (!this.state.playing) return Promise.resolve(); return new Promise(resolve => { this._player.pause(); this._player.load(); this.setState( { playing: false, pausing: false }, () => { // socket.close(); resolve(); } ); }); } /** * Very basic method that acts like the play/pause button * of a standard player. It loads in a new song if there * isn't already one loaded. */ togglePlay() { // If there already is a source, confirm it's playing or not if (this._player.src) { // If the player is paused, set the volume to 0 and fade up if (!this.state.playing) { this.play(); } // If it is already playing, fade the music out (resulting in a pause) else { this.fadeDown(); } } } setTargetVolume(volume) { let audioConfig = { ...this.state.audioConfig }; let maxVolume = parseFloat(Math.max(0, Math.min(1, volume).toFixed(2))); audioConfig.maxVolume = maxVolume; audioConfig.currentVolume = maxVolume; this._player.volume = audioConfig.maxVolume; this.setState( { audioConfig }, () => { // Save user volume to local storage store.set(CODERADIO_VOLUME, maxVolume); } ); } /** * Simple fade command to initiate the playing and pausing * in a more fluid method. */ fade(direction) { let audioConfig = { ...this.state.audioConfig }; audioConfig.targetVolume = direction.toLowerCase() === 'up' ? this.state.audioConfig.maxVolume : 0; this.setState( { audioConfig, pausing: direction === 'down' }, this.updateVolume ); } fadeUp() { this.fade('up'); } fadeDown() { this.fade('down'); } /** * In order to have nice fading, * this method adjusts the volume dynamically over time. */ updateVolume() { /** * In order to fix floating math issues, * we set the toFixed in order to avoid 0.999999999999 increments. */ let currentVolume = parseFloat(this._player.volume.toFixed(2)); /** * If the volume is correctly set to the target, no need to change it * * Note: On iOS devices, volume level is totally under user's control and cannot be programmatically set. * We pause the music immediately in this case. * (https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html) */ if (currentVolume === this.state.audioConfig.targetVolume || isIOS) { // If the audio is set to 0 and it’s been met, pause the audio if (this.state.audioConfig.targetVolume === 0 && this.state.pausing) this.pause(); // Unmet audio volume settings require it to be changed } else { /** * We capture the value of the next increment by either the configuration * or the difference between the current and target * if it's smaller than the increment. */ let volumeNextIncrement = Math.min( this.state.audioConfig.fadeSteps, Math.abs(this.state.audioConfig.targetVolume - this._player.volume) ); /** * Adjust the audio based on if the target is * higher or lower than the current. */ let volumeAdjust = this.state.audioConfig.targetVolume > this._player.volume ? volumeNextIncrement : -volumeNextIncrement; this._player.volume += volumeAdjust; let audioConfig = this.state.audioConfig; audioConfig.currentVolume += volumeAdjust; this.setState({ audioConfig }); // The speed at which the audio lowers is also controlled. setTimeout( this.updateVolume, this.state.audioConfig.volumeTransitionSpeed ); } } sortStreams = (streams, lowBitrate = false, shuffle = false) => { if (shuffle) { /** * Shuffling should only happen among streams with similar bitrates * since each relay displays listener numbers across relays. Shuffling * should be used to spread the load on initial stream selection. */ let bitrates = streams.map(stream => stream.bitrate); let maxBitrate = Math.max(...bitrates); return streams .filter(stream => { if (!lowBitrate) return stream.bitrate === maxBitrate; else return stream.bitrate !== maxBitrate; }) .sort(() => Math.random() - 0.5); } else { return streams.sort((a, b) => { if (lowBitrate) { // Sort by bitrate from low to high if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return -1; if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return 1; } else { // Sort by bitrate, from high to low if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return 1; if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return -1; } // If both items have the same bitrate, sort by listeners from low to high if (a.listeners.current < b.listeners.current) return -1; if (a.listeners.current > b.listeners.current) return 1; return 0; }); } }; getStreamUrl = (streams, lowBitrate) => { const sorted = this.sortStreams(streams, lowBitrate, true); return sorted[0].url; }; // Choose the stream based on the connection and availability of relay(remotes) setMountToConnection(mounts = [], remotes = []) { let url = null; if (this.state.fastConnection === false && remotes.length > 0) { url = this.getStreamUrl(remotes, true); } else if (this.state.fastConnection && remotes.length > 0) { url = this.getStreamUrl(remotes); } else if (this.state.fastConnection === false) { url = this.getStreamUrl(mounts, true); } else { url = this.getStreamUrl(mounts); } this._player.src = url; this.setState({ url }); } fetchJSON() { fetch(jsonUri) .then(response => { return response.json(); }) .then(np => { this.setState({ mounts: np.station.mounts, remotes: np.station.remotes, listeners: np.listeners.current, currentSong: np.now_playing.song, songStartedAt: np.now_playing.played_at * 1000, songDuration: np.now_playing.duration, pullMeta: false, songHistory: np.song_history }); this.setMountToConnection(np.station.mounts, np.station.remotes); }) .catch(() => {}); } getNowPlaying() { // Since json recives data faster than sse, set the data initially this.fetchJSON(); // Reconnect Timeout needs to be added sse.onmessage = event => { const data = JSON.parse(event.data); const np = data?.pub?.data?.np || null; if (np) { // Process Now Playing data in `np` var. // We look through the available mounts to find the default mount if (this.state.url === '') { this.setState({ mounts: np.station.mounts, remotes: np.station.remotes }); this.setMountToConnection(np.station.mounts, np.station.remotes); } if (this.state.listeners !== np.listeners.current) { this.setState({ listeners: np.listeners.current }); } // We only need to update the metadata if the song has been changed if ( np.now_playing.song.id !== this.state.currentSong.id || this.state.pullMeta ) { this.setState({ currentSong: np.now_playing.song, songStartedAt: np.now_playing.played_at * 1000, songDuration: np.now_playing.duration, pullMeta: false, songHistory: np.song_history }); } } }; } increaseVolume = () => this.setTargetVolume( Math.min( this.state.audioConfig.maxVolume + this.state.audioConfig.volumeSteps, 1 ) ); decreaseVolume = () => this.setTargetVolume( Math.max( this.state.audioConfig.maxVolume - this.state.audioConfig.volumeSteps, 0 ) ); onPlayerError = async () => { /** * This error handler works as follows: * - When the player cannot play the url: * - If the player's src is falsy and the `playing` state is being false, * return early. (It means the user has paused the player and * the src has been reset to an empty string). * - If the url is already in the `erroredStreams` list: Try another url. * - If the url is not in `erroredStreams`: Add the url to the list and * try another url. * - If `erroredStreams` has as many items as the list of available streams: * Pause the player because this means all of our urls are having issues. */ if (!this.state.playing && !this._player.src) return; const { mounts, remotes, erroredStreams, url } = this.state; const sortedStreams = this.sortStreams([...remotes, ...mounts]); const currentStream = sortedStreams.find(stream => stream.url === url); const isStreamInErroredList = erroredStreams.some( stream => stream.url === url ); const newErroredStreams = isStreamInErroredList ? erroredStreams : [...erroredStreams, currentStream]; // Pause if all streams are in the errored list if (newErroredStreams.length === sortedStreams.length) { await this.pause(); return; } /** * Available streams are those in `sortedStreams` * that don't exist in the errored list. */ const availableUrls = sortedStreams .filter( stream => !newErroredStreams.some( erroredStream => erroredStream.url === stream.url ) ) .map(({ url }) => url); // If the url is already in the errored list, use another url if (isStreamInErroredList) { this.setUrl(availableUrls[0]); } else { // Otherwise, add the url to the errored list, then use another url this.setState({ erroredStreams: newErroredStreams }, () => this.setUrl(availableUrls[0]) ); } }; render() { return (
); } } ================================================ FILE: src/components/App.test.js ================================================ import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; it('renders without crashing', () => { const div = document.createElement('div'); ReactDOM.render(, div); ReactDOM.unmountComponentAtNode(div); }); ================================================ FILE: src/components/CurrentSong.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; const DEFAULT_ART = 'https://cdn-media-1.freecodecamp.org/code-radio/cover_placeholder.gif'; const CurrentSong = props => (
album art
{props.currentSong.title}
{props.currentSong.artist}
{props.currentSong.album}
Listeners: {props.listeners}
{props.mountOptions}
); CurrentSong.propTypes = { currentSong: PropTypes.object, fastConnection: PropTypes.bool, listeners: PropTypes.number, mountOptions: PropTypes.node, playing: PropTypes.bool, progressVal: PropTypes.number, songDuration: PropTypes.number }; export default CurrentSong; ================================================ FILE: src/components/Footer.js ================================================ /* eslint-disable react/jsx-sort-props */ import React from 'react'; import PropTypes from 'prop-types'; import PageVisibility from 'react-page-visibility'; import CurrentSong from './CurrentSong'; import Slider from './Slider'; import PlayPauseButton from './PlayPauseButton'; import SongHistory from './SongHistory'; export default class Footer extends React.PureComponent { constructor(props) { super(props); this.state = { progressVal: 0, currentSong: {}, progressInterval: null, alternativeMounts: null, isTabVisible: true }; this.updateProgress = this.updateProgress.bind(this); } componentDidUpdate(prevProps) { /** * If the song is new and we have all required props, * reset setInterval and currentSong. */ if ( this.state.currentSong.id !== prevProps.currentSong.id && this.props.songStartedAt && this.props.playing ) { // eslint-disable-next-line react/no-did-update-set-state this.setState({ currentSong: this.props.currentSong, alternativeMounts: [].concat(this.props.remotes, this.props.mounts) }); this.toggleInterval(); } else if (prevProps.playing !== this.props.playing) { this.toggleInterval(); } } componentWillUnmount() { this.stopCurrentInterval(); } startInterval() { this.stopCurrentInterval(); this.setState({ progressInterval: setInterval(this.updateProgress, 100) }); } stopCurrentInterval() { if (this.state.progressInterval) { clearInterval(this.state.progressInterval); } } toggleInterval() { if (this.props.playing && this.state.isTabVisible) this.startInterval(); else this.stopCurrentInterval(); } updateProgress() { let progressVal = parseInt( ((new Date().valueOf() - this.props.songStartedAt) / 1000).toFixed(2), 10 ); this.setState({ progressVal }); } handleChange(event) { let { value } = event.target; this.props.setUrl(value); } handleVisibilityChange = isTabVisible => { this.setState({ isTabVisible }, () => { this.toggleInterval(); }); }; getMountOptions() { let mountOptions = ''; let { alternativeMounts } = this.state; if (alternativeMounts && this.props.url) { mountOptions = ( ); } return mountOptions; } render() { let { progressVal, currentSong, isTabVisible } = this.state; let { playing, songDuration, togglePlay, currentVolume, setTargetVolume, listeners, fastConnection, url } = this.props; return (
{isTabVisible && ( )}
); } } Footer.propTypes = { currentSong: PropTypes.object, currentVolume: PropTypes.number, fastConnection: PropTypes.bool, listeners: PropTypes.number, mounts: PropTypes.array, playing: PropTypes.bool, remotes: PropTypes.array, setTargetVolume: PropTypes.func, setUrl: PropTypes.func, songDuration: PropTypes.number, songHistory: PropTypes.array, songStartedAt: PropTypes.number, togglePlay: PropTypes.func, url: PropTypes.string }; ================================================ FILE: src/components/Main.js ================================================ import React from 'react'; import PropTypes from 'prop-types'; import { isBrowser } from 'react-device-detect'; import Visualizer from './Visualizer'; import Video from '../assets/Saron3.webm'; const Main = props => { return (

Welcome to Code Radio.

24/7 music designed for coding.

{isBrowser && ( <>
Keyboard Controls
Play/Pause:
Spacebar or "k"
Volume:
Up Arrow / Down Arrow
)}
); }; Main.propTypes = { fastConnection: PropTypes.bool, player: PropTypes.object, playing: PropTypes.bool }; export default Main; ================================================ FILE: src/components/Nav.js ================================================ import React, { useState } from 'react'; export default function Nav() { const [isOpen, setIsOpen] = useState(false); const toggleSidenav = () => { setIsOpen(!isOpen); }; const links = [ { href: 'https://www.freecodecamp.org/news/', text: 'News' }, { href: 'https://www.freecodecamp.org/forum/', text: 'Forum' }, { href: 'https://www.freecodecamp.org/learn/', text: 'Learn' } ]; return ( ); } ================================================ FILE: src/components/Nav.test.js ================================================ import React from 'react'; import { render, screen } from '@testing-library/react'; import Nav from './Nav'; describe('