refactor AI

Signed-off-by: ale <ale@manalejandro.com>
This commit is contained in:
ale 2025-05-30 17:45:13 +02:00
parent 1cb4b8a023
commit 2ec5c3df5e
Signed by: ale
GPG Key ID: 244A9C4DAB1C0C81
8 changed files with 525 additions and 160 deletions

View File

@ -8,6 +8,7 @@
"@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",

View File

@ -1,175 +1,111 @@
import "./App.css";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import WaveForm from "./WaveForm";
import M from "materialize-css";
import { Button, Icon, Range } from "react-materialize";
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 = () => {
const [audioUrl, setAudioUrl] = useState(null),
[analyzerData, setAnalyzerData] = useState(null),
[bounce, setBounce] = useState(''),
[json, setJson] = useState({}),
[currentVolume, setCurrentVolume] = useState(0.5),
[muted, setMuted] = useState(false),
[link, setLink] = useState(''),
[title, setTitle] = useState('Stream Radio'),
[images, setImages] = useState([]),
[currentListeners, setCurrentListeners] = useState(0),
[maxListeners, setMaxListeners] = useState(0),
[paused, setPaused] = useState(true),
audioElmRef = useRef(null),
loadedAnalyzer = useRef(false),
once = useRef(false),
audioAnalyzer = () => {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)(),
source = audioCtx.createMediaElementSource(audioElmRef.current),
analyzer = audioCtx.createAnalyser()
analyzer.fftSize = 2048
const bufferLength = analyzer.frequencyBinCount,
dataArray = new Uint8Array(bufferLength)
source.connect(analyzer)
source.connect(audioCtx.destination)
source.onended = () => {
source.disconnect()
}
setAnalyzerData({ analyzer, bufferLength, dataArray })
},
loadData = async () => {
try {
const response = await fetch('/stream.json'),
json = await response.json()
setJson(json)
} catch (err) {
console.error('Error fetching data: ' + err.message)
}
},
loadListeners = async () => {
try {
const response = await fetch('/status.xsl'),
data = await response.text(),
parser = new DOMParser(),
xmlDoc = parser.parseFromString(data, 'text/html'),
listeners = xmlDoc.getElementsByTagName('td')
for (let i = 0; i < listeners.length; i++) {
if (i === 9) {
setCurrentListeners(listeners[i].textContent)
} else if (i === 11) {
setMaxListeners(listeners[i].textContent)
}
}
} catch (err) {
console.error('Error fetching data: ' + err.message)
}
},
loadImages = useCallback(async () => {
const response = await fetch(text),
data = await response.text()
setImages(data.split('\n').filter(line => line.length > 0))
}),
load = useCallback(async () => {
await loadData()
await loadListeners()
}),
play = useCallback(async () => {
if (!loadedAnalyzer.current) {
audioAnalyzer()
loadedAnalyzer.current = true
}
setPaused(false)
setMuted(false)
await audioElmRef.current?.play()
})
// Custom hook for managing background images
const { loadImages } = useBackgroundImages(text);
// Custom hook for streaming data
const {
json,
currentListeners,
maxListeners,
title,
link,
trackInfo,
loadAllData
} = useStreamData();
// Custom hook for audio player functionality
const {
audioElmRef,
analyzerData,
muted,
paused,
play,
pause,
toggleMute
} = useAudioPlayer("/stream.mp3");
// Initialization flag to prevent multiple initializations
const initialized = useRef(false);
// Initialize app and setup periodic data refresh
useEffect(() => {
if (json?.media?.track[0] && bounce.search(json?.media?.track[0].Title) === -1) {
setBounce('Now Playing: ')
setLink(json?.media['@ref']?.replace('/musica', 'https://manalejandro.com'))
Object.keys(json.media.track[0]).map((key) => {
if (key === 'Title' || key === 'Performer' || key === 'Album') {
if (key === 'Title') {
setTitle(json.media.track[0][key])
}
setBounce(data => data + json.media.track[0][key] + ' - ')
} else if (key === 'Filesize') {
setBounce(data => data + Math.round(parseInt(json.media.track[0][key]) / 1024 / 1024) + 'Mb - ')
} else if (key === 'Bitrate') {
setBounce(data => data + Math.floor(parseInt(json.media.track[0][key]) / 1000) + 'kbps - ')
} else if (key === 'Duration') {
setBounce(data => data + Math.floor(parseInt(json.media.track[0][key])) + 's - ')
} else if (key === 'Recorded_Date') {
setBounce(data => data + json.media.track[0][key])
}
})
} else if (!json?.media && bounce.search('Now Playing: Title not available') === -1) {
setLink('')
setTitle('Stream Radio')
setBounce('Now Playing: Title not available')
}
}, [json])
useEffect(() => {
document.body.style.backgroundImage = `url('/wallpapers/${images[Math.floor(Math.random() * images.length)]}')`
}, [images])
useEffect(() => {
if (once.current) return
once.current = true
M.AutoInit()
load()
loadImages()
setAudioUrl('/stream.mp3')
document.querySelector('input[type="range"]').addEventListener('change', (e) => {
setCurrentVolume(e.target.value / 100)
})
const inter = setInterval(() => {
load()
}, (Math.floor(Math.random() * 20) + 10) * 1000),
interback = setInterval(() => {
loadImages()
}, (Math.floor(Math.random() * 60) + 90) * 1000)
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(inter)
clearInterval(interback)
}
}, [])
useEffect(() => {
if (audioElmRef.current && audioElmRef.current.volume !== currentVolume) {
if (currentVolume > 0 && currentVolume <= 1) {
setMuted(false)
} else if (currentVolume === 0) {
setMuted(true)
}
audioElmRef.current.volume = currentVolume
}
}, [currentVolume])
clearInterval(dataRefreshInterval);
clearInterval(imageRefreshInterval);
};
}, [loadAllData, loadImages]);
return (
<>
<h1><a href="https://git.manalejandro.com/ale/stream-radio" title="Stream Radio Git Repository" alt="Stream Radio Git Repository" target="_blank"><Icon className="medium">hearing</Icon> Stream Radio</a></h1>
<Header />
{analyzerData && <WaveForm analyzerData={analyzerData} />}
<h4><p className="bounce"><a href={link} target="_blank" title={title} alt={title}>{bounce}</a></p></h4>
<TrackInfo
title={title}
link={link}
trackInfo={trackInfo}
/>
<br /><br />
<div className="brown darken-3 player">
{paused ?
<Button node="button" className="brown" onClick={() => play()} waves="light" floating><Icon>play_arrow</Icon></Button>
:
<Button node="button" className="brown" onClick={() => {
audioElmRef.current?.pause()
setPaused(true)
}} waves="light" floating><Icon>pause</Icon></Button>
}&nbsp;
{muted ?
<Button node="button" className="brown" onClick={() => setMuted(false)} waves="light" floating><Icon>volume_off</Icon></Button>
:
<Button node="button" className="brown" onClick={() => setMuted(true)} waves="light" floating><Icon>volume_up</Icon></Button>
}&nbsp;&nbsp;
<Range className="brown" waves="light"
min={0}
max={100}
step={1}
/>&nbsp;&nbsp;
<span>
<Icon tiny>people</Icon>&nbsp;{currentListeners} / {maxListeners}
</span>
</div >
<audio src={audioUrl} ref={audioElmRef} volume={Math.log10(currentVolume * 10)} preload={"none"} muted={muted} controls={false} />
<AudioControls
paused={paused}
muted={muted}
currentListeners={currentListeners}
maxListeners={maxListeners}
onPlay={play}
onPause={pause}
onToggleMute={toggleMute}
/>
<audio
src="/stream.mp3"
ref={audioElmRef}
preload="none"
muted={muted}
controls={false}
/>
</>
)
}

