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
================================================

## Coderadio Client UI
This repository powers the current client application for the Code Radio at: <https://coderadio.freecodecamp.org>.
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: <https://www.freecodecamp.org/news/code-radio-24-7>
### 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 */
// / <reference types="cypress" />
// ***********************************************************
// 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
================================================
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta content="IE=edge" http-equiv="X-UA-Compatible" />
<meta
name="viewport"
content="width=device-width, minimum-scale=1.0, maximum-scale=8.0"
/>
<meta content="X5tHeKjV-jMLyp4VMoUhW9PAYaOjtPslV250" name="csrf-token" />
<link href="https://coderadio.freecodecamp.org" rel="canonical" />
<meta
content="Code Radio - 24/7 concentration music for developers"
property="og:title"
/>
<meta content="freeCodeCamp.org" property="og:site_name" />
<meta content="on" name="twitter:widgets:csp" />
<meta content="d0bc047a482c03c24f1168004c2a216a" name="p:domain_verify" />
<meta content="https://coderadio.freecodecamp.org" property="og:url" />
<meta
content="Code Radio - 24/7 concentration music for developers"
property="og:description"
/>
<meta
content="https://cdn.freecodecamp.org/coderadio/coderadio-meta-1920x1080.png"
property="og:image"
/>
<meta content="article" property="og:type" />
<meta
content="https://www.facebook.com/freecodecamp"
property="article:publisher"
/>
<meta content="Responsive" property="article:section" />
<meta content="Support" name="description" />
<meta content="@freecodecamp" name="twitter:creator" />
<meta content="https://coderadio.freecodecamp.org" name="twitter:url" />
<meta content="@freecodecamp" name="twitter:site" />
<meta content="summary_large_image" name="twitter:card" />
<meta
content="https://cdn.freecodecamp.org/coderadio/coderadio-meta-1920x1080.png"
name="twitter:image:src"
/>
<meta content="Code Radio" name="twitter:title" />
<meta
content="24/7 concentration music for developers"
name="twitter:description"
/>
<meta content="a40ee5d5dba3bb091ad783ebd2b1383f" name="p:domain_verify" />
<meta content="#FFFFFF" name="msapplication-TileColor" />
<meta
content="https://cdn.freecodecamp.org/universal/favicons/browserconfig.xml"
rel="msapplication-config"
/>
<link
href="https://cdn.freecodecamp.org/universal/favicons/android-chrome-192x192.png"
rel="android-chrome"
sizes="192x192"
/>
<link
href="https://cdn.freecodecamp.org/universal/favicons/android-chrome-384x384.png"
rel="android-chrome"
sizes="384x384"
/>
<link
href="https://cdn.freecodecamp.org/universal/favicons/site.webmanifest"
rel="manifest"
/>
<link
href="https://cdn.freecodecamp.org/universal/favicons/apple-touch-icon.png"
rel="apple-touch-icon"
sizes="180x180"
/>
<link
href="https://cdn.freecodecamp.org/universal/favicons/favicon-16x16.png"
rel="favicon"
sizes="16x16"
/>
<link
href="https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.png"
rel="favicon"
sizes="32x32"
/>
<link
href="https://cdn.freecodecamp.org/universal/favicons/favicon.ico"
rel="icon"
/>
<title>freeCodeCamp.org Code Radio</title>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=UA-55446531-21"
></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'UA-55446531-21');
</script>
</head>
<body>
<div id="root"></div>
</body>
<script src="https://cdn.jsdelivr.net/npm/@widgetbot/crate@3" async defer>
new Crate({
server: '692816967895220344', // freeCodeCamp.org Official ᕕ(⌐■_■)ᕗ ♪♬
channel: '1254842489362317322' // #code-radio
})
const button = document.querySelector('crate > div').shadowRoot.querySelector("button");
button.style.bottom = "90px";
const embed = document.querySelector('crate > div').shadowRoot.querySelector(".embed");
embed.style.bottom = "90px";
// we only need to adjust the height at the iframe's mobile breakpoint.
// This does break during resizing, but I think that's enough of an edge case.
if (window.innerWidth <= 500) {
embed.style.maxHeight = "calc(100% - 80px)"
}
</script>
</html>
================================================
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=<DSN-from-sentry-project-settings>
================================================
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 (
<div className='App'>
<Nav />
<Main
fastConnection={this.state.fastConnection}
player={this._player}
playing={this.state.playing}
/>
<audio
aria-label='audio'
crossOrigin='anonymous'
onError={this.onPlayerError}
ref={a => (this._player = a)}
>
<track kind='captions' {...this.state.captions} />
</audio>
<Footer
currentSong={this.state.currentSong}
currentVolume={this.state.audioConfig.currentVolume}
fastConnection={this.state.fastConnection}
listeners={this.state.listeners}
mounts={this.state.mounts}
playing={this.state.playing}
remotes={this.state.remotes}
setTargetVolume={this.setTargetVolume}
setUrl={this.setUrl}
songDuration={this.state.songDuration}
songHistory={this.state.songHistory}
songStartedAt={this.state.songStartedAt}
togglePlay={this.togglePlay}
url={this.state.url}
/>
</div>
);
}
}
================================================
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(<App />, 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 => (
<div
className={
props.playing
? 'meta-display thumb meta-display-visible'
: 'meta-display thumb'
}
>
<img
alt='album art'
data-meta='picture'
src={props.fastConnection ? props.currentSong.art : DEFAULT_ART}
/>
<div className='now-playing'>
<div className='progress-container'>
<progress
aria-hidden='true'
data-meta='duration'
max={props.songDuration}
value={props.progressVal}
/>
</div>
<div data-meta='title'>{props.currentSong.title}</div>
<div data-meta='artist'>{props.currentSong.artist}</div>
<div data-meta='album'>{props.currentSong.album}</div>
<div data-meta='listeners'>Listeners: {props.listeners}</div>
{props.mountOptions}
</div>
</div>
);
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 = (
<select
aria-label='Select Stream'
data-meta='stream-select'
id='stream-select'
onChange={this.handleChange.bind(this)}
value={this.props.url}
>
{alternativeMounts.map((mount, index) => (
<option key={index} value={mount.url}>
{mount.name}
</option>
))}
</select>
);
}
return mountOptions;
}
render() {
let { progressVal, currentSong, isTabVisible } = this.state;
let {
playing,
songDuration,
togglePlay,
currentVolume,
setTargetVolume,
listeners,
fastConnection,
url
} = this.props;
return (
<PageVisibility onChange={this.handleVisibilityChange}>
<footer>
{isTabVisible && (
<SongHistory
songHistory={this.props.songHistory}
fastConnection={fastConnection}
/>
)}
<CurrentSong
currentSong={currentSong}
progressVal={progressVal}
fastConnection={fastConnection}
listeners={listeners}
mountOptions={this.getMountOptions()}
playing={playing}
songDuration={songDuration}
/>
<PlayPauseButton
playing={playing}
togglePlay={togglePlay}
url={url}
/>
<Slider
currentVolume={currentVolume}
setTargetVolume={setTargetVolume}
/>
</footer>
</PageVisibility>
);
}
}
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 (
<main>
<div className='under-header-content'>
<h1 className='site-title'>Welcome to Code Radio.</h1>
<h2 className='site-description'>24/7 music designed for coding.</h2>
</div>
{isBrowser && (
<>
<div className='animation'>
<video
aria-hidden={true}
autoPlay={true}
loop={true}
muted={true}
playsInline={true}
>
<source src={Video} type='video/webm' />
</video>
</div>
<Visualizer player={props.player} playing={props.playing} />
<details>
<summary id='keyboard-controls'>Keyboard Controls</summary>
<dl>
<dt>Play/Pause:</dt>
<dd>Spacebar or "k"</dd>
<dt>Volume:</dt>
<dd>Up Arrow / Down Arrow</dd>
</dl>
</details>
</>
)}
</main>
);
};
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 (
<nav className={'site-nav' + (isOpen ? ' expand-nav' : '')} id='site-nav'>
<div className='site-nav-left' />
<div className='site-nav-middle'>
<a
aria-label='freeCodeCamp.org'
className='site-nav-logo'
href='https://www.freecodecamp.org/'
>
<img
alt='freeCodeCamp.org'
aria-hidden='true'
src='https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg'
/>
</a>
</div>
<div className='site-nav-right main-nav'>
<button
aria-controls='nav'
aria-expanded={isOpen}
className={
'site-nav-right toggle-button-nav' +
(isOpen ? ' reverse-toggle-color' : '')
}
id='toggle-button-nav'
onClick={toggleSidenav}
>
Menu
</button>
<div className='main-nav-group'>
<ul
className={'nav' + (isOpen ? ' show-main-nav-items' : '')}
id='nav'
>
{links.map((link, index) => (
<li key={index}>
<a href={link.href} rel='noopener noreferrer' target='_blank'>
<span>
{link.text}{' '}
<span className='sr-only'>opens in new window</span>
</span>
</a>
</li>
))}
</ul>
</div>
</div>
</nav>
);
}
================================================
FILE: src/components/Nav.test.js
================================================
import React from 'react';
import { render, screen } from '@testing-library/react';
import Nav from './Nav';
describe('<Nav />', () => {
it('should render a link to the News page', () => {
render(<Nav />);
const newsLink = screen.getByRole('link', {
name: /news opens in new window/i
});
expect(newsLink).toBeInTheDocument();
expect(newsLink).toHaveAttribute(
'href',
'https://www.freecodecamp.org/news/'
);
});
it('should render a link to the Forum page', () => {
render(<Nav />);
const forumLink = screen.getByRole('link', {
name: /forum opens in new window/i
});
expect(forumLink).toBeInTheDocument();
expect(forumLink).toHaveAttribute(
'href',
'https://www.freecodecamp.org/forum/'
);
});
it('should render a link to the Learn page', () => {
render(<Nav />);
const learnLink = screen.getByRole('link', {
name: /learn opens in new window/i
});
expect(learnLink).toBeInTheDocument();
expect(learnLink).toHaveAttribute(
'href',
'https://www.freecodecamp.org/learn/'
);
});
});
================================================
FILE: src/components/PlayPauseButton.js
================================================
import React from 'react';
import PropTypes from 'prop-types';
import { isBrowser } from 'react-device-detect';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons';
import { ReactComponent as Pause } from '../assets/pause.svg';
import { ReactComponent as Play } from '../assets/play.svg';
class PlayPauseButton extends React.Component {
state = {
initialLoad: true
};
handleOnClick = () => isBrowser && this.props.togglePlay();
handleOnTouchEnd = () => !isBrowser && this.props.togglePlay();
static getDerivedStateFromProps(nextProps, prevState) {
// Set initial load to render the initial message accordingly
if (prevState.initialLoad && nextProps.playing) {
return { initialLoad: false };
}
return null;
}
render() {
return this.props.url ? (
<button
aria-label={this.props.playing ? 'Pause' : 'Play'}
className={
this.state.initialLoad
? 'play-container-cta play-container'
: 'play-container'
}
id='toggle-play-pause'
onClick={this.handleOnClick}
onTouchEnd={this.handleOnTouchEnd}
>
{this.props.playing ? <Pause /> : <Play />}
</button>
) : (
<FontAwesomeIcon
aria-hidden='true'
className='loader-circle-notch'
icon={faCircleNotch}
spin={true}
/>
);
}
}
PlayPauseButton.propTypes = {
playing: PropTypes.bool,
togglePlay: PropTypes.func,
url: PropTypes.string
};
export default PlayPauseButton;
================================================
FILE: src/components/Slider.js
================================================
import React from 'react';
import PropTypes from 'prop-types';
const MAX = 100;
const STEP = 5;
const Slider = ({ currentVolume, setTargetVolume }) => {
const handleChange = event => {
let { value } = event.target;
setTargetVolume(value / MAX);
};
const sliderVal = currentVolume * MAX;
return (
<div className='slider-container'>
<input
aria-label='volume'
className='slider'
id='volume-input'
max={MAX}
min='0'
onChange={handleChange}
step={STEP}
type='range'
value={sliderVal}
/>
</div>
);
};
Slider.propTypes = {
currentVolume: PropTypes.number,
setTargetVolume: PropTypes.func
};
export default Slider;
================================================
FILE: src/components/SongHistory.js
================================================
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHistory } from '@fortawesome/free-solid-svg-icons';
const DEFAULT_ART =
'https://cdn-media-1.freecodecamp.org/code-radio/cover_placeholder.gif';
class SongHistory extends Component {
constructor(props) {
super(props);
this.state = {
displayList: false
};
}
toggleDisplay = () => {
this.setState({
displayList: !this.state.displayList
});
};
render() {
const { songHistory, fastConnection } = this.props;
// Don't reverse song list, we want most recent song first.
const songs = songHistory.map(song => song.song);
return (
<>
<button
aria-controls='song-history'
aria-expanded={this.state.displayList}
aria-label='Recent Song History'
className='recent-song-history'
id='recent-song-history'
onClick={this.toggleDisplay}
>
<FontAwesomeIcon
aria-hidden='true'
className='recently-played-icon'
icon={faHistory}
/>
</button>
<ol
aria-hidden={!this.state.displayList}
className='recent-song-list'
id='song-history'
>
{songs.map((song, index) => (
<li className='recent-song-info' key={song.id}>
<img
alt=''
role='presentation'
src={fastConnection ? song.art : DEFAULT_ART}
/>
<p className='recent-song-meta'>
<span>
<span className='sr-only'>Song {index + 1}:</span>
{song.title}
</span>
<span>
<span className='sr-only'>, Artist:</span> {song.artist}
</span>
<span>
<span className='sr-only'>, Album:</span> {song.album}
</span>
</p>
</li>
))}
</ol>
</>
);
}
}
SongHistory.propTypes = {
fastConnection: PropTypes.bool,
songHistory: PropTypes.array
};
export default SongHistory;
================================================
FILE: src/components/Visualizer.js
================================================
import React from 'react';
import PropTypes from 'prop-types';
import PageVisibility from 'react-page-visibility';
const DELAY = 500;
export default class Visualizer extends React.PureComponent {
rafId = null;
timerId = null;
constructor(props) {
super(props);
this.state = {
eq: {},
config: {
baseColour: 'rgb(10, 10, 35)',
translucent: 'rgba(10, 10, 35, 0.6)',
multiplier: 0.7529
},
isTabVisible: true
};
this.timeoutId = null;
}
/**
* In order to get around some mobile browser limitations,
* we can only generate a lot of the audio context stuff AFTER
* the audio has been triggered.
* We can't see it until then anyway so it makes no difference to desktop.
*/
componentDidUpdate(prevProps, prevState) {
if (
prevProps.playing === this.props.playing &&
prevState.isTabVisible === this.state.isTabVisible
) {
return;
}
/**
* If the player is playing and the tab is being active,
* draw the visualization.
*/
if (this.props.playing && this.state.isTabVisible) {
// Create a new audio context if there isn't one available
if (!this.state.eq.context) {
this.initiateEQ();
}
this.createVisualizer();
this.startDrawing();
} else {
/**
* If the player is not playing or the tab is running in the background,
* stop the animation.
*/
/**
* Workaround for componentWillUnmount to delay the clean up and
* achieve fadeout animation.
*/
this.timeoutId = setTimeout(() => {
// Note: Order matters.
// Stop the drawing loop first (using this.rafId), then set the ID to null
this.stopDrawing();
this.reset();
}, DELAY);
}
}
componentWillUnmount() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
initiateEQ() {
let eq = this.state.eq;
// Safari requires a webkit prefix to support AudioContext
const AudioContext = window.AudioContext || window.webkitAudioContext;
// Create a new Audio Context element to read the samples from
eq.context = new AudioContext();
// Apply the audio element as the source where to pull all the data from
eq.src = eq.context.createMediaElementSource(this.props.player);
/**
* Use some amazing trickery that allows javascript to
* analyse the current state.
*/
eq.analyser = eq.context.createAnalyser();
eq.src.connect(eq.analyser);
eq.analyser.connect(eq.context.destination);
eq.analyser.fftSize = 256;
/**
* Create a buffer array for the number of frequencies available
* (minus the high pitch useless ones that never really do anything anyway).
*/
eq.bands = new Uint8Array(eq.analyser.frequencyBinCount - 32);
this.setState({ eq });
}
reset = () => {
this.rafId = null;
};
/**
* The equalizer bands available need to be updated
* constantly in order to ensure that the value for any
* visualizer is up to date.
*/
updateEQBands() {
const newEQ = this.state.eq;
// Populate the buffer with the audio source's current data
newEQ.analyser.getByteFrequencyData(newEQ.bands);
this.setState({ eq: { ...newEQ } });
}
/**
* When starting the page, the visualizer dom is needed to be
* created.
*/
createVisualizer() {
this._canvas.width = this._canvas.parentNode.offsetWidth;
this._canvas.height = this._canvas.parentNode.offsetHeight;
this.visualizer = {
ctx: this._canvas.getContext('2d'),
height: this._canvas.height,
width: this._canvas.width,
barWidth: this._canvas.width / this.state.eq.bands.length
};
}
startDrawing = () => {
if (!this.rafId) {
this.rafId = window.requestAnimationFrame(this.drawingLoop);
}
};
stopDrawing = () => {
window.cancelAnimationFrame(this.rafId);
clearTimeout(this.timerId);
};
drawingLoop = () => {
const haveWaveform = this.state.eq.bands.reduce((a, b) => a + b, 0) !== 0;
this.updateEQBands();
this.drawVisualizer();
/**
* Because timeupdate events are not triggered at browser speed,
* we use requestanimationframe for higher framerates
*/
if (haveWaveform) {
this.rafId = window.requestAnimationFrame(this.drawingLoop);
}
// If there is no music or audio in the song, then reduce the FPS
else {
this.timerId = setTimeout(this.drawingLoop, 250);
}
};
/**
* As a base visualizer, the equalizer bands are drawn using
* canvas in the window directly above the song into.
*/
drawVisualizer() {
// Initial bar x coordinate
let y,
x = 0;
// Clear the complete canvas
this.visualizer.ctx.clearRect(
0,
0,
this.visualizer.width,
this.visualizer.height
);
/**
* Set the primary colour of the brand
* (probably moving to a higher object level variable soon)
* Start creating a canvas polygon
*/
this.visualizer.ctx.beginPath();
// Start at the bottom left
this.visualizer.ctx.moveTo(x, 0);
this.visualizer.ctx.fillStyle = this.state.config.translucent;
this.state.eq.bands.forEach(band => {
/**
* Get the overall hight associated to the current band and
* convert that into a Y position on the canvas
*/
y = this.state.config.multiplier * band;
// Draw a line from the current position to the wherever the Y position is
this.visualizer.ctx.lineTo(x, y);
/**
* Continue that line to meet the width of the bars
* (canvas width ÷ bar count).
*/
this.visualizer.ctx.lineTo(x + this.visualizer.barWidth, y);
// Add pixels to the x for the next bar
x += this.visualizer.barWidth;
});
// Bring the line back down to the bottom of the canvas
this.visualizer.ctx.lineTo(x, 0);
// Fill it
this.visualizer.ctx.fill();
}
handleVisibilityChange = isTabVisible => {
this.setState({ isTabVisible });
};
render() {
return (
<PageVisibility onChange={this.handleVisibilityChange}>
<div className='visualizer'>
<canvas aria-label='visualizer' ref={a => (this._canvas = a)} />
</div>
</PageVisibility>
);
}
}
Visualizer.propTypes = {
player: PropTypes.object,
playing: PropTypes.bool
};
================================================
FILE: src/css/App.css
================================================
@import url('https://fonts.googleapis.com/css?family=Lato:400,700&display=swap');
/* Globals */
:root {
--focus-outline: #0044FF;
}
* {
box-sizing: border-box;
font-family: 'Lato', Arial;
}
body {
background-color: #1b1b32;
background-position: center center;
background-size: cover;
color: #fff;
font-family: 'Open Sans', sans-serif;
margin: 0;
overflow: hidden;
padding: 0;
}
html,
body,
#root,
main {
height: 100%;
}
a {
text-decoration: none;
}
h1,
h2 {
margin: 0;
}
p {
margin: 0;
}
/* App */
.App {
height: 100%;
}
/* Main */
.animation {
display: none;
}
@media only screen and (min-width: 768px) {
.animation {
display: block;
height: calc(100% - 158px);
margin-top: 88px;
position: relative;
width: 100%;
}
}
.animation video {
width: 100%;
height: 100%;
object-fit: cover;
object-position: left;
}
.site-title {
font-size: 30px;
}
@media only screen and (min-width: 768px) {
.site-title {
font-size: 18px;
}
}
.site-description {
font-size: 18px;
margin-top: 18px;
}
@media only screen and (min-width: 768px) {
.site-description {
margin-left: 10px;
margin-top: 0;
}
}
.under-header-content {
background-color: #0a0a23;
display: flex;
flex-direction: column;
height: 200px;
justify-content: center;
padding: 0 20px;
position: absolute;
text-align: center;
top: 38px;
width: 100%;
}
@media only screen and (min-width: 768px) {
.under-header-content {
align-items: center;
flex-direction: row;
height: 50px;
z-index: 1;
}
}
/* Slider */
.slider-container {
height: 100%;
padding: 22px 20px 22px 15px;
width: 140px;
}
.slider {
appearance: none;
-webkit-appearance: none;
background: #dfdfe2;
height: 6px;
width: 100%;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
background: #fff;
cursor: pointer;
height: 15px;
width: 15px;
}
.slider::-moz-range-thumb {
background: #fff;
cursor: pointer;
height: 15px;
width: 15px;
}
/* Current Song */
[data-meta='title'] {
font-size: 18px;
}
[data-meta='artist'] {
font-size: 14px;
}
[data-meta='album'] {
font-size: 12px;
}
[data-meta='listeners'] {
bottom: 20px;
font-size: 20px;
font-weight: 900;
left: 10px;
position: absolute;
}
@media only screen and (min-width: 768px) {
[data-meta='listeners'] {
bottom: auto;
left: auto;
top: 2px;
right: 10px;
}
}
[data-meta='picture'] {
bottom: 212px;
height: 200px;
left: 50%;
margin-left: -100px;
position: absolute;
width: 200px;
}
@media only screen and (min-width: 768px) {
[data-meta='picture'] {
bottom: auto;
height: 70px;
left: auto;
margin-left: 0;
width: 70px;
}
}
@media (max-height: 600px) {
[data-meta='picture'] {
display: none;
}
}
.now-playing {
align-content: center;
background-color: #1b1b32;
bottom: 70px;
display: flex;
flex-direction: column;
height: 120px;
left: 0;
padding: 10px;
position: absolute;
width: 100vw;
}
@media only screen and (min-width: 768px) {
.now-playing {
background-color: #0a0a23;
bottom: auto;
height: 100%;
left: 70px;
padding-bottom: 2px;
padding-top: 2px;
position: relative;
width: calc(100% - 70px);
}
}
.progress-container {
border-radius: 4px;
bottom: 10px;
height: 4px;
left: 10px;
overflow: hidden;
position: absolute;
right: 10px;
}
@media only screen and (min-width: 768px) {
.progress-container {
bottom: 5px;
}
}
.now-playing progress {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: #3b3b4f;
height: 4px;
left: 0;
position: absolute;
top: 0;
width: 100%;
border: none;
}
.now-playing progress::-webkit-progress-bar {
background-color: #3b3b4f;
}
.now-playing progress::-webkit-progress-value {
background-color: #fff;
}
.meta-display {
opacity: 0;
transition: opacity 0.5s ease-out;
width: calc(100% - 210px);
}
.meta-display-visible {
opacity: 1;
}
/* Visualizer */
.visualizer {
background-color: #002ead;
height: 100%;
pointer-events: none;
position: absolute;
top: 238px;
width: 100%;
z-index: 5;
}
@media only screen and (min-width: 768px) {
.visualizer {
background-color: transparent;
top: 88px;
}
}
.visualizer canvas {
height: 100%;
width: 100%;
}
/* Song History */
.recent-song-history {
background-color: transparent;
border: 0;
cursor: pointer;
height: 100%;
padding: 0;
text-align: center;
width: 70px;
z-index: 10;
}
.recent-song-history:hover,
.recent-song-history:active {
background-color: #1b1b32;
}
.recent-song-history .recently-played-icon {
color: #fff;
height: 60%;
width: 60%;
}
.loader-circle-notch{
height: 60%;
width: auto;
align-self: center;
}
.recent-song-list {
background-color: #1b1b32;
bottom: 70px;
color: #fff;
cursor: default;
display: none;
left: 0;
list-style-type: none;
margin: 0;
padding-left: 0;
position: absolute;
width: 100%;
}
.recent-song-list[aria-hidden="false"] {
display: flex;
flex-direction: column-reverse;
}
@media only screen and (min-width: 768px) {
.recent-song-list {
bottom: 73px;
width: 350px;
}
}
.recently-played-icon {
font-weight: bold;
justify-content: center;
}
.recent-song-info {
border-bottom: 1px solid #3b3b4f;
display: flex;
justify-content: flex-start;
padding: 10px;
}
.recent-song-meta {
font-size: 16px;
margin-left: 10px;
overflow: hidden;
text-align: left;
}
.recent-song-meta p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-song-meta span:first-of-type {
font-weight: bold;
}
.recent-song-meta span {
display: block;
}
.recent-song-info:nth-child(1) {
border-bottom: none;
}
.recent-song-info img {
float: left;
height: 50px;
width: 50px;
}
/* Details */
details {
display: none;
}
@media only screen and (min-width: 768px) {
details {
background-color: rgba(10, 10, 35, 0.6); /* #0a0a23 */
bottom: 100px;
color: #fff;
display: block;
opacity: 0.1;
padding: 15px;
position: absolute;
left: 50px;
transition: opacity 0.25s ease-out;
}
}
details:hover,
details[open] {
opacity: 1;
}
dl {
margin-bottom: 0;
}
dt {
margin-top: 10px;
}
dd {
font-style: italic;
margin-left: 10px;
}
/* Footer */
[data-meta='stream-select'] {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
background-color: #fff;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23007CB2%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E');
background-position: right 0.7em top 50%, 0 0;
background-repeat: no-repeat;
background-size: 0.65em auto, 100%;
border-radius: 0;
border: none;
bottom: 90px;
font-size: 12px;
padding: 2px 20px 2px 2px;
position: absolute;
right: 10px;
}
@media only screen and (min-width: 768px) {
[data-meta='stream-select'] {
bottom: 16px;
}
}
footer {
background-color: #0a0a23;
bottom: 0;
display: flex;
height: 70px;
position: absolute;
width: 100%;
z-index: 10;
}
@media only screen and (min-width: 768px) {
footer {
border-top: 3px solid #fff;
box-sizing: content-box;
}
}
/* Play Pause Button */
.play-container {
background-color: transparent;
border: 0;
color: #fff;
cursor: pointer;
height: 100%;
left: 50%;
margin-left: -35px;
padding: 15px;
position: absolute;
width: 70px;
}
.play-container:hover,
.play-container:active {
background-color: #1b1b32;
}
@media only screen and (min-width: 768px) {
.play-container {
left: auto;
margin-left: 0;
position: relative;
}
}
.play-container::before {
background-color: #1b1b32;
background-image: url(https://cdn-media-1.freecodecamp.org/code-radio/cta.png);
background-position: 150px center;
background-repeat: no-repeat;
border-radius: 5px;
box-sizing: border-box;
content: 'Push Play or Space Bar to Start Music';
display: none;
font-size: 21px;
font-weight: bold;
height: 100px;
left: -170px;
opacity: 0;
padding: 10px 100px 10px 20px;
pointer-events: none;
position: absolute;
top: -110px;
transition: opacity 0.5s ease-out;
width: 250px;
}
@media only screen and (min-width: 768px) {
.play-container::before {
display: block;
}
}
.play-container-cta::before {
opacity: 1;
}
.play-container-cta:hover {
background: #2a2a40;
}
/* Nav */
.site-nav {
align-items: flex-start;
background: #0a0a23;
display: flex;
font-family: 'Lato', sans-serif;
font-size: 18px;
height: 38px;
justify-content: space-between;
overflow-y: hidden;
padding: 0 0;
position: fixed;
top: 0;
width: 100%;
z-index: 1000;
}
@media only screen and (min-width: 350px) {
.site-nav {
padding: 0 15px;
}
}
.site-nav-middle {
padding-top: 7px;
}
@media only screen and (min-width: 768px) {
.site-nav-middle {
flex: 1 0 30%;
margin-right: 0;
text-align: center;
}
}
.site-nav-left {
display: none;
}
@media only screen and (min-width: 768px) {
.site-nav-left {
display: flex;
flex: 1 0 30%;
margin-left: 0;
}
}
.site-nav-logo {
background-image: url(https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg);
background-position: -179px 0;
background-repeat: no-repeat;
background-size: cover;
color: #fff;
display: inline-block;
flex-shrink: 0;
font-size: 1.7rem;
font-weight: bold;
height: 25px;
letter-spacing: -0.5px;
line-height: 1em;
margin: -1px 5px 0;
width: 43px;
}
@media only screen and (min-width: 350px) {
.site-nav-logo {
background-image: none;
height: unset;
padding-top: 1px;
width: unset;
}
}
@media only screen and (min-width: 768px) {
.site-nav-logo {
margin-left: auto;
margin-right: auto;
}
}
.site-nav-logo:focus {
outline: 2px solid var(--focus-outline);
outline-offset: 3px;
}
.site-nav-logo:focus:not(:focus-visible) {
outline: none;
}
.site-nav-logo:hover {
text-decoration: none;
}
.site-nav-logo img {
display: none;
height: 25px;
width: auto;
}
@media only screen and (min-width: 350px) {
.site-nav-logo img {
display: block;
}
}
.site-nav-right {
align-items: center;
display: flex;
flex-shrink: 0;
height: 38px;
}
@media only screen and (min-width: 768px) {
.site-nav-right {
flex: 1 0 30%;
margin-left: auto;
}
}
@media only screen and (min-width: 768px) {
.main-nav-group {
margin-left: auto;
}
}
.nav {
background-color: #0a0a23;
display: none;
left: 15px;
list-style: none;
margin: 0 0 0 -12px;
padding: 0;
position: absolute;
top: 38px;
}
@media only screen and (min-width: 518px) {
.nav {
align-items: center;
display: flex;
left: auto;
margin-top: 1px;
position: relative;
top: 0;
}
}
.nav {
height: 38px;
}
.nav li {
display: block;
margin: 0;
padding: 0;
height: 100%;
}
.nav li a {
align-items: center;
color: #fff;
display: flex;
height: 100%;
margin: 0;
opacity: 1;
padding: 0 10px;
white-space: nowrap;
}
.nav li a span {
display: flex;
align-items: center;
gap: 0.15rem;
margin-top: -1px;
}
.nav li a svg {
fill: #fff;
height: 1rem;
width: 1rem;
margin-left: 0.15rem;
}
.nav li a:focus {
outline: 2px solid var(--focus-outline);
outline-offset: -3px;
}
.nav li a:focus:not(:focus-visible) {
outline: none;
}
.nav li:hover {
background: #fff;
}
.nav li a:hover {
color: #0a0a23;
text-decoration: none;
}
.nav li a:hover svg {
fill: #0a0a23;
}
.toggle-button-nav {
background-color: #0a0a23;
border: 1px solid #fff;
color: #fff;
cursor: pointer;
display: flex;
font-family: 'lato', sans-serif;
font-size: 18px;
height: auto;
margin-right: 5px;
outline: 0;
padding: 2px 14px 3px;
}
.toggle-button-nav:focus {
outline: 2px solid var(--focus-outline);
outline-offset: 2px;
}
.toggle-button-nav:focus:not(:focus-visible) {
outline: none;
}
@media only screen and (min-width: 518px) {
.toggle-button-nav {
display: none;
}
}
.show-main-nav-items {
display: flex;
}
.expand-nav {
min-height: calc(40px + 38px);
}
@media only screen and (min-width: 768px) {
.expand-nav {
min-height: 38px;
}
}
.reverse-toggle-color {
background-color: #fff;
color: #0a0a23;
}
================================================
FILE: src/index.js
================================================
import React from 'react';
import ReactDOM from 'react-dom';
import * as Sentry from '@sentry/react';
import { Integrations } from '@sentry/tracing';
import App from './components/App';
Sentry.init({
dsn: process.env.REACT_APP_SENTRY_DSN,
integrations: [new Integrations.BrowserTracing()],
// We recommend adjusting this value in production, or using tracesSampler
// for finer control
tracesSampleRate: 1.0
});
ReactDOM.render(<App />, document.getElementById('root'));
================================================
FILE: src/setupTests.js
================================================
import '@testing-library/jest-dom';
Object.defineProperty(window, 'EventSource', {
writable: true,
value: jest.fn().mockImplementation(() => ({
CLOSED: 0,
CONNECTING: 0,
OPEN: 0,
dispatchEvent(event) {
return false;
},
onerror: jest.fn(),
onmessage: jest.fn(),
onopen: jest.fn(),
readyState: 0,
url: '',
withCredentials: false,
addEventListener: jest.fn(),
close: jest.fn(),
removeEventListener: jest.fn()
}))
});
================================================
FILE: src/utils/buildEventSource.js
================================================
export const buildEventSource = url => {
return new EventSource(url);
};
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
SYMBOL INDEX (59 symbols across 9 files)
FILE: src/components/App.js
constant CODERADIO_VOLUME (line 19) | const CODERADIO_VOLUME = 'coderadio-volume';
class App (line 28) | class App extends React.Component {
method constructor (line 29) | constructor(props) {
method isSpacePressed (line 108) | isSpacePressed(event) {
method canTogglePlayPause (line 112) | canTogglePlayPause() {
method isUpDownArrowPressed (line 124) | isUpDownArrowPressed(event) {
method canAdjustVolume (line 128) | canAdjustVolume() {
method handleKeyboardHotKeys (line 134) | handleKeyboardHotKeys(event) {
method addKeyboardHotKeysListener (line 154) | addKeyboardHotKeysListener() {
method removeKeyboardHotKeysListener (line 158) | removeKeyboardHotKeysListener() {
method setPlayerInitial (line 163) | setPlayerInitial() {
method componentDidMount (line 184) | componentDidMount() {
method componentWillUnmount (line 192) | componentWillUnmount() {
method setUrl (line 204) | async setUrl(url = false) {
method play (line 225) | play() {
method pause (line 250) | pause() {
method togglePlay (line 276) | togglePlay() {
method setTargetVolume (line 290) | setTargetVolume(volume) {
method fade (line 311) | fade(direction) {
method fadeUp (line 324) | fadeUp() {
method fadeDown (line 328) | fadeDown() {
method updateVolume (line 336) | updateVolume() {
method setMountToConnection (line 432) | setMountToConnection(mounts = [], remotes = []) {
method fetchJSON (line 449) | fetchJSON() {
method getNowPlaying (line 470) | getNowPlaying() {
method render (line 581) | render() {
FILE: src/components/CurrentSong.js
constant DEFAULT_ART (line 4) | const DEFAULT_ART =
FILE: src/components/Footer.js
class Footer (line 10) | class Footer extends React.PureComponent {
method constructor (line 11) | constructor(props) {
method componentDidUpdate (line 23) | componentDidUpdate(prevProps) {
method componentWillUnmount (line 44) | componentWillUnmount() {
method startInterval (line 48) | startInterval() {
method stopCurrentInterval (line 55) | stopCurrentInterval() {
method toggleInterval (line 61) | toggleInterval() {
method updateProgress (line 66) | updateProgress() {
method handleChange (line 74) | handleChange(event) {
method getMountOptions (line 85) | getMountOptions() {
method render (line 108) | render() {
FILE: src/components/Nav.js
function Nav (line 3) | function Nav() {
FILE: src/components/PlayPauseButton.js
class PlayPauseButton (line 10) | class PlayPauseButton extends React.Component {
method getDerivedStateFromProps (line 19) | static getDerivedStateFromProps(nextProps, prevState) {
method render (line 27) | render() {
FILE: src/components/Slider.js
constant MAX (line 4) | const MAX = 100;
constant STEP (line 5) | const STEP = 5;
FILE: src/components/SongHistory.js
constant DEFAULT_ART (line 7) | const DEFAULT_ART =
class SongHistory (line 9) | class SongHistory extends Component {
method constructor (line 10) | constructor(props) {
method render (line 23) | render() {
FILE: src/components/Visualizer.js
constant DELAY (line 5) | const DELAY = 500;
class Visualizer (line 7) | class Visualizer extends React.PureComponent {
method constructor (line 11) | constructor(props) {
method componentDidUpdate (line 31) | componentDidUpdate(prevProps, prevState) {
method componentWillUnmount (line 69) | componentWillUnmount() {
method initiateEQ (line 75) | initiateEQ() {
method updateEQBands (line 111) | updateEQBands() {
method createVisualizer (line 123) | createVisualizer() {
method drawVisualizer (line 169) | drawVisualizer() {
method render (line 216) | render() {
FILE: src/setupTests.js
method dispatchEvent (line 9) | dispatchEvent(event) {
Condensed preview — 41 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (79K chars).
[
{
"path": ".eslintrc",
"chars": 347,
"preview": "{\n \"extends\": [\n \"react-app\",\n \"./.eslintrc-base.json\",\n \"plugin:prettier/recommended\"\n ],\n \"globals\": {\n "
},
{
"path": ".eslintrc-base.json",
"chars": 4946,
"preview": "{\n \"rules\": {\n \"max-len\": [\n \"error\",\n { \"code\": 80, \"ignoreUrls\": true, \"ignoreTemplateLiterals\": true }\n"
},
{
"path": ".github/workflows/ci.yml",
"chars": 1613,
"preview": "name: Coderadio-client ci\n\non: [push, pull_request]\n\njobs:\n lint:\n name: Lint\n runs-on: ubuntu-20.04\n\n strateg"
},
{
"path": ".gitignore",
"chars": 468,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".husky/.gitignore",
"chars": 2,
"preview": "_\n"
},
{
"path": ".husky/pre-commit",
"chars": 16,
"preview": "npx lint-staged\n"
},
{
"path": ".npmrc",
"chars": 102,
"preview": "CYPRESS_INSTALL_BINARY=0\nengine-strict=true\nenable-pre-post-scripts=true\npackage-manager-strict=false\n"
},
{
"path": ".nvmrc",
"chars": 8,
"preview": "20.19.0\n"
},
{
"path": ".prettierrc",
"chars": 164,
"preview": "{\n \"endOfLine\":\"auto\", \n \"semi\": true,\n \"singleQuote\": true,\n \"jsxSingleQuote\": true,\n \"tabWidth\": 2,\n \"trailingC"
},
{
"path": "LICENSE",
"chars": 1516,
"preview": "BSD 3-Clause License\n\nCopyright (c) 2018, freeCodeCamp.org\n\nAll rights reserved.\n\nRedistribution and use in source and b"
},
{
"path": "README.md",
"chars": 615,
"preview": "\n\n## Coderadio Client UI\n"
},
{
"path": "cypress/fixtures/example.json",
"chars": 154,
"preview": "{\n \"name\": \"Using fixtures to represent data\",\n \"email\": \"hello@cypress.io\",\n \"body\": \"Fixtures are a great way to mo"
},
{
"path": "cypress/integration/home.js",
"chars": 188,
"preview": "/* global cy */\ndescribe('Landing page', () => {\n it('Should render', () => {\n cy.visit('http://localhost:3001');\n "
},
{
"path": "cypress/integration/play-button.js",
"chars": 499,
"preview": "describe('Stop and play the music', () => {\n beforeEach(() => {\n cy.visit('http://localhost:3001');\n });\n\n it('Cli"
},
{
"path": "cypress/plugins/index.js",
"chars": 756,
"preview": "/* eslint-disable no-unused-vars */\n// / <reference types=\"cypress\" />\n// **********************************************"
},
{
"path": "cypress/support/commands.js",
"chars": 838,
"preview": "// ***********************************************\n// This example commands.js shows you how to\n// create various custom"
},
{
"path": "cypress/support/index.js",
"chars": 671,
"preview": "// ***********************************************************\n// This example support/index.js is processed and\n// load"
},
{
"path": "cypress-install.js",
"chars": 306,
"preview": "const util = require('cypress/lib/util');\nconst execa = require('execa');\n\nconst pkg = util.pkgVersion();\n\n(async () => "
},
{
"path": "cypress.json",
"chars": 29,
"preview": "{\n \"projectId\": \"kqzjwp\"\n}"
},
{
"path": "netlify.toml",
"chars": 72,
"preview": "\n[build]\n base = \"\"\n publish = \"/build\"\n command = \"npm run build\""
},
{
"path": "package.json",
"chars": 1638,
"preview": "{\n \"name\": \"coderadio\",\n \"version\": \"0.1.0\",\n \"private\": true,\n \"dependencies\": {\n \"@fortawesome/fontawesome-svg-"
},
{
"path": "public/_redirects",
"chars": 158,
"preview": "# Optional: Redirect default Netlify subdomain to primary domain\nhttps://freecodecamp-code-radio.netlify.com/* https://c"
},
{
"path": "public/index.html",
"chars": 4246,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta content=\"IE=edge\" http-equiv=\"X-UA-Comp"
},
{
"path": "renovate.json",
"chars": 57,
"preview": "{\n \"extends\": [\"github>freecodecamp/renovate-config\"]\n}\n"
},
{
"path": "sample.env",
"chars": 117,
"preview": "# Sentry DSN - a public id that identifies your app to Sentry\nREACT_APP_SENTRY_DSN=<DSN-from-sentry-project-settings>"
},
{
"path": "src/components/App.js",
"chars": 18355,
"preview": "import React from 'react';\nimport * as Sentry from '@sentry/react';\nimport store from 'store';\nimport { isIOS, isDesktop"
},
{
"path": "src/components/App.test.js",
"chars": 248,
"preview": "import React from 'react';\nimport ReactDOM from 'react-dom';\nimport App from './App';\n\nit('renders without crashing', ()"
},
{
"path": "src/components/CurrentSong.js",
"chars": 1297,
"preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst DEFAULT_ART =\n 'https://cdn-media-1.freecodecamp."
},
{
"path": "src/components/Footer.js",
"chars": 4361,
"preview": "/* eslint-disable react/jsx-sort-props */\nimport React from 'react';\nimport PropTypes from 'prop-types';\nimport PageVisi"
},
{
"path": "src/components/Main.js",
"chars": 1330,
"preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport { isBrowser } from 'react-device-detect';\n\nimport "
},
{
"path": "src/components/Nav.js",
"chars": 1888,
"preview": "import React, { useState } from 'react';\n\nexport default function Nav() {\n const [isOpen, setIsOpen] = useState(false);"
},
{
"path": "src/components/Nav.test.js",
"chars": 1129,
"preview": "import React from 'react';\nimport { render, screen } from '@testing-library/react';\n\nimport Nav from './Nav';\n\ndescribe("
},
{
"path": "src/components/PlayPauseButton.js",
"chars": 1598,
"preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport { isBrowser } from 'react-device-detect';\nimport {"
},
{
"path": "src/components/Slider.js",
"chars": 728,
"preview": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst MAX = 100;\nconst STEP = 5;\n\nconst Slider = ({ curr"
},
{
"path": "src/components/SongHistory.js",
"chars": 2229,
"preview": "import React, { Component } from 'react';\nimport PropTypes from 'prop-types';\nimport { FontAwesomeIcon } from '@fortawes"
},
{
"path": "src/components/Visualizer.js",
"chars": 6424,
"preview": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport PageVisibility from 'react-page-visibility';\n\ncons"
},
{
"path": "src/css/App.css",
"chars": 12808,
"preview": "@import url('https://fonts.googleapis.com/css?family=Lato:400,700&display=swap');\n\n/* Globals */\n\n:root {\n --focus-outl"
},
{
"path": "src/index.js",
"chars": 484,
"preview": "import React from 'react';\nimport ReactDOM from 'react-dom';\nimport * as Sentry from '@sentry/react';\nimport { Integrati"
},
{
"path": "src/setupTests.js",
"chars": 483,
"preview": "import '@testing-library/jest-dom';\n\nObject.defineProperty(window, 'EventSource', {\n writable: true,\n value: jest.fn()"
},
{
"path": "src/utils/buildEventSource.js",
"chars": 75,
"preview": "export const buildEventSource = url => {\n return new EventSource(url);\n};\n"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the freeCodeCamp/coderadio-client GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 41 files (71.3 KB), approximately 20.5k tokens, and a symbol index with 59 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.