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

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