View File

@ -0,0 +1,93 @@
import { Button, Icon } from "react-materialize";
import PropTypes from "prop-types";
/**
* Audio player controls component
*/
const AudioControls = ({
paused,
muted,
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;
<input
type="range"
className="brown waves-light range-field"
min={0}
max={100}
step={1}
defaultValue={50}
/>
&nbsp;&nbsp;
<span>
<Icon tiny>people</Icon>&nbsp;{currentListeners} / {maxListeners}
</span>
</div>
);
};
AudioControls.propTypes = {
paused: PropTypes.bool.isRequired,
muted: PropTypes.bool.isRequired,
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 = {
currentListeners: 0,
maxListeners: 0
};
export default AudioControls;

32
src/components/Header.js Normal file
View File

@ -0,0 +1,32 @@
import { useEffect, useRef, useCallback } from "react";
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"
alt="Stream Radio Git Repository"
target="_blank"
rel="noopener noreferrer"
>
<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;

View File

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

107
src/hooks/useAudioPlayer.js Normal file
View File

@ -0,0 +1,107 @@
import { useCallback, useRef, useState, useEffect } from "react";
/**
* Custom hook for managing audio playback functionality
* @param {string} audioUrl - URL of the audio stream
* @returns {Object} - Audio player state and controls
*/
const useAudioPlayer = (audioUrl) => {
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);
// 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);
source.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);
setMuted(false);
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(() => {
setMuted(prevMuted => !prevMuted);
}, []);
// Update volume when currentVolume changes
useEffect(() => {
if (!audioElmRef.current || audioElmRef.current.volume === currentVolume) return;
// Update muted state based on volume level
if (currentVolume > 0) {
setMuted(false);
} else if (currentVolume === 0) {
setMuted(true);
}
audioElmRef.current.volume = currentVolume;
}, [currentVolume]);
// Setup volume change listener
useEffect(() => {
const volumeSlider = document.querySelector('input[type="range"]');
if (!volumeSlider) return;
const handleVolumeChange = (e) => {
setCurrentVolume(e.target.value / 100);
};
volumeSlider.addEventListener('change', handleVolumeChange);
return () => {
volumeSlider.removeEventListener('change', handleVolumeChange);
};
}, []);
return {
audioElmRef,
analyzerData,
currentVolume,
muted,
paused,
setCurrentVolume,
play,
pause,
toggleMute
};
};
export default useAudioPlayer;

View File

@ -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 Normal file
View File

@ -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://manalejandro.com') || '',
trackInfo
}));
} else if (!json?.media) {
setStreamInfo(prev => ({
...prev,
title: 'Stream Radio',
link: '',
trackInfo: 'Now Playing: Title not available'
}));
}
}, [streamInfo.json]);
return {
...streamInfo,
loadAllData
};
};
export default useStreamData;