diff --git a/package.json b/package.json index da13a59..cc06b0d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.js b/src/App.js index bb21933..a7ef4f6 100644 --- a/src/App.js +++ b/src/App.js @@ -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 ( <> -
+ + {trackInfo} + +
+ + ); +}; + +TrackInfo.propTypes = { + title: PropTypes.string.isRequired, + link: PropTypes.string, + trackInfo: PropTypes.string +}; + +TrackInfo.defaultProps = { + link: '', + trackInfo: 'Now Playing: Title not available' +}; + +export default TrackInfo; diff --git a/src/hooks/useAudioPlayer.js b/src/hooks/useAudioPlayer.js new file mode 100644 index 0000000..f44f937 --- /dev/null +++ b/src/hooks/useAudioPlayer.js @@ -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; diff --git a/src/hooks/useBackgroundImages.js b/src/hooks/useBackgroundImages.js new file mode 100644 index 0000000..300744d --- /dev/null +++ b/src/hooks/useBackgroundImages.js @@ -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; diff --git a/src/hooks/useStreamData.js b/src/hooks/useStreamData.js new file mode 100644 index 0000000..c76ccf3 --- /dev/null +++ b/src/hooks/useStreamData.js @@ -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;