115
src/App.css
Archivo normal
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
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
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
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
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
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
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
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
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
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
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
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
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
BIN
src/material-icons.ttf
Archivo normal
Archivo binario no mostrado.
5
src/setupTests.js
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
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
|
||||
Referencia en una nueva incidencia
Block a user