initial commit

Signed-off-by: ale <ale@manalejandro.com>
Este commit está contenido en:
ale
2025-12-13 02:38:29 +01:00
commit 60b77a3c99
Se han modificado 92 ficheros con 1040 adiciones y 0 borrados

25
.gitignore vendido Archivo normal
Ver fichero

@@ -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
Ver fichero

@@ -0,0 +1,3 @@
# 📻 Stream Radio
## A simple radio streaming react app

43
package.json Archivo normal
Ver fichero

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

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 4.2 KiB

48
public/index.html Archivo normal
Ver fichero

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

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 7.4 KiB

BIN
public/logo512.png Archivo normal

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 12 KiB

26
public/manifest.json Archivo normal
Ver fichero

@@ -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
Ver fichero

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 5.0 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.0 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 109 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 991 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.8 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.3 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.9 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 478 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.4 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 540 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 746 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 746 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.6 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 912 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 749 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.2 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 929 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.8 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 883 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.8 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 762 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.2 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.5 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.3 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.1 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 665 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.0 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.8 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.2 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.4 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 562 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 899 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.0 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 573 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.6 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.4 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 100 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.5 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.4 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 849 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 636 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.3 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.3 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 652 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.5 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.3 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 411 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.4 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.5 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 376 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.4 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 227 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 467 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.6 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 976 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.2 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.6 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.3 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 3.2 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 475 KiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.2 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 2.9 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.5 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.1 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 1.1 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 4.9 MiB

Archivo binario no mostrado.

Después

Anchura:  |  Altura:  |  Tamaño: 686 KiB

115
src/App.css Archivo normal
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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;

Ver fichero

@@ -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>
)}
&nbsp;
{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>
)}
&nbsp;&nbsp;
<Range
className="brown waves-light range-field"
min={0}
max={100}
step={1}
value={Math.round(currentVolume * 100)}
/>
&nbsp;&nbsp;
<span>
<Icon tiny>people</Icon>&nbsp;{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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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;

Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

@@ -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
Ver fichero

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

Archivo binario no mostrado.

5
src/setupTests.js Archivo normal
Ver fichero

@@ -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
Ver fichero

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