25
.gitignore
vendido
Archivo normal
@@ -0,0 +1,25 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
*.lock
|
||||||
|
*-lock.json
|
||||||
3
README.md
Archivo normal
@@ -0,0 +1,3 @@
|
|||||||
|
# 📻 Stream Radio
|
||||||
|
|
||||||
|
## A simple radio streaming react app
|
||||||
43
package.json
Archivo normal
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "stream-radio",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"materialize-css": "^1.0.0",
|
||||||
|
"prop-types": "^15.8.1",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-materialize": "^3.10.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"sass": "^1.89.0",
|
||||||
|
"web-vitals": "^2.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"react-app/jest"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/favicon.ico
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.2 KiB |
48
public/index.html
Archivo normal
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Stream Radio is a web application that allows you to listen to your favorite radio stations online."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="Stream Radio, radio, online radio, music, streaming"
|
||||||
|
/>
|
||||||
|
<meta name="author" content="manalejandro.com" />
|
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
<title>Stream Radio</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/logo192.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 7.4 KiB |
BIN
public/logo512.png
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 12 KiB |
26
public/manifest.json
Archivo normal
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Stream Radio",
|
||||||
|
"name": "Stream Radio",
|
||||||
|
"description": "A simple radio streaming app",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo192.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "192x192"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "logo512.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "512x512"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
3
public/robots.txt
Archivo normal
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
BIN
public/wallpapers/pexels-baskincreativeco-1480807.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 5.0 MiB |
BIN
public/wallpapers/pexels-belle-co-99483-847393.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.0 MiB |
BIN
public/wallpapers/pexels-bess-hamiti-83687-36487.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 109 KiB |
BIN
public/wallpapers/pexels-carlos-oliva-1966452-3586966.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 991 KiB |
BIN
public/wallpapers/pexels-christian-heitz-285904-842711.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.8 MiB |
BIN
public/wallpapers/pexels-dreamypixel-547115.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.3 MiB |
BIN
public/wallpapers/pexels-eberhardgross-1062249.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.9 MiB |
BIN
public/wallpapers/pexels-eberhardgross-1287075.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 478 KiB |
BIN
public/wallpapers/pexels-eberhardgross-1287089.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.4 MiB |
BIN
public/wallpapers/pexels-eberhardgross-1301976.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 540 KiB |
BIN
public/wallpapers/pexels-eberhardgross-1367192(1).jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 746 KiB |
BIN
public/wallpapers/pexels-eberhardgross-1367192.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 746 KiB |
BIN
public/wallpapers/pexels-eberhardgross-1612351.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.6 MiB |
BIN
public/wallpapers/pexels-eberhardgross-1612360.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 912 KiB |
BIN
public/wallpapers/pexels-eberhardgross-1612362.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 749 KiB |
BIN
public/wallpapers/pexels-eberhardgross-1612371.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.2 MiB |
BIN
public/wallpapers/pexels-eberhardgross-1624255.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 929 KiB |
BIN
public/wallpapers/pexels-eberhardgross-534164.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.8 MiB |
BIN
public/wallpapers/pexels-eberhardgross-629167.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 883 KiB |
BIN
public/wallpapers/pexels-eberhardgross-691668.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.8 MiB |
BIN
public/wallpapers/pexels-eberhardgross-707344.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 762 KiB |
BIN
public/wallpapers/pexels-eberhardgross-730981.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.2 MiB |
BIN
public/wallpapers/pexels-esan-2085998.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.5 MiB |
BIN
public/wallpapers/pexels-fotios-photos-109260.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.3 MiB |
BIN
public/wallpapers/pexels-francesco-ungaro-1525041.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.1 MiB |
BIN
public/wallpapers/pexels-gochrisgoxyz-1477166.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 665 KiB |
BIN
public/wallpapers/pexels-johnnoibn-1448136.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.0 MiB |
BIN
public/wallpapers/pexels-joshkjack-135018.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.8 MiB |
BIN
public/wallpapers/pexels-jplenio-1110656.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.2 MiB |
BIN
public/wallpapers/pexels-jplenio-1146708.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.4 MiB |
BIN
public/wallpapers/pexels-jplenio-1435075.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 562 KiB |
BIN
public/wallpapers/pexels-kasperphotography-1042423.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 899 KiB |
BIN
public/wallpapers/pexels-katja-79053-592077.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.0 MiB |
BIN
public/wallpapers/pexels-lastly-937782.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 573 KiB |
BIN
public/wallpapers/pexels-lilartsy-1213447.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.6 MiB |
BIN
public/wallpapers/pexels-maxfrancis-2246476.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.4 MiB |
BIN
public/wallpapers/pexels-mdsnmdsnmdsn-1831234.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 100 KiB |
BIN
public/wallpapers/pexels-mdx014-814499.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.5 MiB |
BIN
public/wallpapers/pexels-michal-pech-213601-1632044.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.4 MiB |
BIN
public/wallpapers/pexels-no-name-14543-66997.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 849 KiB |
BIN
public/wallpapers/pexels-pixabay-158063.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 636 KiB |
BIN
public/wallpapers/pexels-pixabay-33109(1).jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.3 MiB |
BIN
public/wallpapers/pexels-pixabay-33109.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.3 MiB |
BIN
public/wallpapers/pexels-pixabay-33545.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 652 KiB |
BIN
public/wallpapers/pexels-pixabay-358532.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.5 MiB |
BIN
public/wallpapers/pexels-pixabay-41004.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.3 MiB |
BIN
public/wallpapers/pexels-pixabay-414144.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 411 KiB |
BIN
public/wallpapers/pexels-pixabay-416160.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.4 MiB |
BIN
public/wallpapers/pexels-pixabay-459203.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.5 MiB |
BIN
public/wallpapers/pexels-pixabay-462162.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 376 KiB |
BIN
public/wallpapers/pexels-pixabay-50594.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.4 MiB |
BIN
public/wallpapers/pexels-pixabay-50686.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 227 KiB |
BIN
public/wallpapers/pexels-pixabay-52500.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 467 KiB |
BIN
public/wallpapers/pexels-rpnickson-2559941.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.6 MiB |
BIN
public/wallpapers/pexels-rpnickson-2647990.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 976 KiB |
BIN
public/wallpapers/pexels-samandgos-709552.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.2 MiB |
BIN
public/wallpapers/pexels-samkolder-2387873.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.6 MiB |
BIN
public/wallpapers/pexels-sebastian-312105.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.3 MiB |
BIN
public/wallpapers/pexels-simon73-1183099.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 3.2 MiB |
BIN
public/wallpapers/pexels-souvenirpixels-1519088.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 475 KiB |
BIN
public/wallpapers/pexels-souvenirpixels-417074.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.2 MiB |
BIN
public/wallpapers/pexels-stefanstefancik-919606.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 2.9 MiB |
BIN
public/wallpapers/pexels-stywo-1054289.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.5 MiB |
BIN
public/wallpapers/pexels-stywo-1668246.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.1 MiB |
BIN
public/wallpapers/pexels-therato-1933239.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 1.1 MiB |
BIN
public/wallpapers/pexels-todd-trapani-488382-1198817.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 4.9 MiB |
BIN
public/wallpapers/pexels-umaraffan499-21787.jpg
Archivo normal
|
Después Anchura: | Altura: | Tamaño: 686 KiB |
115
src/App.css
Archivo normal
@@ -0,0 +1,115 @@
|
|||||||
|
body {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
background-position: center center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding-top: 16vh;
|
||||||
|
font-size: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 a:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transition: 0.3s;
|
||||||
|
width: 85%;
|
||||||
|
font-size: x-large;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4:hover {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bounce a {
|
||||||
|
border-bottom: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bounce {
|
||||||
|
animation: marquee 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bounce:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
a:visited,
|
||||||
|
a:active,
|
||||||
|
a:hover {
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 0 5px #000;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marquee {
|
||||||
|
0% {
|
||||||
|
transform: translateX(100vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
audio {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player {
|
||||||
|
width: 26rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"],
|
||||||
|
input[type="range"]::-moz-range-thumb,
|
||||||
|
input[type="range"]::-webkit-slider-runnable-track,
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb,
|
||||||
|
input[type="range"]::-webkit-slider-thumb,
|
||||||
|
input[type=range]+.thumb,
|
||||||
|
.thumb {
|
||||||
|
background-color: #795548 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"],
|
||||||
|
input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
background-color: #4e342e !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range-field {
|
||||||
|
vertical-align: middle;;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: inline-block;
|
||||||
|
width: 6rem !important;
|
||||||
|
border: 0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
114
src/App.js
Archivo normal
@@ -0,0 +1,114 @@
|
|||||||
|
import "./App.css";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import WaveForm from "./WaveForm";
|
||||||
|
import M from "materialize-css";
|
||||||
|
import text from "./list.txt";
|
||||||
|
|
||||||
|
// Custom hooks
|
||||||
|
import useAudioPlayer from "./hooks/useAudioPlayer";
|
||||||
|
import useStreamData from "./hooks/useStreamData";
|
||||||
|
import useBackgroundImages from "./hooks/useBackgroundImages";
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import Header from "./components/Header";
|
||||||
|
import TrackInfo from "./components/TrackInfo";
|
||||||
|
import AudioControls from "./components/AudioControls";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main App component
|
||||||
|
*/
|
||||||
|
const App = () => {
|
||||||
|
// Custom hook for managing background images
|
||||||
|
const { loadImages } = useBackgroundImages(text);
|
||||||
|
|
||||||
|
// Custom hook for streaming data
|
||||||
|
const {
|
||||||
|
currentListeners,
|
||||||
|
maxListeners,
|
||||||
|
title,
|
||||||
|
link,
|
||||||
|
trackInfo,
|
||||||
|
loadAllData
|
||||||
|
} = useStreamData();
|
||||||
|
|
||||||
|
// Custom hook for audio player functionality
|
||||||
|
const {
|
||||||
|
audioElmRef,
|
||||||
|
analyzerData,
|
||||||
|
currentVolume,
|
||||||
|
muted,
|
||||||
|
paused,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
toggleMute
|
||||||
|
} = useAudioPlayer();
|
||||||
|
|
||||||
|
// Initialization flag to prevent multiple initializations
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
// Initialize app and setup periodic data refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialized.current) return;
|
||||||
|
initialized.current = true;
|
||||||
|
|
||||||
|
// Initialize Materialize components
|
||||||
|
M.AutoInit();
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadAllData();
|
||||||
|
loadImages();
|
||||||
|
|
||||||
|
// Set up periodic data refresh
|
||||||
|
const dataRefreshInterval = setInterval(() => {
|
||||||
|
loadAllData();
|
||||||
|
}, (Math.floor(Math.random() * 20) + 10) * 1000);
|
||||||
|
|
||||||
|
// Set up periodic background image refresh
|
||||||
|
const imageRefreshInterval = setInterval(() => {
|
||||||
|
loadImages();
|
||||||
|
}, (Math.floor(Math.random() * 60) + 90) * 1000);
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
clearInterval(dataRefreshInterval);
|
||||||
|
clearInterval(imageRefreshInterval);
|
||||||
|
};
|
||||||
|
}, [loadAllData, loadImages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header repoUrl="https://git.manalejandro.com/ale/stream-radio" />
|
||||||
|
|
||||||
|
{analyzerData && <WaveForm analyzerData={analyzerData} />}
|
||||||
|
|
||||||
|
<TrackInfo
|
||||||
|
title={title}
|
||||||
|
link={link}
|
||||||
|
trackInfo={trackInfo}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<br /><br />
|
||||||
|
|
||||||
|
<AudioControls
|
||||||
|
paused={paused}
|
||||||
|
muted={muted}
|
||||||
|
currentVolume={currentVolume}
|
||||||
|
currentListeners={currentListeners}
|
||||||
|
maxListeners={maxListeners}
|
||||||
|
onPlay={play}
|
||||||
|
onPause={pause}
|
||||||
|
onToggleMute={toggleMute}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<audio
|
||||||
|
src="/stream.mp3"
|
||||||
|
ref={audioElmRef}
|
||||||
|
preload="none"
|
||||||
|
muted={muted}
|
||||||
|
controls={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
8
src/App.test.js
Archivo normal
@@ -0,0 +1,8 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
test('renders stream radio app', () => {
|
||||||
|
render(<App />);
|
||||||
|
const linkElement = screen.getByText(/stream radio/i);
|
||||||
|
expect(linkElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
69
src/WaveForm.jsx
Archivo normal
@@ -0,0 +1,69 @@
|
|||||||
|
import { useRef, useEffect } from "react";
|
||||||
|
import useSize from "./useSize";
|
||||||
|
|
||||||
|
const BLUE_SHADES = [
|
||||||
|
"rgba(255,255,255,0.5)",
|
||||||
|
"rgba(255,255,255,0.4)",
|
||||||
|
"rgba(255,255,255,0.3)",
|
||||||
|
"rgba(255,255,255,0.2)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const animateBars = (analyser, canvas, ctx, dataArray, bufferLength) => {
|
||||||
|
analyser.getByteFrequencyData(dataArray);
|
||||||
|
const HEIGHT = canvas.height / 2;
|
||||||
|
const barWidth = Math.ceil(canvas.width / bufferLength) * 2.5;
|
||||||
|
let x = 0;
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const barHeight = (dataArray[i] / 255) * HEIGHT;
|
||||||
|
const blueShade = Math.floor((dataArray[i] / 255) * 4);
|
||||||
|
ctx.fillStyle = BLUE_SHADES[blueShade];
|
||||||
|
ctx.fillRect(x, HEIGHT - barHeight, barWidth, barHeight);
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const WaveForm = ({ analyzerData }) => {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const [width, height] = useSize();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || !analyzerData || !analyzerData.analyzer) return;
|
||||||
|
|
||||||
|
const { dataArray, analyzer, bufferLength } = analyzerData;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
let animationId;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(0, canvas.height / 2);
|
||||||
|
animateBars(analyzer, canvas, ctx, dataArray, bufferLength);
|
||||||
|
ctx.restore();
|
||||||
|
animationId = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
|
||||||
|
render();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
};
|
||||||
|
}, [analyzerData, width, height]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
ref={canvasRef}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WaveForm;
|
||||||
95
src/components/AudioControls.js
Archivo normal
@@ -0,0 +1,95 @@
|
|||||||
|
import { Button, Icon, Range } from "react-materialize";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audio player controls component
|
||||||
|
*/
|
||||||
|
const AudioControls = ({
|
||||||
|
paused,
|
||||||
|
muted,
|
||||||
|
currentVolume,
|
||||||
|
currentListeners,
|
||||||
|
maxListeners,
|
||||||
|
onPlay,
|
||||||
|
onPause,
|
||||||
|
onToggleMute
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="brown darken-3 player">
|
||||||
|
{paused ? (
|
||||||
|
<Button
|
||||||
|
node="button"
|
||||||
|
className="brown"
|
||||||
|
onClick={onPlay}
|
||||||
|
waves="light"
|
||||||
|
floating
|
||||||
|
>
|
||||||
|
<Icon>play_arrow</Icon>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
node="button"
|
||||||
|
className="brown"
|
||||||
|
onClick={onPause}
|
||||||
|
waves="light"
|
||||||
|
floating
|
||||||
|
>
|
||||||
|
<Icon>pause</Icon>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{muted ? (
|
||||||
|
<Button
|
||||||
|
node="button"
|
||||||
|
className="brown"
|
||||||
|
onClick={onToggleMute}
|
||||||
|
waves="light"
|
||||||
|
floating
|
||||||
|
>
|
||||||
|
<Icon>volume_off</Icon>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
node="button"
|
||||||
|
className="brown"
|
||||||
|
onClick={onToggleMute}
|
||||||
|
waves="light"
|
||||||
|
floating
|
||||||
|
>
|
||||||
|
<Icon>volume_up</Icon>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Range
|
||||||
|
className="brown waves-light range-field"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={Math.round(currentVolume * 100)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
<Icon tiny>people</Icon> {currentListeners} / {maxListeners}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AudioControls.propTypes = {
|
||||||
|
paused: PropTypes.bool.isRequired,
|
||||||
|
muted: PropTypes.bool.isRequired,
|
||||||
|
currentVolume: PropTypes.number,
|
||||||
|
currentListeners: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||||
|
maxListeners: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
||||||
|
onPlay: PropTypes.func.isRequired,
|
||||||
|
onPause: PropTypes.func.isRequired,
|
||||||
|
onToggleMute: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
AudioControls.defaultProps = {
|
||||||
|
currentVolume: 0.5,
|
||||||
|
currentListeners: 0,
|
||||||
|
maxListeners: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioControls;
|
||||||
31
src/components/Header.js
Archivo normal
@@ -0,0 +1,31 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { Icon } from "react-materialize";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header component with title and link
|
||||||
|
*/
|
||||||
|
const Header = ({ repoUrl }) => {
|
||||||
|
return (
|
||||||
|
<h1>
|
||||||
|
<a
|
||||||
|
href={repoUrl}
|
||||||
|
title="Stream Radio Git Repository"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'inherit', textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
<Icon className="medium">hearing</Icon> Stream Radio
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Header.propTypes = {
|
||||||
|
repoUrl: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
Header.defaultProps = {
|
||||||
|
repoUrl: "https://git.manalejandro.com/ale/stream-radio"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
35
src/components/TrackInfo.js
Archivo normal
@@ -0,0 +1,35 @@
|
|||||||
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track info component with marquee effect
|
||||||
|
*/
|
||||||
|
const TrackInfo = ({ title, link, trackInfo }) => {
|
||||||
|
return (
|
||||||
|
<h4>
|
||||||
|
<p className="bounce">
|
||||||
|
<a
|
||||||
|
href={link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={title}
|
||||||
|
alt={title}
|
||||||
|
>
|
||||||
|
{trackInfo}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</h4>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
TrackInfo.propTypes = {
|
||||||
|
title: PropTypes.string.isRequired,
|
||||||
|
link: PropTypes.string,
|
||||||
|
trackInfo: PropTypes.string
|
||||||
|
};
|
||||||
|
|
||||||
|
TrackInfo.defaultProps = {
|
||||||
|
link: '',
|
||||||
|
trackInfo: 'Now Playing: Title not available'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TrackInfo;
|
||||||
133
src/hooks/useAudioPlayer.js
Archivo normal
@@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useRef, useState, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing audio playback functionality
|
||||||
|
* @returns {Object} - Audio player state and controls
|
||||||
|
*/
|
||||||
|
const useAudioPlayer = () => {
|
||||||
|
const [analyzerData, setAnalyzerData] = useState(null);
|
||||||
|
const [currentVolume, setCurrentVolume] = useState(0.5);
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [paused, setPaused] = useState(true);
|
||||||
|
|
||||||
|
const audioElmRef = useRef(null);
|
||||||
|
const loadedAnalyzer = useRef(false);
|
||||||
|
const previousVolume = useRef(0.5); // Store previous volume for unmuting
|
||||||
|
|
||||||
|
// Initialize audio analyzer for visualization
|
||||||
|
const initAudioAnalyzer = useCallback(() => {
|
||||||
|
if (!audioElmRef.current) return;
|
||||||
|
|
||||||
|
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
const source = audioCtx.createMediaElementSource(audioElmRef.current);
|
||||||
|
const analyzer = audioCtx.createAnalyser();
|
||||||
|
|
||||||
|
analyzer.fftSize = 2048;
|
||||||
|
const bufferLength = analyzer.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
source.connect(analyzer);
|
||||||
|
analyzer.connect(audioCtx.destination);
|
||||||
|
source.onended = () => source.disconnect();
|
||||||
|
|
||||||
|
setAnalyzerData({ analyzer, bufferLength, dataArray });
|
||||||
|
loadedAnalyzer.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Play audio function
|
||||||
|
const play = useCallback(async () => {
|
||||||
|
if (!loadedAnalyzer.current) {
|
||||||
|
initAudioAnalyzer();
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaused(false);
|
||||||
|
|
||||||
|
// Don't automatically unmute when playing - let user control mute state
|
||||||
|
try {
|
||||||
|
await audioElmRef.current?.play();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play audio:', error);
|
||||||
|
}
|
||||||
|
}, [initAudioAnalyzer]);
|
||||||
|
|
||||||
|
// Pause audio function
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
audioElmRef.current?.pause();
|
||||||
|
setPaused(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Toggle mute function
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
if (!audioElmRef.current) return;
|
||||||
|
|
||||||
|
setMuted(prevMuted => {
|
||||||
|
const newMuted = !prevMuted;
|
||||||
|
|
||||||
|
if (newMuted) {
|
||||||
|
// Muting: store current volume and set to 0
|
||||||
|
previousVolume.current = currentVolume;
|
||||||
|
setCurrentVolume(0);
|
||||||
|
audioElmRef.current.muted = true;
|
||||||
|
} else {
|
||||||
|
// Unmuting: restore previous volume
|
||||||
|
const volumeToRestore = previousVolume.current > 0 ? previousVolume.current : 0.5;
|
||||||
|
setCurrentVolume(volumeToRestore);
|
||||||
|
audioElmRef.current.muted = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMuted;
|
||||||
|
});
|
||||||
|
}, [currentVolume]);
|
||||||
|
|
||||||
|
// Update volume when currentVolume changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioElmRef.current) return;
|
||||||
|
|
||||||
|
audioElmRef.current.volume = currentVolume;
|
||||||
|
|
||||||
|
// Update muted state based on volume level
|
||||||
|
if (currentVolume === 0 && !muted) {
|
||||||
|
setMuted(true);
|
||||||
|
audioElmRef.current.muted = true;
|
||||||
|
} else if (currentVolume > 0 && muted) {
|
||||||
|
setMuted(false);
|
||||||
|
audioElmRef.current.muted = false;
|
||||||
|
}
|
||||||
|
}, [currentVolume, muted]);
|
||||||
|
|
||||||
|
// Setup volume change listener
|
||||||
|
useEffect(() => {
|
||||||
|
const volumeSlider = document.querySelector('input[type="range"]');
|
||||||
|
|
||||||
|
if (!volumeSlider) return;
|
||||||
|
|
||||||
|
const handleVolumeChange = (e) => {
|
||||||
|
const newVolume = e.target.value / 100;
|
||||||
|
if (newVolume > 0) {
|
||||||
|
previousVolume.current = newVolume;
|
||||||
|
}
|
||||||
|
setCurrentVolume(newVolume);
|
||||||
|
};
|
||||||
|
|
||||||
|
volumeSlider.addEventListener('input', handleVolumeChange);
|
||||||
|
volumeSlider.addEventListener('change', handleVolumeChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
volumeSlider.removeEventListener('input', handleVolumeChange);
|
||||||
|
volumeSlider.removeEventListener('change', handleVolumeChange);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioElmRef,
|
||||||
|
analyzerData,
|
||||||
|
currentVolume,
|
||||||
|
muted,
|
||||||
|
paused,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
toggleMute
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAudioPlayer;
|
||||||
39
src/hooks/useBackgroundImages.js
Archivo normal
@@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing background images
|
||||||
|
* @param {string} imagePath - Path to the text file containing image filenames
|
||||||
|
* @returns {Object} - Background image state and loading function
|
||||||
|
*/
|
||||||
|
const useBackgroundImages = (imagePath) => {
|
||||||
|
const [images, setImages] = useState([]);
|
||||||
|
|
||||||
|
// Load images from text file
|
||||||
|
const loadImages = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(imagePath);
|
||||||
|
const data = await response.text();
|
||||||
|
const imageList = data.split('\n').filter(line => line.length > 0);
|
||||||
|
setImages(imageList);
|
||||||
|
return imageList;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading background images:', err.message);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [imagePath]);
|
||||||
|
|
||||||
|
// Set random background image when images list changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (images.length > 0) {
|
||||||
|
const randomImage = images[Math.floor(Math.random() * images.length)];
|
||||||
|
document.body.style.backgroundImage = `url('/wallpapers/${randomImage}')`;
|
||||||
|
}
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
images,
|
||||||
|
loadImages
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBackgroundImages;
|
||||||
122
src/hooks/useStreamData.js
Archivo normal
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for fetching streaming data
|
||||||
|
* @returns {Object} - Streaming data and loading functions
|
||||||
|
*/
|
||||||
|
const useStreamData = () => {
|
||||||
|
const [streamInfo, setStreamInfo] = useState({
|
||||||
|
json: {},
|
||||||
|
currentListeners: 0,
|
||||||
|
maxListeners: 0,
|
||||||
|
title: 'Stream Radio',
|
||||||
|
link: '',
|
||||||
|
trackInfo: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load stream metadata
|
||||||
|
const loadStreamMetadata = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/stream.json');
|
||||||
|
const json = await response.json();
|
||||||
|
return json;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching stream metadata:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load listener statistics
|
||||||
|
const loadListenerStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/status.xsl');
|
||||||
|
const data = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(data, 'text/html');
|
||||||
|
const listeners = xmlDoc.getElementsByTagName('td');
|
||||||
|
|
||||||
|
let current = 0;
|
||||||
|
let max = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < listeners.length; i++) {
|
||||||
|
if (i === 9) {
|
||||||
|
current = listeners[i].textContent;
|
||||||
|
} else if (i === 11) {
|
||||||
|
max = listeners[i].textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { current, max };
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching listener stats:', err.message);
|
||||||
|
return { current: 0, max: 0 };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load all stream data
|
||||||
|
const loadAllData = useCallback(async () => {
|
||||||
|
const [jsonData, listenerStats] = await Promise.all([
|
||||||
|
loadStreamMetadata(),
|
||||||
|
loadListenerStats()
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStreamInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
json: jsonData || {},
|
||||||
|
currentListeners: listenerStats.current,
|
||||||
|
maxListeners: listenerStats.max
|
||||||
|
}));
|
||||||
|
|
||||||
|
return jsonData;
|
||||||
|
}, [loadStreamMetadata, loadListenerStats]);
|
||||||
|
|
||||||
|
// Update track info when json changes
|
||||||
|
useEffect(() => {
|
||||||
|
const { json } = streamInfo;
|
||||||
|
|
||||||
|
if (json?.media?.track && json.media.track[0]) {
|
||||||
|
const track = json.media.track[0];
|
||||||
|
|
||||||
|
// Build track info string
|
||||||
|
let trackInfo = 'Now Playing: ';
|
||||||
|
let trackTitle = '';
|
||||||
|
|
||||||
|
Object.entries(track).forEach(([key, value]) => {
|
||||||
|
if (['Title', 'Performer', 'Album'].includes(key)) {
|
||||||
|
trackInfo += value + ' - ';
|
||||||
|
if (key === 'Title') trackTitle = value;
|
||||||
|
} else if (key === 'Filesize') {
|
||||||
|
trackInfo += Math.round(parseInt(value) / 1024 / 1024) + 'Mb - ';
|
||||||
|
} else if (key === 'Bitrate') {
|
||||||
|
trackInfo += Math.floor(parseInt(value) / 1000) + 'kbps - ';
|
||||||
|
} else if (key === 'Duration') {
|
||||||
|
trackInfo += Math.floor(parseInt(value)) + 's - ';
|
||||||
|
} else if (key === 'Recorded_Date') {
|
||||||
|
trackInfo += value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update stream info
|
||||||
|
setStreamInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
title: trackTitle || 'Stream Radio',
|
||||||
|
link: json?.media['@ref']?.replace('/musica', 'https://radio.manalejandro.com') || '',
|
||||||
|
trackInfo
|
||||||
|
}));
|
||||||
|
} else if (!json?.media && json?.title) {
|
||||||
|
setStreamInfo(prev => ({
|
||||||
|
...prev,
|
||||||
|
title: 'Stream Radio',
|
||||||
|
link: json?.title?.replace('/musica', 'https://radio.manalejandro.com') || '',
|
||||||
|
trackInfo: 'Now Playing: Title not available'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [streamInfo.json]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...streamInfo,
|
||||||
|
loadAllData
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useStreamData;
|
||||||
11
src/index.js
Archivo normal
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.scss';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
31
src/index.scss
Archivo normal
@@ -0,0 +1,31 @@
|
|||||||
|
@import 'materialize-css/sass/components/_color-variables';
|
||||||
|
$primary-color: color('brown', 'base') !default;
|
||||||
|
$radio-fill-color: color('brown', 'base') !default;
|
||||||
|
@import 'materialize-css/sass/materialize';
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url(./material-icons.ttf) format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-family: 'Material Icons';
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
word-wrap: normal;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: monospace, monospace;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
67
src/list.txt
Archivo normal
@@ -0,0 +1,67 @@
|
|||||||
|
pexels-baskincreativeco-1480807.jpg
|
||||||
|
pexels-belle-co-99483-847393.jpg
|
||||||
|
pexels-bess-hamiti-83687-36487.jpg
|
||||||
|
pexels-carlos-oliva-1966452-3586966.jpg
|
||||||
|
pexels-christian-heitz-285904-842711.jpg
|
||||||
|
pexels-dreamypixel-547115.jpg
|
||||||
|
pexels-eberhardgross-1062249.jpg
|
||||||
|
pexels-eberhardgross-1287075.jpg
|
||||||
|
pexels-eberhardgross-1287089.jpg
|
||||||
|
pexels-eberhardgross-1301976.jpg
|
||||||
|
pexels-eberhardgross-1367192(1).jpg
|
||||||
|
pexels-eberhardgross-1367192.jpg
|
||||||
|
pexels-eberhardgross-1612351.jpg
|
||||||
|
pexels-eberhardgross-1612360.jpg
|
||||||
|
pexels-eberhardgross-1612362.jpg
|
||||||
|
pexels-eberhardgross-1612371.jpg
|
||||||
|
pexels-eberhardgross-1624255.jpg
|
||||||
|
pexels-eberhardgross-534164.jpg
|
||||||
|
pexels-eberhardgross-629167.jpg
|
||||||
|
pexels-eberhardgross-691668.jpg
|
||||||
|
pexels-eberhardgross-707344.jpg
|
||||||
|
pexels-eberhardgross-730981.jpg
|
||||||
|
pexels-esan-2085998.jpg
|
||||||
|
pexels-fotios-photos-109260.jpg
|
||||||
|
pexels-francesco-ungaro-1525041.jpg
|
||||||
|
pexels-gochrisgoxyz-1477166.jpg
|
||||||
|
pexels-johnnoibn-1448136.jpg
|
||||||
|
pexels-joshkjack-135018.jpg
|
||||||
|
pexels-jplenio-1110656.jpg
|
||||||
|
pexels-jplenio-1146708.jpg
|
||||||
|
pexels-jplenio-1435075.jpg
|
||||||
|
pexels-kasperphotography-1042423.jpg
|
||||||
|
pexels-katja-79053-592077.jpg
|
||||||
|
pexels-lastly-937782.jpg
|
||||||
|
pexels-lilartsy-1213447.jpg
|
||||||
|
pexels-maxfrancis-2246476.jpg
|
||||||
|
pexels-mdsnmdsnmdsn-1831234.jpg
|
||||||
|
pexels-mdx014-814499.jpg
|
||||||
|
pexels-michal-pech-213601-1632044.jpg
|
||||||
|
pexels-no-name-14543-66997.jpg
|
||||||
|
pexels-pixabay-158063.jpg
|
||||||
|
pexels-pixabay-33109(1).jpg
|
||||||
|
pexels-pixabay-33109.jpg
|
||||||
|
pexels-pixabay-33545.jpg
|
||||||
|
pexels-pixabay-358532.jpg
|
||||||
|
pexels-pixabay-41004.jpg
|
||||||
|
pexels-pixabay-414144.jpg
|
||||||
|
pexels-pixabay-416160.jpg
|
||||||
|
pexels-pixabay-459203.jpg
|
||||||
|
pexels-pixabay-462162.jpg
|
||||||
|
pexels-pixabay-50594.jpg
|
||||||
|
pexels-pixabay-50686.jpg
|
||||||
|
pexels-pixabay-52500.jpg
|
||||||
|
pexels-rpnickson-2559941.jpg
|
||||||
|
pexels-rpnickson-2647990.jpg
|
||||||
|
pexels-samandgos-709552.jpg
|
||||||
|
pexels-samkolder-2387873.jpg
|
||||||
|
pexels-sebastian-312105.jpg
|
||||||
|
pexels-simon73-1183099.jpg
|
||||||
|
pexels-souvenirpixels-1519088.jpg
|
||||||
|
pexels-souvenirpixels-417074.jpg
|
||||||
|
pexels-stefanstefancik-919606.jpg
|
||||||
|
pexels-stywo-1054289.jpg
|
||||||
|
pexels-stywo-1668246.jpg
|
||||||
|
pexels-therato-1933239.jpg
|
||||||
|
pexels-todd-trapani-488382-1198817.jpg
|
||||||
|
pexels-umaraffan499-21787.jpg
|
||||||
BIN
src/material-icons.ttf
Archivo normal
5
src/setupTests.js
Archivo normal
@@ -0,0 +1,5 @@
|
|||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
17
src/useSize.js
Archivo normal
@@ -0,0 +1,17 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const useSize = () => {
|
||||||
|
const [width, setWidth] = useState(0),
|
||||||
|
[height, setHeight] = useState(0),
|
||||||
|
setSizes = useCallback(() => {
|
||||||
|
setWidth(window.innerWidth)
|
||||||
|
setHeight(window.innerHeight)
|
||||||
|
}, [setWidth, setHeight])
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("resize", setSizes)
|
||||||
|
setSizes()
|
||||||
|
}, [setSizes])
|
||||||
|
return [width, height]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useSize
|
||||||