[
  {
    "path": ".eslintrc",
    "content": "{\n  \"extends\": [\n    \"react-app\",\n    \"./.eslintrc-base.json\",\n    \"plugin:prettier/recommended\"\n  ],\n  \"globals\": {\n    \"Promise\": true,\n    \"window\": true,\n    \"$\": true,\n    \"ga\": true,\n    \"jQuery\": true,\n    \"router\": true\n  },\n  \"settings\": {\n    \"import/ignore\": [\"node_modules\", \"\\\\.json$\"],\n    \"import/extensions\": [\".js\", \".jsx\"]\n  }\n}\n"
  },
  {
    "path": ".eslintrc-base.json",
    "content": "{\n  \"rules\": {\n    \"max-len\": [\n      \"error\",\n      { \"code\": 80, \"ignoreUrls\": true, \"ignoreTemplateLiterals\": true }\n    ],\n    \"block-scoped-var\": 0,\n    \"brace-style\": [2, \"1tbs\", { \"allowSingleLine\": true }],\n    \"camelcase\": 2,\n    \"comma-dangle\": 2,\n    \"comma-spacing\": [2, { \"before\": false, \"after\": true }],\n    \"comma-style\": [2, \"last\"],\n    \"complexity\": 0,\n    \"consistent-return\": 2,\n    \"consistent-this\": 0,\n    \"curly\": 2,\n    \"default-case\": 2,\n    \"dot-notation\": 0,\n    \"eol-last\": 2,\n    \"eqeqeq\": 2,\n    \"func-call-spacing\": 2,\n    \"func-names\": 0,\n    \"func-style\": 0,\n    \"guard-for-in\": 2,\n    \"handle-callback-err\": 2,\n    \"import/default\": 2,\n    \"import/export\": 2,\n    \"import/extensions\": [0, \"always\"],\n    \"import/first\": 2,\n    \"import/named\": 2,\n    \"import/namespace\": 2,\n    \"import/newline-after-import\": 2,\n    \"import/no-duplicates\": 2,\n    \"import/no-unresolved\": 2,\n    \"import/unambiguous\": 2,\n    \"jsx-quotes\": [2, \"prefer-single\"],\n    \"key-spacing\": [2, { \"beforeColon\": false, \"afterColon\": true }],\n    \"keyword-spacing\": [2],\n    \"max-depth\": 0,\n    \"max-nested-callbacks\": 0,\n    \"max-params\": 0,\n    \"max-statements\": 0,\n    \"new-cap\": 0,\n    \"new-parens\": 2,\n    \"no-alert\": 2,\n    \"no-array-constructor\": 2,\n    \"no-bitwise\": 2,\n    \"no-caller\": 2,\n    \"no-cond-assign\": 2,\n    \"no-console\": 0,\n    \"no-constant-condition\": 2,\n    \"no-control-regex\": 2,\n    \"no-debugger\": 2,\n    \"no-delete-var\": 2,\n    \"no-div-regex\": 2,\n    \"no-dupe-keys\": 2,\n    \"no-else-return\": 0,\n    \"no-empty\": 2,\n    \"no-empty-character-class\": 2,\n    \"no-eq-null\": 2,\n    \"no-eval\": 2,\n    \"no-ex-assign\": 2,\n    \"no-extend-native\": 2,\n    \"no-extra-bind\": 2,\n    \"no-extra-boolean-cast\": 2,\n    \"no-extra-parens\": 0,\n    \"no-extra-semi\": 2,\n    \"no-fallthrough\": 2,\n    \"no-floating-decimal\": 2,\n    \"no-func-assign\": 2,\n    \"no-global-assign\": 2,\n    \"no-implied-eval\": 2,\n    \"no-inline-comments\": 2,\n    \"no-inner-declarations\": 2,\n    \"no-invalid-regexp\": 2,\n    \"no-irregular-whitespace\": 2,\n    \"no-iterator\": 2,\n    \"no-label-var\": 2,\n    \"no-labels\": 2,\n    \"no-lone-blocks\": 2,\n    \"no-lonely-if\": 2,\n    \"no-loop-func\": 2,\n    \"no-mixed-requires\": 0,\n    \"no-mixed-spaces-and-tabs\": 2,\n    \"no-multi-spaces\": 2,\n    \"no-multi-str\": 2,\n    \"no-multiple-empty-lines\": [2, { \"max\": 2 }],\n    \"no-nested-ternary\": 2,\n    \"no-new\": 2,\n    \"no-new-func\": 2,\n    \"no-new-object\": 2,\n    \"no-new-require\": 2,\n    \"no-new-wrappers\": 2,\n    \"no-obj-calls\": 2,\n    \"no-octal\": 2,\n    \"no-octal-escape\": 2,\n    \"no-path-concat\": 2,\n    \"no-plusplus\": 0,\n    \"no-process-env\": 0,\n    \"no-process-exit\": 2,\n    \"no-proto\": 2,\n    \"no-regex-spaces\": 2,\n    \"no-reserved-keys\": 0,\n    \"no-restricted-modules\": 0,\n    \"no-return-assign\": 2,\n    \"no-script-url\": 2,\n    \"no-self-compare\": 2,\n    \"no-sequences\": 2,\n    \"no-shadow\": 0,\n    \"no-shadow-restricted-names\": 2,\n    \"no-sparse-arrays\": 2,\n    \"no-sync\": 0,\n    \"no-ternary\": 0,\n    \"no-trailing-spaces\": 2,\n    \"no-undef\": 2,\n    \"no-undef-init\": 2,\n    \"no-undefined\": 2,\n    \"no-underscore-dangle\": 0,\n    \"no-unreachable\": 2,\n    \"no-unsafe-negation\": 2,\n    \"no-unused-expressions\": 2,\n    \"no-unused-vars\": 2,\n    \"no-use-before-define\": 0,\n    \"no-void\": 0,\n    \"no-warning-comments\": [2, { \"terms\": [\"fixme\"], \"location\": \"start\" }],\n    \"no-with\": 2,\n    \"one-var\": 0,\n    \"operator-assignment\": 0,\n    \"padded-blocks\": 0,\n    \"prettier/prettier\": \"error\",\n    \"quote-props\": [2, \"as-needed\"],\n    \"quotes\": [2, \"single\", \"avoid-escape\"],\n    \"radix\": 2,\n    \"react/display-name\": 2,\n    \"react/jsx-boolean-value\": [2, \"always\"],\n    \"react/jsx-closing-bracket-location\": [\n      2,\n      { \"selfClosing\": \"line-aligned\", \"nonEmpty\": \"props-aligned\" }\n    ],\n    \"react/jsx-no-undef\": 2,\n    \"react/jsx-sort-props\": [2, { \"ignoreCase\": true }],\n    \"react/jsx-uses-react\": 2,\n    \"react/jsx-uses-vars\": 2,\n    \"react/jsx-wrap-multilines\": 2,\n    \"react/no-did-mount-set-state\": 2,\n    \"react/no-did-update-set-state\": 2,\n    \"react/no-multi-comp\": [2, { \"ignoreStateless\": true }],\n    \"react/no-unescaped-entities\": 0,\n    \"react/no-unknown-property\": 2,\n    \"react/prop-types\": 2,\n    \"react/react-in-jsx-scope\": 2,\n    \"react/self-closing-comp\": 2,\n    \"react/sort-prop-types\": 2,\n    \"react-hooks/rules-of-hooks\": \"error\",\n    \"react-hooks/exhaustive-deps\": \"error\",\n    \"semi\": [2, \"always\"],\n    \"semi-spacing\": [2, { \"before\": false, \"after\": true }],\n    \"sort-vars\": 0,\n    \"space-before-blocks\": [2, \"always\"],\n    \"space-before-function-paren\": [2, \"never\"],\n    \"space-in-brackets\": 0,\n    \"space-in-parens\": 0,\n    \"space-infix-ops\": 2,\n    \"space-unary-ops\": [2, { \"words\": true, \"nonwords\": false }],\n    \"spaced-comment\": [2, \"always\", { \"exceptions\": [\"-\"] }],\n    \"strict\": 0,\n    \"use-isnan\": 2,\n    \"valid-jsdoc\": 0,\n    \"valid-typeof\": 2,\n    \"vars-on-top\": 0,\n    \"wrap-iife\": [2, \"any\"],\n    \"wrap-regex\": 2,\n    \"yoda\": 0\n  }\n}\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: Coderadio-client ci\n\non: [push, pull_request]\n\njobs:\n  lint:\n    name: Lint\n    runs-on: ubuntu-20.04\n\n    strategy:\n      matrix:\n        node-version: [20.x]\n\n    steps:\n      - name: Checkout Source Files\n        uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2\n\n      - name: Install modules\n        run: npm ci\n\n      - name: Run ESLint\n        run: npm run lint\n\n  cypress-run:\n    name: Cypress Test\n    # Netlify deploys onto Ubuntu 20.04, so we should test on that os:\n    runs-on: ubuntu-20.04\n    strategy:\n      matrix:\n        browsers: [chrome, firefox]\n        node-version: [20.x]\n    steps:\n      - name: Set Action Environment Variables\n        run: |\n          echo \"CYPRESS_RECORD_KEY=${{ secrets.CYPRESS_RECORD_KEY }}\" >> $GITHUB_ENV\n          echo \"GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}\" >> $GITHUB_ENV\n          echo \"CYPRESS_INSTALL_BINARY=6.0.0\" >> $GITHUB_ENV\n\n      - name: Checkout\n        uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2\n\n      - name: Cypress run\n        uses: cypress-io/github-action@v2\n        with:\n          browser: ${{ matrix.browsers }}\n          build: npm run build\n          start: npm start\n          wait-on: http://localhost:3001\n          wait-on-timeout: 1200\n\n  unit-test:\n    name: Unit Test\n    runs-on: ubuntu-20.04\n\n    strategy:\n      matrix:\n        node-version: [20.x]\n\n    steps:\n      - name: Checkout Source Files\n        uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2\n\n      - name: Install modules\n        run: npm ci\n\n      - name: Run tests\n        run: npm test\n"
  },
  {
    "path": ".gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n/cypress/videos\n\n# dotenv environment variables file\n.env\n.env.test\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# production\n/build\n\n# misc\n.DS_Store\n.vscode\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Optional eslint cache\n.eslintcache\n\n### Netlify ###\n.netlify\n"
  },
  {
    "path": ".husky/.gitignore",
    "content": "_\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "npx lint-staged\n"
  },
  {
    "path": ".npmrc",
    "content": "CYPRESS_INSTALL_BINARY=0\nengine-strict=true\nenable-pre-post-scripts=true\npackage-manager-strict=false\n"
  },
  {
    "path": ".nvmrc",
    "content": "20.19.0\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"endOfLine\":\"auto\",  \n  \"semi\": true,\n  \"singleQuote\": true,\n  \"jsxSingleQuote\": true,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"none\",\n  \"arrowParens\": \"avoid\"\n}\n  "
  },
  {
    "path": "LICENSE",
    "content": "BSD 3-Clause License\n\nCopyright (c) 2018, freeCodeCamp.org\n\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n  list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n  this list of conditions and the following disclaimer in the documentation\n  and/or other materials provided with the distribution.\n\n* Neither the name of the copyright holder nor the names of its\n  contributors may be used to endorse or promote products derived from\n  this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
  },
  {
    "path": "README.md",
    "content": "![freeCodeCamp.org Social Banner](https://s3.amazonaws.com/freecodecamp/wide-social-banner.png)\n\n## Coderadio Client UI\n\nThis repository powers the current client application for the Code Radio at: <https://coderadio.freecodecamp.org>. \nEventually we will move this over to our Gatsby based client application for our curriculum and user profiles.\n\nYou can learn more about the coderadio here: <https://www.freecodecamp.org/news/code-radio-24-7>\n\n### Local setup\n\n`npm ci` then `npm start` will open the app.\n\nTo send errors to Sentry: `cp sample.env .env.local` and fill in the Sentry DSN from the project settings"
  },
  {
    "path": "cypress/fixtures/example.json",
    "content": "{\n  \"name\": \"Using fixtures to represent data\",\n  \"email\": \"hello@cypress.io\",\n  \"body\": \"Fixtures are a great way to mock data for responses to routes\"\n}"
  },
  {
    "path": "cypress/integration/home.js",
    "content": "/* global cy */\ndescribe('Landing page', () => {\n  it('Should render', () => {\n    cy.visit('http://localhost:3001');\n    cy.title().should('eq', 'freeCodeCamp.org Code Radio');\n  });\n});\n"
  },
  {
    "path": "cypress/integration/play-button.js",
    "content": "describe('Stop and play the music', () => {\n  beforeEach(() => {\n    cy.visit('http://localhost:3001');\n  });\n\n  it('Click play button', () => {\n    cy.get('audio')\n      .invoke('attr', 'src')\n      .should('contain', '.mp3')\n      .then(() => {\n        cy.get('#toggle-play-pause').should('be.visible').click();\n        cy.get('audio').should(audioElements => {\n          const audioIsPaused = audioElements[0].paused;\n          expect(audioIsPaused).to.eq(false);\n        });\n      });\n  });\n});\n"
  },
  {
    "path": "cypress/plugins/index.js",
    "content": "/* eslint-disable no-unused-vars */\n// / <reference types=\"cypress\" />\n// ***********************************************************\n// This example plugins/index.js can be used to load plugins\n//\n// You can change the location of this file or turn off loading\n// the plugins file with the 'pluginsFile' configuration option.\n//\n// You can read more here:\n// https://on.cypress.io/plugins-guide\n// ***********************************************************\n\n// This function is called when a project is opened or re-opened (e.g. due to\n// the project's config changing)\n\n/**\n * @type {Cypress.PluginConfig}\n */\nmodule.exports = (on, config) => {\n  // `on` is used to hook into various events Cypress emits\n  // `config` is the resolved Cypress config\n};\n"
  },
  {
    "path": "cypress/support/commands.js",
    "content": "// ***********************************************\n// This example commands.js shows you how to\n// create various custom commands and overwrite\n// existing commands.\n//\n// For more comprehensive examples of custom\n// commands please read more here:\n// https://on.cypress.io/custom-commands\n// ***********************************************\n//\n//\n// -- This is a parent command --\n// Cypress.Commands.add(\"login\", (email, password) => { ... })\n//\n//\n// -- This is a child command --\n// Cypress.Commands.add(\"drag\", { prevSubject: 'element'}, (subject, options) => { ... })\n//\n//\n// -- This is a dual command --\n// Cypress.Commands.add(\"dismiss\", { prevSubject: 'optional'}, (subject, options) => { ... })\n//\n//\n// -- This will overwrite an existing command --\n// Cypress.Commands.overwrite(\"visit\", (originalFn, url, options) => { ... })\n"
  },
  {
    "path": "cypress/support/index.js",
    "content": "// ***********************************************************\n// This example support/index.js is processed and\n// loaded automatically before your test files.\n//\n// This is a great place to put global configuration and\n// behavior that modifies Cypress.\n//\n// You can change the location of this file or turn off\n// automatically serving support files with the\n// 'supportFile' configuration option.\n//\n// You can read more here:\n// https://on.cypress.io/configuration\n// ***********************************************************\n\n// Import commands.js using ES2015 syntax:\nimport './commands';\n\n// Alternatively you can use CommonJS syntax:\n// require('./commands')\n"
  },
  {
    "path": "cypress-install.js",
    "content": "const util = require('cypress/lib/util');\nconst execa = require('execa');\n\nconst pkg = util.pkgVersion();\n\n(async () => {\n  console.log('Installing Cypress ' + pkg);\n  await execa('npm', ['run', 'cypress:install'], {\n    env: { CYPRESS_INSTALL_BINARY: pkg }\n  });\n  console.log('Cypress installed');\n})();\n"
  },
  {
    "path": "cypress.json",
    "content": "{\n    \"projectId\": \"kqzjwp\"\n}"
  },
  {
    "path": "netlify.toml",
    "content": "\n[build]\n  base    = \"\"\n  publish = \"/build\"\n  command = \"npm run build\""
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"coderadio\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"dependencies\": {\n    \"@fortawesome/fontawesome-svg-core\": \"6.7.2\",\n    \"@fortawesome/free-solid-svg-icons\": \"6.7.2\",\n    \"@fortawesome/react-fontawesome\": \"0.2.2\",\n    \"@sentry/react\": \"8.55.0\",\n    \"@sentry/tracing\": \"7.120.3\",\n    \"react\": \"18.3.1\",\n    \"react-device-detect\": \"2.2.3\",\n    \"react-dom\": \"18.3.1\",\n    \"react-page-visibility\": \"7.0.0\",\n    \"react-scripts\": \"5.0.1\",\n    \"store\": \"2.0.12\"\n  },\n  \"scripts\": {\n    \"start\": \"PORT=3001 react-scripts start\",\n    \"build\": \"react-scripts build\",\n    \"test\": \"react-scripts test --watchAll=false\",\n    \"test:watch\": \"react-scripts test\",\n    \"eject\": \"react-scripts eject\",\n    \"precypress\": \"node cypress-install.js\",\n    \"cypress\": \"cypress\",\n    \"cypress:open\": \"npm run cypress open\",\n    \"cypress:install\": \"cypress install && echo 'for use with ./cypress-install.js'\",\n    \"lint\": \"prettier --check \\\"src/**/*.{md,js}\\\"\",\n    \"lint:fix\": \"prettier --write \\\"src/**/*.{md,js}\\\"\",\n    \"prepare\": \"husky\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@testing-library/jest-dom\": \"6.6.3\",\n    \"@testing-library/react\": \"16.3.0\",\n    \"cypress\": \"13.17.0\",\n    \"eslint-config-prettier\": \"9.1.0\",\n    \"eslint-plugin-prettier\": \"5.2.6\",\n    \"execa\": \"9.5.2\",\n    \"husky\": \"9.1.7\",\n    \"lint-staged\": \"15.5.1\",\n    \"prettier\": \"3.5.3\"\n  },\n  \"lint-staged\": {\n    \"*.js\": \"npm run lint:fix\"\n  }\n}\n"
  },
  {
    "path": "public/_redirects",
    "content": "# Optional: Redirect default Netlify subdomain to primary domain\nhttps://freecodecamp-code-radio.netlify.com/* https://coderadio.freecodecamp.org/:splat 301!\n"
  },
  {
    "path": "public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <meta content=\"IE=edge\" http-equiv=\"X-UA-Compatible\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, minimum-scale=1.0, maximum-scale=8.0\"\n    />\n    <meta content=\"X5tHeKjV-jMLyp4VMoUhW9PAYaOjtPslV250\" name=\"csrf-token\" />\n    <link href=\"https://coderadio.freecodecamp.org\" rel=\"canonical\" />\n    <meta\n      content=\"Code Radio - 24/7 concentration music for developers\"\n      property=\"og:title\"\n    />\n    <meta content=\"freeCodeCamp.org\" property=\"og:site_name\" />\n    <meta content=\"on\" name=\"twitter:widgets:csp\" />\n    <meta content=\"d0bc047a482c03c24f1168004c2a216a\" name=\"p:domain_verify\" />\n    <meta content=\"https://coderadio.freecodecamp.org\" property=\"og:url\" />\n    <meta\n      content=\"Code Radio - 24/7 concentration music for developers\"\n      property=\"og:description\"\n    />\n    <meta\n      content=\"https://cdn.freecodecamp.org/coderadio/coderadio-meta-1920x1080.png\"\n      property=\"og:image\"\n    />\n    <meta content=\"article\" property=\"og:type\" />\n    <meta\n      content=\"https://www.facebook.com/freecodecamp\"\n      property=\"article:publisher\"\n    />\n    <meta content=\"Responsive\" property=\"article:section\" />\n    <meta content=\"Support\" name=\"description\" />\n    <meta content=\"@freecodecamp\" name=\"twitter:creator\" />\n    <meta content=\"https://coderadio.freecodecamp.org\" name=\"twitter:url\" />\n    <meta content=\"@freecodecamp\" name=\"twitter:site\" />\n    <meta content=\"summary_large_image\" name=\"twitter:card\" />\n    <meta\n      content=\"https://cdn.freecodecamp.org/coderadio/coderadio-meta-1920x1080.png\"\n      name=\"twitter:image:src\"\n    />\n    <meta content=\"Code Radio\" name=\"twitter:title\" />\n    <meta\n      content=\"24/7 concentration music for developers\"\n      name=\"twitter:description\"\n    />\n    <meta content=\"a40ee5d5dba3bb091ad783ebd2b1383f\" name=\"p:domain_verify\" />\n    <meta content=\"#FFFFFF\" name=\"msapplication-TileColor\" />\n    <meta\n      content=\"https://cdn.freecodecamp.org/universal/favicons/browserconfig.xml\"\n      rel=\"msapplication-config\"\n    />\n    <link\n      href=\"https://cdn.freecodecamp.org/universal/favicons/android-chrome-192x192.png\"\n      rel=\"android-chrome\"\n      sizes=\"192x192\"\n    />\n    <link\n      href=\"https://cdn.freecodecamp.org/universal/favicons/android-chrome-384x384.png\"\n      rel=\"android-chrome\"\n      sizes=\"384x384\"\n    />\n    <link\n      href=\"https://cdn.freecodecamp.org/universal/favicons/site.webmanifest\"\n      rel=\"manifest\"\n    />\n    <link\n      href=\"https://cdn.freecodecamp.org/universal/favicons/apple-touch-icon.png\"\n      rel=\"apple-touch-icon\"\n      sizes=\"180x180\"\n    />\n    <link\n      href=\"https://cdn.freecodecamp.org/universal/favicons/favicon-16x16.png\"\n      rel=\"favicon\"\n      sizes=\"16x16\"\n    />\n    <link\n      href=\"https://cdn.freecodecamp.org/universal/favicons/favicon-32x32.png\"\n      rel=\"favicon\"\n      sizes=\"32x32\"\n    />\n    <link\n      href=\"https://cdn.freecodecamp.org/universal/favicons/favicon.ico\"\n      rel=\"icon\"\n    />\n    <title>freeCodeCamp.org Code Radio</title>\n    <script\n      async\n      src=\"https://www.googletagmanager.com/gtag/js?id=UA-55446531-21\"\n    ></script>\n    <script>\n      window.dataLayer = window.dataLayer || [];\n      function gtag() {\n        dataLayer.push(arguments);\n      }\n      gtag('js', new Date());\n      gtag('config', 'UA-55446531-21');\n    </script>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n  </body>\n  <script src=\"https://cdn.jsdelivr.net/npm/@widgetbot/crate@3\" async defer>\n     new Crate({\n        server: '692816967895220344', // freeCodeCamp.org Official ᕕ(⌐■_■)ᕗ ♪♬\n        channel: '1254842489362317322' // #code-radio\n    })\n    const button = document.querySelector('crate > div').shadowRoot.querySelector(\"button\");\n    button.style.bottom = \"90px\";\n    const embed = document.querySelector('crate > div').shadowRoot.querySelector(\".embed\");\n    embed.style.bottom = \"90px\";\n    // we only need to adjust the height at the iframe's mobile breakpoint.\n    // This does break during resizing, but I think that's enough of an edge case.\n    if (window.innerWidth <= 500) {\n      embed.style.maxHeight = \"calc(100% - 80px)\"\n    }\n  </script>\n</html>\n"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"extends\": [\"github>freecodecamp/renovate-config\"]\n}\n"
  },
  {
    "path": "sample.env",
    "content": "# 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",
    "content": "import React from 'react';\nimport * as Sentry from '@sentry/react';\nimport store from 'store';\nimport { isIOS, isDesktop } from 'react-device-detect';\n\nimport Nav from './Nav';\nimport Main from './Main';\nimport Footer from './Footer';\nimport { buildEventSource } from '../utils/buildEventSource';\n\nimport '../css/App.css';\n\nconst sseUri =\n  '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';\nconst jsonUri = `https://coderadio-admin-v2.freecodecamp.org/api/nowplaying_static/coderadio.json`;\n\nlet sse = buildEventSource(sseUri);\n\nconst CODERADIO_VOLUME = 'coderadio-volume';\n\nsse.onerror = ({ message, error }) => {\n  Sentry.addBreadcrumb({\n    message: 'WebSocket error: ' + message\n  });\n  Sentry.captureException(error);\n};\n\nexport default class App extends React.Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      // General configuration options\n      config: {\n        metadataTimer: 1000\n      },\n      fastConnection: navigator.connection\n        ? navigator.connection.downlink > 1.5\n        : false,\n\n      /**\n       * The equalizer data is held as a separate data set\n       * to allow for easy implementation of visualizers.\n       * With the ultimate goal of this allowing plug and\n       * play visualizers.\n       */\n      eq: {},\n\n      /**\n       * Potentially removing the visualizer from this class\n       * to build it as a stand alone element that can be\n       * replaced by community submissions.\n       */\n      visualizer: {},\n\n      /**\n       * Some basic configuration for nicer audio transitions\n       * (Used in earlier projects and just maintained).\n       */\n      audioConfig: {\n        targetVolume: 0,\n        maxVolume: 0.5,\n        volumeSteps: 0.05,\n        fadeSteps: 0.01,\n        currentVolume: 0.5,\n        volumeTransitionSpeed: 10\n      },\n\n      /**\n       * This is where all the audio is pumped through. Due\n       * to it being a single audio element, there should be\n       * no memory leaks of extra floating audio elements.\n       */\n      url: '',\n      mounts: [],\n      remotes: [],\n      playing: null,\n      captions: null,\n      pausing: null,\n      pullMeta: false,\n      erroredStreams: [],\n\n      // Note: the crossOrigin is needed to fix a CORS JavaScript requirement\n\n      // There are a few *private* variables used\n      currentSong: {},\n      songStartedAt: 0,\n      songDuration: 0,\n      listeners: 0,\n      songHistory: []\n    };\n\n    this.togglePlay = this.togglePlay.bind(this);\n    this.setUrl = this.setUrl.bind(this);\n    this.setTargetVolume = this.setTargetVolume.bind(this);\n    this.getNowPlaying = this.getNowPlaying.bind(this);\n    this.updateVolume = this.updateVolume.bind(this);\n    this.increaseVolume = this.increaseVolume.bind(this);\n    this.decreaseVolume = this.decreaseVolume.bind(this);\n\n    // Keyboard handlers\n    this.addKeyboardHotKeysListener =\n      this.addKeyboardHotKeysListener.bind(this);\n    this.removeKeyboardHotKeysListener =\n      this.removeKeyboardHotKeysListener.bind(this);\n    this.handleKeyboardHotKeys = this.handleKeyboardHotKeys.bind(this);\n  }\n\n  isSpacePressed(event) {\n    return event.key === ' ';\n  }\n\n  canTogglePlayPause() {\n    // Prevent play/pause toggle when elements with ids in the following list are pressed.\n    const disallowedIds = [\n      'recent-song-history',\n      'toggle-play-pause',\n      'stream-select',\n      'keyboard-controls',\n      'toggle-button-nav'\n    ];\n    return !disallowedIds.includes(document.activeElement.id);\n  }\n\n  isUpDownArrowPressed(event) {\n    return event.key === 'ArrowUp' || event.key === 'ArrowDown';\n  }\n\n  canAdjustVolume() {\n    // Ignore arrow hot keys if focus is on volume slider or stream selector.\n    const disallowedIds = ['volume-input', 'stream-select'];\n    return !disallowedIds.includes(document.activeElement.id);\n  }\n\n  handleKeyboardHotKeys(event) {\n    const keyMap = new Map();\n    keyMap.set(' ', this.togglePlay);\n    keyMap.set('k', this.togglePlay);\n    keyMap.set('ArrowUp', this.increaseVolume);\n    keyMap.set('ArrowDown', this.decreaseVolume);\n\n    if (!keyMap.has(event.key)) return;\n\n    if (this.isSpacePressed(event) && !this.canTogglePlayPause()) return;\n\n    if (this.isUpDownArrowPressed(event) && !this.canAdjustVolume()) return;\n\n    try {\n      keyMap.get(event.key)();\n    } catch (err) {\n      console.log(`Bad callback for hotkey '${event.key}': ${err.message}`);\n    }\n  }\n\n  addKeyboardHotKeysListener() {\n    window.addEventListener('keydown', this.handleKeyboardHotKeys);\n  }\n\n  removeKeyboardHotKeysListener() {\n    window.removeEventListener('keydown', this.handleKeyboardHotKeys);\n  }\n\n  // Set the players initial vol and crossOrigin\n  setPlayerInitial() {\n    /**\n     * Get user volume level from local storage\n     * if not available set to default 0.5.\n     */\n    const maxVolume =\n      store.get(CODERADIO_VOLUME) || this.state.audioConfig.maxVolume;\n    this.setState(\n      {\n        audioConfig: {\n          ...this.state.audioConfig,\n          maxVolume,\n          currentVolume: maxVolume\n        }\n      },\n      () => {\n        this._player.volume = maxVolume;\n      }\n    );\n  }\n\n  componentDidMount() {\n    this.setPlayerInitial();\n    this.getNowPlaying();\n    if (isDesktop) {\n      this.addKeyboardHotKeysListener();\n    }\n  }\n\n  componentWillUnmount() {\n    if (isDesktop) {\n      this.removeKeyboardHotKeysListener();\n    }\n    sse.close();\n  }\n\n  /**\n   * If we ever change the URL, we need to update the player\n   * and begin playing it again. This can happen if the server\n   * resets the URL.\n   */\n  async setUrl(url = false) {\n    if (!url) return;\n\n    if (this.state.playing) await this.pause();\n\n    this._player.src = url;\n    this.setState({\n      url\n    });\n\n    /**\n     * Since the `playing` state is initially `null` when the app first loads\n     * and is set to boolean when there is an user interaction,\n     * we prevent the app from auto-playing the music\n     * by only calling `this.play()` if the `playing` state is not `null`.\n     */\n    if (this.state.playing !== null) {\n      this.play();\n    }\n  }\n\n  play() {\n    const { mounts, remotes } = this.state;\n\n    let streamUrls = Array.from([...mounts, ...remotes], stream => stream.url);\n\n    // Check if the url has been reset by pause\n    if (!streamUrls.includes(this._player.src)) {\n      this._player.src = this.state.url;\n      this._player.load();\n    }\n\n    this._player.volume = 0;\n    this._player.play().then(() => {\n      this.setState(state => {\n        return {\n          audioConfig: { ...state.audioConfig, currentVolume: 0 },\n          playing: true,\n          pullMeta: true\n        };\n      });\n\n      this.fadeUp();\n    });\n  }\n\n  pause() {\n    // Completely stop the audio element\n    if (!this.state.playing) return Promise.resolve();\n\n    return new Promise(resolve => {\n      this._player.pause();\n      this._player.load();\n\n      this.setState(\n        {\n          playing: false,\n          pausing: false\n        },\n        () => {\n          // socket.close();\n          resolve();\n        }\n      );\n    });\n  }\n\n  /**\n   * Very basic method that acts like the play/pause button\n   * of a standard player. It loads in a new song if there\n   * isn't already one loaded.\n   */\n  togglePlay() {\n    // If there already is a source, confirm it's playing or not\n    if (this._player.src) {\n      // If the player is paused, set the volume to 0 and fade up\n      if (!this.state.playing) {\n        this.play();\n      }\n      // If it is already playing, fade the music out (resulting in a pause)\n      else {\n        this.fadeDown();\n      }\n    }\n  }\n\n  setTargetVolume(volume) {\n    let audioConfig = { ...this.state.audioConfig };\n    let maxVolume = parseFloat(Math.max(0, Math.min(1, volume).toFixed(2)));\n    audioConfig.maxVolume = maxVolume;\n    audioConfig.currentVolume = maxVolume;\n    this._player.volume = audioConfig.maxVolume;\n    this.setState(\n      {\n        audioConfig\n      },\n      () => {\n        // Save user volume to local storage\n        store.set(CODERADIO_VOLUME, maxVolume);\n      }\n    );\n  }\n\n  /**\n   * Simple fade command to initiate the playing and pausing\n   * in a more fluid method.\n   */\n  fade(direction) {\n    let audioConfig = { ...this.state.audioConfig };\n    audioConfig.targetVolume =\n      direction.toLowerCase() === 'up' ? this.state.audioConfig.maxVolume : 0;\n    this.setState(\n      {\n        audioConfig,\n        pausing: direction === 'down'\n      },\n      this.updateVolume\n    );\n  }\n\n  fadeUp() {\n    this.fade('up');\n  }\n\n  fadeDown() {\n    this.fade('down');\n  }\n\n  /**\n   * In order to have nice fading,\n   * this method adjusts the volume dynamically over time.\n   */\n  updateVolume() {\n    /**\n     * In order to fix floating math issues,\n     * we set the toFixed in order to avoid 0.999999999999 increments.\n     */\n    let currentVolume = parseFloat(this._player.volume.toFixed(2));\n    /**\n     * If the volume is correctly set to the target, no need to change it\n     *\n     * Note: On iOS devices, volume level is totally under user's control and cannot be programmatically set.\n     * We pause the music immediately in this case.\n     * (https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html)\n     */\n    if (currentVolume === this.state.audioConfig.targetVolume || isIOS) {\n      // If the audio is set to 0 and it’s been met, pause the audio\n      if (this.state.audioConfig.targetVolume === 0 && this.state.pausing)\n        this.pause();\n\n      // Unmet audio volume settings require it to be changed\n    } else {\n      /**\n       * We capture the value of the next increment by either the configuration\n       * or the difference between the current and target\n       * if it's smaller than the increment.\n       */\n      let volumeNextIncrement = Math.min(\n        this.state.audioConfig.fadeSteps,\n        Math.abs(this.state.audioConfig.targetVolume - this._player.volume)\n      );\n\n      /**\n       * Adjust the audio based on if the target is\n       * higher or lower than the current.\n       */\n      let volumeAdjust =\n        this.state.audioConfig.targetVolume > this._player.volume\n          ? volumeNextIncrement\n          : -volumeNextIncrement;\n\n      this._player.volume += volumeAdjust;\n\n      let audioConfig = this.state.audioConfig;\n      audioConfig.currentVolume += volumeAdjust;\n\n      this.setState({\n        audioConfig\n      });\n      // The speed at which the audio lowers is also controlled.\n      setTimeout(\n        this.updateVolume,\n        this.state.audioConfig.volumeTransitionSpeed\n      );\n    }\n  }\n\n  sortStreams = (streams, lowBitrate = false, shuffle = false) => {\n    if (shuffle) {\n      /**\n       * Shuffling should only happen among streams with similar bitrates\n       * since each relay displays listener numbers across relays. Shuffling\n       * should be used to spread the load on initial stream selection.\n       */\n      let bitrates = streams.map(stream => stream.bitrate);\n      let maxBitrate = Math.max(...bitrates);\n      return streams\n        .filter(stream => {\n          if (!lowBitrate) return stream.bitrate === maxBitrate;\n          else return stream.bitrate !== maxBitrate;\n        })\n        .sort(() => Math.random() - 0.5);\n    } else {\n      return streams.sort((a, b) => {\n        if (lowBitrate) {\n          // Sort by bitrate from low to high\n          if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return -1;\n          if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return 1;\n        } else {\n          // Sort by bitrate, from high to low\n          if (parseFloat(a.bitrate) < parseFloat(b.bitrate)) return 1;\n          if (parseFloat(a.bitrate) > parseFloat(b.bitrate)) return -1;\n        }\n\n        // If both items have the same bitrate, sort by listeners from low to high\n        if (a.listeners.current < b.listeners.current) return -1;\n        if (a.listeners.current > b.listeners.current) return 1;\n        return 0;\n      });\n    }\n  };\n\n  getStreamUrl = (streams, lowBitrate) => {\n    const sorted = this.sortStreams(streams, lowBitrate, true);\n    return sorted[0].url;\n  };\n\n  // Choose the stream based on the connection and availability of relay(remotes)\n  setMountToConnection(mounts = [], remotes = []) {\n    let url = null;\n    if (this.state.fastConnection === false && remotes.length > 0) {\n      url = this.getStreamUrl(remotes, true);\n    } else if (this.state.fastConnection && remotes.length > 0) {\n      url = this.getStreamUrl(remotes);\n    } else if (this.state.fastConnection === false) {\n      url = this.getStreamUrl(mounts, true);\n    } else {\n      url = this.getStreamUrl(mounts);\n    }\n    this._player.src = url;\n    this.setState({\n      url\n    });\n  }\n\n  fetchJSON() {\n    fetch(jsonUri)\n      .then(response => {\n        return response.json();\n      })\n      .then(np => {\n        this.setState({\n          mounts: np.station.mounts,\n          remotes: np.station.remotes,\n          listeners: np.listeners.current,\n          currentSong: np.now_playing.song,\n          songStartedAt: np.now_playing.played_at * 1000,\n          songDuration: np.now_playing.duration,\n          pullMeta: false,\n          songHistory: np.song_history\n        });\n        this.setMountToConnection(np.station.mounts, np.station.remotes);\n      })\n      .catch(() => {});\n  }\n\n  getNowPlaying() {\n    // Since json recives data faster than sse, set the data initially\n    this.fetchJSON();\n\n    // Reconnect Timeout needs to be added\n    sse.onmessage = event => {\n      const data = JSON.parse(event.data);\n      const np = data?.pub?.data?.np || null;\n      if (np) {\n        // Process Now Playing data in `np` var.\n        // We look through the available mounts to find the default mount\n        if (this.state.url === '') {\n          this.setState({\n            mounts: np.station.mounts,\n            remotes: np.station.remotes\n          });\n          this.setMountToConnection(np.station.mounts, np.station.remotes);\n        }\n        if (this.state.listeners !== np.listeners.current) {\n          this.setState({\n            listeners: np.listeners.current\n          });\n        }\n        // We only need to update the metadata if the song has been changed\n        if (\n          np.now_playing.song.id !== this.state.currentSong.id ||\n          this.state.pullMeta\n        ) {\n          this.setState({\n            currentSong: np.now_playing.song,\n            songStartedAt: np.now_playing.played_at * 1000,\n            songDuration: np.now_playing.duration,\n            pullMeta: false,\n            songHistory: np.song_history\n          });\n        }\n      }\n    };\n  }\n\n  increaseVolume = () =>\n    this.setTargetVolume(\n      Math.min(\n        this.state.audioConfig.maxVolume + this.state.audioConfig.volumeSteps,\n        1\n      )\n    );\n\n  decreaseVolume = () =>\n    this.setTargetVolume(\n      Math.max(\n        this.state.audioConfig.maxVolume - this.state.audioConfig.volumeSteps,\n        0\n      )\n    );\n\n  onPlayerError = async () => {\n    /**\n     * This error handler works as follows:\n     * - When the player cannot play the url:\n     *   - If the player's src is falsy and the `playing` state is being false,\n     *     return early. (It means the user has paused the player and\n     *     the src has been reset to an empty string).\n     *   - If the url is already in the `erroredStreams` list: Try another url.\n     *   - If the url is not in `erroredStreams`: Add the url to the list and\n     *     try another url.\n     *   - If `erroredStreams` has as many items as the list of available streams:\n     *     Pause the player because this means all of our urls are having issues.\n     */\n    if (!this.state.playing && !this._player.src) return;\n\n    const { mounts, remotes, erroredStreams, url } = this.state;\n    const sortedStreams = this.sortStreams([...remotes, ...mounts]);\n    const currentStream = sortedStreams.find(stream => stream.url === url);\n    const isStreamInErroredList = erroredStreams.some(\n      stream => stream.url === url\n    );\n    const newErroredStreams = isStreamInErroredList\n      ? erroredStreams\n      : [...erroredStreams, currentStream];\n\n    // Pause if all streams are in the errored list\n    if (newErroredStreams.length === sortedStreams.length) {\n      await this.pause();\n      return;\n    }\n\n    /**\n     * Available streams are those in `sortedStreams`\n     * that don't exist in the errored list.\n     */\n    const availableUrls = sortedStreams\n      .filter(\n        stream =>\n          !newErroredStreams.some(\n            erroredStream => erroredStream.url === stream.url\n          )\n      )\n      .map(({ url }) => url);\n\n    // If the url is already in the errored list, use another url\n    if (isStreamInErroredList) {\n      this.setUrl(availableUrls[0]);\n    } else {\n      // Otherwise, add the url to the errored list, then use another url\n      this.setState({ erroredStreams: newErroredStreams }, () =>\n        this.setUrl(availableUrls[0])\n      );\n    }\n  };\n\n  render() {\n    return (\n      <div className='App'>\n        <Nav />\n        <Main\n          fastConnection={this.state.fastConnection}\n          player={this._player}\n          playing={this.state.playing}\n        />\n        <audio\n          aria-label='audio'\n          crossOrigin='anonymous'\n          onError={this.onPlayerError}\n          ref={a => (this._player = a)}\n        >\n          <track kind='captions' {...this.state.captions} />\n        </audio>\n        <Footer\n          currentSong={this.state.currentSong}\n          currentVolume={this.state.audioConfig.currentVolume}\n          fastConnection={this.state.fastConnection}\n          listeners={this.state.listeners}\n          mounts={this.state.mounts}\n          playing={this.state.playing}\n          remotes={this.state.remotes}\n          setTargetVolume={this.setTargetVolume}\n          setUrl={this.setUrl}\n          songDuration={this.state.songDuration}\n          songHistory={this.state.songHistory}\n          songStartedAt={this.state.songStartedAt}\n          togglePlay={this.togglePlay}\n          url={this.state.url}\n        />\n      </div>\n    );\n  }\n}\n"
  },
  {
    "path": "src/components/App.test.js",
    "content": "import React from 'react';\nimport ReactDOM from 'react-dom';\nimport App from './App';\n\nit('renders without crashing', () => {\n  const div = document.createElement('div');\n  ReactDOM.render(<App />, div);\n  ReactDOM.unmountComponentAtNode(div);\n});\n"
  },
  {
    "path": "src/components/CurrentSong.js",
    "content": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst DEFAULT_ART =\n  'https://cdn-media-1.freecodecamp.org/code-radio/cover_placeholder.gif';\n\nconst CurrentSong = props => (\n  <div\n    className={\n      props.playing\n        ? 'meta-display thumb meta-display-visible'\n        : 'meta-display thumb'\n    }\n  >\n    <img\n      alt='album art'\n      data-meta='picture'\n      src={props.fastConnection ? props.currentSong.art : DEFAULT_ART}\n    />\n    <div className='now-playing'>\n      <div className='progress-container'>\n        <progress\n          aria-hidden='true'\n          data-meta='duration'\n          max={props.songDuration}\n          value={props.progressVal}\n        />\n      </div>\n      <div data-meta='title'>{props.currentSong.title}</div>\n      <div data-meta='artist'>{props.currentSong.artist}</div>\n      <div data-meta='album'>{props.currentSong.album}</div>\n      <div data-meta='listeners'>Listeners: {props.listeners}</div>\n      {props.mountOptions}\n    </div>\n  </div>\n);\n\nCurrentSong.propTypes = {\n  currentSong: PropTypes.object,\n  fastConnection: PropTypes.bool,\n  listeners: PropTypes.number,\n  mountOptions: PropTypes.node,\n  playing: PropTypes.bool,\n  progressVal: PropTypes.number,\n  songDuration: PropTypes.number\n};\n\nexport default CurrentSong;\n"
  },
  {
    "path": "src/components/Footer.js",
    "content": "/* eslint-disable react/jsx-sort-props */\nimport React from 'react';\nimport PropTypes from 'prop-types';\nimport PageVisibility from 'react-page-visibility';\nimport CurrentSong from './CurrentSong';\nimport Slider from './Slider';\nimport PlayPauseButton from './PlayPauseButton';\nimport SongHistory from './SongHistory';\n\nexport default class Footer extends React.PureComponent {\n  constructor(props) {\n    super(props);\n    this.state = {\n      progressVal: 0,\n      currentSong: {},\n      progressInterval: null,\n      alternativeMounts: null,\n      isTabVisible: true\n    };\n    this.updateProgress = this.updateProgress.bind(this);\n  }\n\n  componentDidUpdate(prevProps) {\n    /**\n     * If the song is new and we have all required props,\n     * reset setInterval and currentSong.\n     */\n    if (\n      this.state.currentSong.id !== prevProps.currentSong.id &&\n      this.props.songStartedAt &&\n      this.props.playing\n    ) {\n      // eslint-disable-next-line react/no-did-update-set-state\n      this.setState({\n        currentSong: this.props.currentSong,\n        alternativeMounts: [].concat(this.props.remotes, this.props.mounts)\n      });\n      this.toggleInterval();\n    } else if (prevProps.playing !== this.props.playing) {\n      this.toggleInterval();\n    }\n  }\n\n  componentWillUnmount() {\n    this.stopCurrentInterval();\n  }\n\n  startInterval() {\n    this.stopCurrentInterval();\n    this.setState({\n      progressInterval: setInterval(this.updateProgress, 100)\n    });\n  }\n\n  stopCurrentInterval() {\n    if (this.state.progressInterval) {\n      clearInterval(this.state.progressInterval);\n    }\n  }\n\n  toggleInterval() {\n    if (this.props.playing && this.state.isTabVisible) this.startInterval();\n    else this.stopCurrentInterval();\n  }\n\n  updateProgress() {\n    let progressVal = parseInt(\n      ((new Date().valueOf() - this.props.songStartedAt) / 1000).toFixed(2),\n      10\n    );\n    this.setState({ progressVal });\n  }\n\n  handleChange(event) {\n    let { value } = event.target;\n    this.props.setUrl(value);\n  }\n\n  handleVisibilityChange = isTabVisible => {\n    this.setState({ isTabVisible }, () => {\n      this.toggleInterval();\n    });\n  };\n\n  getMountOptions() {\n    let mountOptions = '';\n    let { alternativeMounts } = this.state;\n    if (alternativeMounts && this.props.url) {\n      mountOptions = (\n        <select\n          aria-label='Select Stream'\n          data-meta='stream-select'\n          id='stream-select'\n          onChange={this.handleChange.bind(this)}\n          value={this.props.url}\n        >\n          {alternativeMounts.map((mount, index) => (\n            <option key={index} value={mount.url}>\n              {mount.name}\n            </option>\n          ))}\n        </select>\n      );\n    }\n    return mountOptions;\n  }\n\n  render() {\n    let { progressVal, currentSong, isTabVisible } = this.state;\n    let {\n      playing,\n      songDuration,\n      togglePlay,\n      currentVolume,\n      setTargetVolume,\n      listeners,\n      fastConnection,\n      url\n    } = this.props;\n\n    return (\n      <PageVisibility onChange={this.handleVisibilityChange}>\n        <footer>\n          {isTabVisible && (\n            <SongHistory\n              songHistory={this.props.songHistory}\n              fastConnection={fastConnection}\n            />\n          )}\n          <CurrentSong\n            currentSong={currentSong}\n            progressVal={progressVal}\n            fastConnection={fastConnection}\n            listeners={listeners}\n            mountOptions={this.getMountOptions()}\n            playing={playing}\n            songDuration={songDuration}\n          />\n          <PlayPauseButton\n            playing={playing}\n            togglePlay={togglePlay}\n            url={url}\n          />\n          <Slider\n            currentVolume={currentVolume}\n            setTargetVolume={setTargetVolume}\n          />\n        </footer>\n      </PageVisibility>\n    );\n  }\n}\n\nFooter.propTypes = {\n  currentSong: PropTypes.object,\n  currentVolume: PropTypes.number,\n  fastConnection: PropTypes.bool,\n  listeners: PropTypes.number,\n  mounts: PropTypes.array,\n  playing: PropTypes.bool,\n  remotes: PropTypes.array,\n  setTargetVolume: PropTypes.func,\n  setUrl: PropTypes.func,\n  songDuration: PropTypes.number,\n  songHistory: PropTypes.array,\n  songStartedAt: PropTypes.number,\n  togglePlay: PropTypes.func,\n  url: PropTypes.string\n};\n"
  },
  {
    "path": "src/components/Main.js",
    "content": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport { isBrowser } from 'react-device-detect';\n\nimport Visualizer from './Visualizer';\nimport Video from '../assets/Saron3.webm';\n\nconst Main = props => {\n  return (\n    <main>\n      <div className='under-header-content'>\n        <h1 className='site-title'>Welcome to Code Radio.</h1>\n        <h2 className='site-description'>24/7 music designed for coding.</h2>\n      </div>\n      {isBrowser && (\n        <>\n          <div className='animation'>\n            <video\n              aria-hidden={true}\n              autoPlay={true}\n              loop={true}\n              muted={true}\n              playsInline={true}\n            >\n              <source src={Video} type='video/webm' />\n            </video>\n          </div>\n          <Visualizer player={props.player} playing={props.playing} />\n          <details>\n            <summary id='keyboard-controls'>Keyboard Controls</summary>\n            <dl>\n              <dt>Play/Pause:</dt>\n              <dd>Spacebar or \"k\"</dd>\n              <dt>Volume:</dt>\n              <dd>Up Arrow / Down Arrow</dd>\n            </dl>\n          </details>\n        </>\n      )}\n    </main>\n  );\n};\n\nMain.propTypes = {\n  fastConnection: PropTypes.bool,\n  player: PropTypes.object,\n  playing: PropTypes.bool\n};\n\nexport default Main;\n"
  },
  {
    "path": "src/components/Nav.js",
    "content": "import React, { useState } from 'react';\n\nexport default function Nav() {\n  const [isOpen, setIsOpen] = useState(false);\n\n  const toggleSidenav = () => {\n    setIsOpen(!isOpen);\n  };\n\n  const links = [\n    { href: 'https://www.freecodecamp.org/news/', text: 'News' },\n    { href: 'https://www.freecodecamp.org/forum/', text: 'Forum' },\n    { href: 'https://www.freecodecamp.org/learn/', text: 'Learn' }\n  ];\n\n  return (\n    <nav className={'site-nav' + (isOpen ? ' expand-nav' : '')} id='site-nav'>\n      <div className='site-nav-left' />\n      <div className='site-nav-middle'>\n        <a\n          aria-label='freeCodeCamp.org'\n          className='site-nav-logo'\n          href='https://www.freecodecamp.org/'\n        >\n          <img\n            alt='freeCodeCamp.org'\n            aria-hidden='true'\n            src='https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg'\n          />\n        </a>\n      </div>\n      <div className='site-nav-right main-nav'>\n        <button\n          aria-controls='nav'\n          aria-expanded={isOpen}\n          className={\n            'site-nav-right toggle-button-nav' +\n            (isOpen ? ' reverse-toggle-color' : '')\n          }\n          id='toggle-button-nav'\n          onClick={toggleSidenav}\n        >\n          Menu\n        </button>\n        <div className='main-nav-group'>\n          <ul\n            className={'nav' + (isOpen ? ' show-main-nav-items' : '')}\n            id='nav'\n          >\n            {links.map((link, index) => (\n              <li key={index}>\n                <a href={link.href} rel='noopener noreferrer' target='_blank'>\n                  <span>\n                    {link.text}{' '}\n                    <span className='sr-only'>opens in new window</span>\n                  </span>\n                </a>\n              </li>\n            ))}\n          </ul>\n        </div>\n      </div>\n    </nav>\n  );\n}\n"
  },
  {
    "path": "src/components/Nav.test.js",
    "content": "import React from 'react';\nimport { render, screen } from '@testing-library/react';\n\nimport Nav from './Nav';\n\ndescribe('<Nav />', () => {\n  it('should render a link to the News page', () => {\n    render(<Nav />);\n\n    const newsLink = screen.getByRole('link', {\n      name: /news opens in new window/i\n    });\n\n    expect(newsLink).toBeInTheDocument();\n\n    expect(newsLink).toHaveAttribute(\n      'href',\n      'https://www.freecodecamp.org/news/'\n    );\n  });\n\n  it('should render a link to the Forum page', () => {\n    render(<Nav />);\n\n    const forumLink = screen.getByRole('link', {\n      name: /forum opens in new window/i\n    });\n\n    expect(forumLink).toBeInTheDocument();\n\n    expect(forumLink).toHaveAttribute(\n      'href',\n      'https://www.freecodecamp.org/forum/'\n    );\n  });\n\n  it('should render a link to the Learn page', () => {\n    render(<Nav />);\n\n    const learnLink = screen.getByRole('link', {\n      name: /learn opens in new window/i\n    });\n\n    expect(learnLink).toBeInTheDocument();\n\n    expect(learnLink).toHaveAttribute(\n      'href',\n      'https://www.freecodecamp.org/learn/'\n    );\n  });\n});\n"
  },
  {
    "path": "src/components/PlayPauseButton.js",
    "content": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport { isBrowser } from 'react-device-detect';\nimport { FontAwesomeIcon } from '@fortawesome/react-fontawesome';\nimport { faCircleNotch } from '@fortawesome/free-solid-svg-icons';\n\nimport { ReactComponent as Pause } from '../assets/pause.svg';\nimport { ReactComponent as Play } from '../assets/play.svg';\n\nclass PlayPauseButton extends React.Component {\n  state = {\n    initialLoad: true\n  };\n\n  handleOnClick = () => isBrowser && this.props.togglePlay();\n\n  handleOnTouchEnd = () => !isBrowser && this.props.togglePlay();\n\n  static getDerivedStateFromProps(nextProps, prevState) {\n    // Set initial load to render the initial message accordingly\n    if (prevState.initialLoad && nextProps.playing) {\n      return { initialLoad: false };\n    }\n    return null;\n  }\n\n  render() {\n    return this.props.url ? (\n      <button\n        aria-label={this.props.playing ? 'Pause' : 'Play'}\n        className={\n          this.state.initialLoad\n            ? 'play-container-cta play-container'\n            : 'play-container'\n        }\n        id='toggle-play-pause'\n        onClick={this.handleOnClick}\n        onTouchEnd={this.handleOnTouchEnd}\n      >\n        {this.props.playing ? <Pause /> : <Play />}\n      </button>\n    ) : (\n      <FontAwesomeIcon\n        aria-hidden='true'\n        className='loader-circle-notch'\n        icon={faCircleNotch}\n        spin={true}\n      />\n    );\n  }\n}\n\nPlayPauseButton.propTypes = {\n  playing: PropTypes.bool,\n  togglePlay: PropTypes.func,\n  url: PropTypes.string\n};\n\nexport default PlayPauseButton;\n"
  },
  {
    "path": "src/components/Slider.js",
    "content": "import React from 'react';\nimport PropTypes from 'prop-types';\n\nconst MAX = 100;\nconst STEP = 5;\n\nconst Slider = ({ currentVolume, setTargetVolume }) => {\n  const handleChange = event => {\n    let { value } = event.target;\n    setTargetVolume(value / MAX);\n  };\n\n  const sliderVal = currentVolume * MAX;\n\n  return (\n    <div className='slider-container'>\n      <input\n        aria-label='volume'\n        className='slider'\n        id='volume-input'\n        max={MAX}\n        min='0'\n        onChange={handleChange}\n        step={STEP}\n        type='range'\n        value={sliderVal}\n      />\n    </div>\n  );\n};\n\nSlider.propTypes = {\n  currentVolume: PropTypes.number,\n  setTargetVolume: PropTypes.func\n};\n\nexport default Slider;\n"
  },
  {
    "path": "src/components/SongHistory.js",
    "content": "import React, { Component } from 'react';\nimport PropTypes from 'prop-types';\nimport { FontAwesomeIcon } from '@fortawesome/react-fontawesome';\n\nimport { faHistory } from '@fortawesome/free-solid-svg-icons';\n\nconst DEFAULT_ART =\n  'https://cdn-media-1.freecodecamp.org/code-radio/cover_placeholder.gif';\nclass SongHistory extends Component {\n  constructor(props) {\n    super(props);\n    this.state = {\n      displayList: false\n    };\n  }\n\n  toggleDisplay = () => {\n    this.setState({\n      displayList: !this.state.displayList\n    });\n  };\n\n  render() {\n    const { songHistory, fastConnection } = this.props;\n    // Don't reverse song list, we want most recent song first.\n    const songs = songHistory.map(song => song.song);\n    return (\n      <>\n        <button\n          aria-controls='song-history'\n          aria-expanded={this.state.displayList}\n          aria-label='Recent Song History'\n          className='recent-song-history'\n          id='recent-song-history'\n          onClick={this.toggleDisplay}\n        >\n          <FontAwesomeIcon\n            aria-hidden='true'\n            className='recently-played-icon'\n            icon={faHistory}\n          />\n        </button>\n        <ol\n          aria-hidden={!this.state.displayList}\n          className='recent-song-list'\n          id='song-history'\n        >\n          {songs.map((song, index) => (\n            <li className='recent-song-info' key={song.id}>\n              <img\n                alt=''\n                role='presentation'\n                src={fastConnection ? song.art : DEFAULT_ART}\n              />\n              <p className='recent-song-meta'>\n                <span>\n                  <span className='sr-only'>Song {index + 1}:</span>\n                  {song.title}\n                </span>\n                <span>\n                  <span className='sr-only'>, Artist:</span> {song.artist}\n                </span>\n                <span>\n                  <span className='sr-only'>, Album:</span> {song.album}\n                </span>\n              </p>\n            </li>\n          ))}\n        </ol>\n      </>\n    );\n  }\n}\n\nSongHistory.propTypes = {\n  fastConnection: PropTypes.bool,\n  songHistory: PropTypes.array\n};\n\nexport default SongHistory;\n"
  },
  {
    "path": "src/components/Visualizer.js",
    "content": "import React from 'react';\nimport PropTypes from 'prop-types';\nimport PageVisibility from 'react-page-visibility';\n\nconst DELAY = 500;\n\nexport default class Visualizer extends React.PureComponent {\n  rafId = null;\n  timerId = null;\n\n  constructor(props) {\n    super(props);\n    this.state = {\n      eq: {},\n      config: {\n        baseColour: 'rgb(10, 10, 35)',\n        translucent: 'rgba(10, 10, 35, 0.6)',\n        multiplier: 0.7529\n      },\n      isTabVisible: true\n    };\n    this.timeoutId = null;\n  }\n\n  /**\n   * In order to get around some mobile browser limitations,\n   * we can only generate a lot of the audio context stuff AFTER\n   * the audio has been triggered.\n   * We can't see it until then anyway so it makes no difference to desktop.\n   */\n  componentDidUpdate(prevProps, prevState) {\n    if (\n      prevProps.playing === this.props.playing &&\n      prevState.isTabVisible === this.state.isTabVisible\n    ) {\n      return;\n    }\n\n    /**\n     * If the player is playing and the tab is being active,\n     * draw the visualization.\n     */\n    if (this.props.playing && this.state.isTabVisible) {\n      // Create a new audio context if there isn't one available\n      if (!this.state.eq.context) {\n        this.initiateEQ();\n      }\n      this.createVisualizer();\n      this.startDrawing();\n    } else {\n      /**\n       * If the player is not playing or the tab is running in the background,\n       * stop the animation.\n       */\n\n      /**\n       * Workaround for componentWillUnmount to delay the clean up and\n       * achieve fadeout animation.\n       */\n      this.timeoutId = setTimeout(() => {\n        // Note: Order matters.\n        // Stop the drawing loop first (using this.rafId), then set the ID to null\n        this.stopDrawing();\n        this.reset();\n      }, DELAY);\n    }\n  }\n\n  componentWillUnmount() {\n    if (this.timeoutId) {\n      clearTimeout(this.timeoutId);\n    }\n  }\n\n  initiateEQ() {\n    let eq = this.state.eq;\n    // Safari requires a webkit prefix to support AudioContext\n    const AudioContext = window.AudioContext || window.webkitAudioContext;\n    // Create a new Audio Context element to read the samples from\n    eq.context = new AudioContext();\n    // Apply the audio element as the source where to pull all the data from\n    eq.src = eq.context.createMediaElementSource(this.props.player);\n\n    /**\n     * Use some amazing trickery that allows javascript to\n     * analyse the current state.\n     */\n    eq.analyser = eq.context.createAnalyser();\n    eq.src.connect(eq.analyser);\n    eq.analyser.connect(eq.context.destination);\n    eq.analyser.fftSize = 256;\n\n    /**\n     * Create a buffer array for the number of frequencies available\n     * (minus the high pitch useless ones that never really do anything anyway).\n     */\n    eq.bands = new Uint8Array(eq.analyser.frequencyBinCount - 32);\n\n    this.setState({ eq });\n  }\n\n  reset = () => {\n    this.rafId = null;\n  };\n\n  /**\n   * The equalizer bands available need to be updated\n   * constantly in order to ensure that the value for any\n   * visualizer is up to date.\n   */\n  updateEQBands() {\n    const newEQ = this.state.eq;\n    // Populate the buffer with the audio source's current data\n    newEQ.analyser.getByteFrequencyData(newEQ.bands);\n\n    this.setState({ eq: { ...newEQ } });\n  }\n\n  /**\n   * When starting the page, the visualizer dom is needed to be\n   * created.\n   */\n  createVisualizer() {\n    this._canvas.width = this._canvas.parentNode.offsetWidth;\n    this._canvas.height = this._canvas.parentNode.offsetHeight;\n\n    this.visualizer = {\n      ctx: this._canvas.getContext('2d'),\n      height: this._canvas.height,\n      width: this._canvas.width,\n      barWidth: this._canvas.width / this.state.eq.bands.length\n    };\n  }\n\n  startDrawing = () => {\n    if (!this.rafId) {\n      this.rafId = window.requestAnimationFrame(this.drawingLoop);\n    }\n  };\n\n  stopDrawing = () => {\n    window.cancelAnimationFrame(this.rafId);\n    clearTimeout(this.timerId);\n  };\n\n  drawingLoop = () => {\n    const haveWaveform = this.state.eq.bands.reduce((a, b) => a + b, 0) !== 0;\n\n    this.updateEQBands();\n    this.drawVisualizer();\n\n    /**\n     * Because timeupdate events are not triggered at browser speed,\n     * we use requestanimationframe for higher framerates\n     */\n    if (haveWaveform) {\n      this.rafId = window.requestAnimationFrame(this.drawingLoop);\n    }\n    // If there is no music or audio in the song, then reduce the FPS\n    else {\n      this.timerId = setTimeout(this.drawingLoop, 250);\n    }\n  };\n\n  /**\n   * As a base visualizer, the equalizer bands are drawn using\n   * canvas in the window directly above the song into.\n   */\n  drawVisualizer() {\n    // Initial bar x coordinate\n    let y,\n      x = 0;\n\n    // Clear the complete canvas\n    this.visualizer.ctx.clearRect(\n      0,\n      0,\n      this.visualizer.width,\n      this.visualizer.height\n    );\n    /**\n     * Set the primary colour of the brand\n     * (probably moving to a higher object level variable soon)\n     * Start creating a canvas polygon\n     */\n    this.visualizer.ctx.beginPath();\n    // Start at the bottom left\n    this.visualizer.ctx.moveTo(x, 0);\n    this.visualizer.ctx.fillStyle = this.state.config.translucent;\n    this.state.eq.bands.forEach(band => {\n      /**\n       * Get the overall hight associated to the current band and\n       * convert that into a Y position on the canvas\n       */\n      y = this.state.config.multiplier * band;\n      // Draw a line from the current position to the wherever the Y position is\n      this.visualizer.ctx.lineTo(x, y);\n      /**\n       * Continue that line to meet the width of the bars\n       * (canvas width ÷ bar count).\n       */\n      this.visualizer.ctx.lineTo(x + this.visualizer.barWidth, y);\n      // Add pixels to the x for the next bar\n      x += this.visualizer.barWidth;\n    });\n    // Bring the line back down to the bottom of the canvas\n    this.visualizer.ctx.lineTo(x, 0);\n    // Fill it\n    this.visualizer.ctx.fill();\n  }\n\n  handleVisibilityChange = isTabVisible => {\n    this.setState({ isTabVisible });\n  };\n\n  render() {\n    return (\n      <PageVisibility onChange={this.handleVisibilityChange}>\n        <div className='visualizer'>\n          <canvas aria-label='visualizer' ref={a => (this._canvas = a)} />\n        </div>\n      </PageVisibility>\n    );\n  }\n}\n\nVisualizer.propTypes = {\n  player: PropTypes.object,\n  playing: PropTypes.bool\n};\n"
  },
  {
    "path": "src/css/App.css",
    "content": "@import url('https://fonts.googleapis.com/css?family=Lato:400,700&display=swap');\n\n/* Globals */\n\n:root {\n  --focus-outline: #0044FF;\n}\n\n* {\n  box-sizing: border-box;\n  font-family: 'Lato', Arial;\n}\n\nbody {\n  background-color: #1b1b32;\n  background-position: center center;\n  background-size: cover;\n  color: #fff;\n  font-family: 'Open Sans', sans-serif;\n  margin: 0;\n  overflow: hidden;\n  padding: 0;\n}\n\nhtml,\nbody,\n#root,\nmain {\n  height: 100%;\n}\n\na {\n  text-decoration: none;\n}\n\nh1,\nh2 {\n  margin: 0;\n}\n\np {\n  margin: 0;\n}\n\n/* App */\n\n.App {\n  height: 100%;\n}\n\n/* Main */\n\n.animation {\n  display: none;\n}\n\n@media only screen and (min-width: 768px) {\n  .animation {\n    display: block;\n    height: calc(100% - 158px);\n    margin-top: 88px;\n    position: relative;\n    width: 100%;\n  }\n}\n\n.animation video {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  object-position: left;\n}\n\n.site-title {\n  font-size: 30px;\n}\n\n@media only screen and (min-width: 768px) {\n  .site-title {\n    font-size: 18px;\n  }\n}\n\n.site-description {\n  font-size: 18px;\n  margin-top: 18px;\n}\n\n@media only screen and (min-width: 768px) {\n  .site-description {\n    margin-left: 10px;\n    margin-top: 0;\n  }\n}\n\n.under-header-content {\n  background-color: #0a0a23;\n  display: flex;\n  flex-direction: column;\n  height: 200px;\n  justify-content: center;\n  padding: 0 20px;\n  position: absolute;\n  text-align: center;\n  top: 38px;\n  width: 100%;\n}\n\n@media only screen and (min-width: 768px) {\n  .under-header-content {\n    align-items: center;\n    flex-direction: row;\n    height: 50px;\n    z-index: 1;\n  }\n}\n\n/* Slider */\n\n.slider-container {\n  height: 100%;\n  padding: 22px 20px 22px 15px;\n  width: 140px;\n}\n\n.slider {\n  appearance: none;\n  -webkit-appearance: none;\n  background: #dfdfe2;\n  height: 6px;\n  width: 100%;\n}\n\n.slider::-webkit-slider-thumb {\n  -webkit-appearance: none;\n  appearance: none;\n  background: #fff;\n  cursor: pointer;\n  height: 15px;\n  width: 15px;\n}\n\n.slider::-moz-range-thumb {\n  background: #fff;\n  cursor: pointer;\n  height: 15px;\n  width: 15px;\n}\n\n/* Current Song */\n\n[data-meta='title'] {\n  font-size: 18px;\n}\n\n[data-meta='artist'] {\n  font-size: 14px;\n}\n\n[data-meta='album'] {\n  font-size: 12px;\n}\n\n[data-meta='listeners'] {\n  bottom: 20px;\n  font-size: 20px;\n  font-weight: 900;\n  left: 10px;\n  position: absolute;\n}\n\n@media only screen and (min-width: 768px) {\n  [data-meta='listeners'] {\n    bottom: auto;\n    left: auto;\n    top: 2px;\n    right: 10px;\n  }\n}\n\n[data-meta='picture'] {\n  bottom: 212px;\n  height: 200px;\n  left: 50%;\n  margin-left: -100px;\n  position: absolute;\n  width: 200px;\n}\n\n@media only screen and (min-width: 768px) {\n  [data-meta='picture'] {\n    bottom: auto;\n    height: 70px;\n    left: auto;\n    margin-left: 0;\n    width: 70px;\n  }\n}\n\n@media (max-height: 600px) {\n  [data-meta='picture'] {\n    display: none;\n  }\n}\n\n.now-playing {\n  align-content: center;\n  background-color: #1b1b32;\n  bottom: 70px;\n  display: flex;\n  flex-direction: column;\n  height: 120px;\n  left: 0;\n  padding: 10px;\n  position: absolute;\n  width: 100vw;\n}\n\n@media only screen and (min-width: 768px) {\n  .now-playing {\n    background-color: #0a0a23;\n    bottom: auto;\n    height: 100%;\n    left: 70px;\n    padding-bottom: 2px;\n    padding-top: 2px;\n    position: relative;\n    width: calc(100% - 70px);\n  }\n}\n\n.progress-container {\n  border-radius: 4px;\n  bottom: 10px;\n  height: 4px;\n  left: 10px;\n  overflow: hidden;\n  position: absolute;\n  right: 10px;\n}\n\n@media only screen and (min-width: 768px) {\n  .progress-container {\n    bottom: 5px;\n  }\n}\n\n.now-playing progress {\n  -moz-appearance: none;\n  -webkit-appearance: none;\n  appearance: none;\n  background-color: #3b3b4f;\n  height: 4px;\n  left: 0;\n  position: absolute;\n  top: 0;\n  width: 100%;\n  border: none;\n}\n\n.now-playing progress::-webkit-progress-bar {\n  background-color: #3b3b4f;\n}\n\n.now-playing progress::-webkit-progress-value {\n  background-color: #fff;\n}\n\n.meta-display {\n  opacity: 0;\n  transition: opacity 0.5s ease-out;\n  width: calc(100% - 210px);\n}\n\n.meta-display-visible {\n  opacity: 1;\n}\n\n/* Visualizer */\n\n.visualizer {\n  background-color: #002ead;\n  height: 100%;\n  pointer-events: none;\n  position: absolute;\n  top: 238px;\n  width: 100%;\n  z-index: 5;\n}\n\n@media only screen and (min-width: 768px) {\n  .visualizer {\n    background-color: transparent;\n    top: 88px;\n  }\n}\n\n.visualizer canvas {\n  height: 100%;\n  width: 100%;\n}\n\n/* Song History */\n\n.recent-song-history {\n  background-color: transparent;\n  border: 0;\n  cursor: pointer;\n  height: 100%;\n  padding: 0;\n  text-align: center;\n  width: 70px;\n  z-index: 10;\n}\n\n.recent-song-history:hover,\n.recent-song-history:active {\n  background-color: #1b1b32;\n}\n\n.recent-song-history .recently-played-icon {\n  color: #fff;\n  height: 60%;\n  width: 60%;\n}\n\n.loader-circle-notch{\n  height: 60%;\n  width: auto;\n  align-self: center;\n}\n\n.recent-song-list {\n  background-color: #1b1b32;\n  bottom: 70px;\n  color: #fff;\n  cursor: default;\n  display: none;\n  left: 0;\n  list-style-type: none;\n  margin: 0;\n  padding-left: 0;\n  position: absolute;\n  width: 100%;\n}\n\n.recent-song-list[aria-hidden=\"false\"] {\n  display: flex;\n  flex-direction: column-reverse;\n}\n\n@media only screen and (min-width: 768px) {\n  .recent-song-list {\n    bottom: 73px;\n    width: 350px;\n  }\n}\n\n.recently-played-icon {\n  font-weight: bold;\n  justify-content: center;\n}\n\n.recent-song-info {\n  border-bottom: 1px solid #3b3b4f;\n  display: flex;\n  justify-content: flex-start;\n  padding: 10px;\n}\n\n.recent-song-meta {\n  font-size: 16px;\n  margin-left: 10px;\n  overflow: hidden;\n  text-align: left;\n}\n\n.recent-song-meta p {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.recent-song-meta span:first-of-type {\n  font-weight: bold;\n}\n\n.recent-song-meta span {\n  display: block;\n}\n\n.recent-song-info:nth-child(1) {\n  border-bottom: none;\n}\n\n.recent-song-info img {\n  float: left;\n  height: 50px;\n  width: 50px;\n}\n\n/* Details */\n\ndetails {\n  display: none;\n}\n\n@media only screen and (min-width: 768px) {\n  details {\n    background-color: rgba(10, 10, 35, 0.6); /* #0a0a23 */\n    bottom: 100px;\n    color: #fff;\n    display: block;\n    opacity: 0.1;\n    padding: 15px;\n    position: absolute;\n    left: 50px;\n    transition: opacity 0.25s ease-out;\n  }\n}\n\ndetails:hover,\ndetails[open] {\n  opacity: 1;\n}\n\ndl {\n  margin-bottom: 0;\n}\n\ndt {\n  margin-top: 10px;\n}\n\ndd {\n  font-style: italic;\n  margin-left: 10px;\n}\n\n/* Footer */\n\n[data-meta='stream-select'] {\n  -moz-appearance: none;\n  -webkit-appearance: none;\n  appearance: none;\n  background-color: #fff;\n  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');\n  background-position: right 0.7em top 50%, 0 0;\n  background-repeat: no-repeat;\n  background-size: 0.65em auto, 100%;\n  border-radius: 0;\n  border: none;\n  bottom: 90px;\n  font-size: 12px;\n  padding: 2px 20px 2px 2px;\n  position: absolute;\n  right: 10px;\n}\n\n@media only screen and (min-width: 768px) {\n  [data-meta='stream-select'] {\n    bottom: 16px;\n  }\n}\n\nfooter {\n  background-color: #0a0a23;\n  bottom: 0;\n  display: flex;\n  height: 70px;\n  position: absolute;\n  width: 100%;\n  z-index: 10;\n}\n\n@media only screen and (min-width: 768px) {\n  footer {\n    border-top: 3px solid #fff;\n    box-sizing: content-box;\n  }\n}\n\n/* Play Pause Button */\n\n.play-container {\n  background-color: transparent;\n  border: 0;\n  color: #fff;\n  cursor: pointer;\n  height: 100%;\n  left: 50%;\n  margin-left: -35px;\n  padding: 15px;\n  position: absolute;\n  width: 70px;\n}\n\n.play-container:hover,\n.play-container:active {\n  background-color: #1b1b32;\n}\n\n@media only screen and (min-width: 768px) {\n  .play-container {\n    left: auto;\n    margin-left: 0;\n    position: relative;\n  }\n}\n\n.play-container::before {\n  background-color: #1b1b32;\n  background-image: url(https://cdn-media-1.freecodecamp.org/code-radio/cta.png);\n  background-position: 150px center;\n  background-repeat: no-repeat;\n  border-radius: 5px;\n  box-sizing: border-box;\n  content: 'Push Play or Space Bar to Start Music';\n  display: none;\n  font-size: 21px;\n  font-weight: bold;\n  height: 100px;\n  left: -170px;\n  opacity: 0;\n  padding: 10px 100px 10px 20px;\n  pointer-events: none;\n  position: absolute;\n  top: -110px;\n  transition: opacity 0.5s ease-out;\n  width: 250px;\n}\n\n@media only screen and (min-width: 768px) {\n  .play-container::before {\n    display: block;\n  }\n}\n\n.play-container-cta::before {\n  opacity: 1;\n}\n\n.play-container-cta:hover {\n  background: #2a2a40;\n}\n\n/* Nav */\n\n.site-nav {\n  align-items: flex-start;\n  background: #0a0a23;\n  display: flex;\n  font-family: 'Lato', sans-serif;\n  font-size: 18px;\n  height: 38px;\n  justify-content: space-between;\n  overflow-y: hidden;\n  padding: 0 0;\n  position: fixed;\n  top: 0;\n  width: 100%;\n  z-index: 1000;\n}\n\n@media only screen and (min-width: 350px) {\n  .site-nav {\n    padding: 0 15px;\n  }\n}\n\n.site-nav-middle {\n  padding-top: 7px;\n}\n\n@media only screen and (min-width: 768px) {\n  .site-nav-middle {\n    flex: 1 0 30%;\n    margin-right: 0;\n    text-align: center;\n  }\n}\n\n.site-nav-left {\n  display: none;\n}\n\n@media only screen and (min-width: 768px) {\n  .site-nav-left {\n    display: flex;\n    flex: 1 0 30%;\n    margin-left: 0;\n  }\n}\n\n.site-nav-logo {\n  background-image: url(https://cdn.freecodecamp.org/platform/universal/fcc_primary.svg);\n  background-position: -179px 0;\n  background-repeat: no-repeat;\n  background-size: cover;\n  color: #fff;\n  display: inline-block;\n  flex-shrink: 0;\n  font-size: 1.7rem;\n  font-weight: bold;\n  height: 25px;\n  letter-spacing: -0.5px;\n  line-height: 1em;\n  margin: -1px 5px 0;\n  width: 43px;\n}\n\n@media only screen and (min-width: 350px) {\n  .site-nav-logo {\n    background-image: none;\n    height: unset;\n    padding-top: 1px;\n    width: unset;\n  }\n}\n\n@media only screen and (min-width: 768px) {\n  .site-nav-logo {\n    margin-left: auto;\n    margin-right: auto;\n  }\n}\n\n.site-nav-logo:focus {\n  outline: 2px solid var(--focus-outline);\n  outline-offset: 3px;\n}\n\n.site-nav-logo:focus:not(:focus-visible) {\n  outline: none;\n}\n\n.site-nav-logo:hover {\n  text-decoration: none;\n}\n\n.site-nav-logo img {\n  display: none;\n  height: 25px;\n  width: auto;\n}\n\n@media only screen and (min-width: 350px) {\n  .site-nav-logo img {\n    display: block;\n  }\n}\n\n.site-nav-right {\n  align-items: center;\n  display: flex;\n  flex-shrink: 0;\n  height: 38px;\n}\n\n@media only screen and (min-width: 768px) {\n  .site-nav-right {\n    flex: 1 0 30%;\n    margin-left: auto;\n  }\n}\n\n@media only screen and (min-width: 768px) {\n  .main-nav-group {\n    margin-left: auto;\n  }\n}\n\n.nav {\n  background-color: #0a0a23;\n  display: none;\n  left: 15px;\n  list-style: none;\n  margin: 0 0 0 -12px;\n  padding: 0;\n  position: absolute;\n  top: 38px;\n}\n\n@media only screen and (min-width: 518px) {\n  .nav {\n    align-items: center;\n    display: flex;\n    left: auto;\n    margin-top: 1px;\n    position: relative;\n    top: 0;\n  }\n}\n\n.nav {\n  height: 38px;\n}\n\n.nav li {\n  display: block;\n  margin: 0;\n  padding: 0;\n  height: 100%;\n}\n\n.nav li a {\n  align-items: center;\n  color: #fff;\n  display: flex;\n  height: 100%;\n  margin: 0;\n  opacity: 1;\n  padding: 0 10px;\n  white-space: nowrap;\n}\n\n.nav li a span {\n  display: flex;\n  align-items: center;\n  gap: 0.15rem;\n  margin-top: -1px;\n}\n\n.nav li a svg {\n  fill: #fff;\n  height: 1rem;\n  width: 1rem;\n  margin-left: 0.15rem;\n}\n\n.nav li a:focus {\n  outline: 2px solid var(--focus-outline);\n  outline-offset: -3px;\n}\n\n.nav li a:focus:not(:focus-visible) {\n  outline: none;\n}\n\n.nav li:hover {\n  background: #fff;\n}\n\n.nav li a:hover {\n  color: #0a0a23;\n  text-decoration: none;\n}\n\n.nav li a:hover svg {\n  fill: #0a0a23;\n}\n\n.toggle-button-nav {\n  background-color: #0a0a23;\n  border: 1px solid #fff;\n  color: #fff;\n  cursor: pointer;\n  display: flex;\n  font-family: 'lato', sans-serif;\n  font-size: 18px;\n  height: auto;\n  margin-right: 5px;\n  outline: 0;\n  padding: 2px 14px 3px;\n}\n\n.toggle-button-nav:focus {\n  outline: 2px solid var(--focus-outline);\n  outline-offset: 2px;\n}\n\n.toggle-button-nav:focus:not(:focus-visible) {\n  outline: none;\n}\n\n@media only screen and (min-width: 518px) {\n  .toggle-button-nav {\n    display: none;\n  }\n}\n\n.show-main-nav-items {\n  display: flex;\n}\n\n.expand-nav {\n  min-height: calc(40px + 38px);\n}\n\n@media only screen and (min-width: 768px) {\n  .expand-nav {\n    min-height: 38px;\n  }\n}\n\n.reverse-toggle-color {\n  background-color: #fff;\n  color: #0a0a23;\n}\n"
  },
  {
    "path": "src/index.js",
    "content": "import React from 'react';\nimport ReactDOM from 'react-dom';\nimport * as Sentry from '@sentry/react';\nimport { Integrations } from '@sentry/tracing';\nimport App from './components/App';\n\nSentry.init({\n  dsn: process.env.REACT_APP_SENTRY_DSN,\n  integrations: [new Integrations.BrowserTracing()],\n\n  // We recommend adjusting this value in production, or using tracesSampler\n  // for finer control\n  tracesSampleRate: 1.0\n});\n\nReactDOM.render(<App />, document.getElementById('root'));\n"
  },
  {
    "path": "src/setupTests.js",
    "content": "import '@testing-library/jest-dom';\n\nObject.defineProperty(window, 'EventSource', {\n  writable: true,\n  value: jest.fn().mockImplementation(() => ({\n    CLOSED: 0,\n    CONNECTING: 0,\n    OPEN: 0,\n    dispatchEvent(event) {\n      return false;\n    },\n    onerror: jest.fn(),\n    onmessage: jest.fn(),\n    onopen: jest.fn(),\n    readyState: 0,\n    url: '',\n    withCredentials: false,\n    addEventListener: jest.fn(),\n    close: jest.fn(),\n    removeEventListener: jest.fn()\n  }))\n});\n"
  },
  {
    "path": "src/utils/buildEventSource.js",
    "content": "export const buildEventSource = url => {\n  return new EventSource(url);\n};\n"
  }
]