2025-07-17 06:55:01 -04:00
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
|
|
import { Howl, Howler } from "howler";
|
|
|
|
import Play from "@mui/icons-material/PlayArrow";
|
|
|
|
import Pause from "@mui/icons-material/Pause";
|
|
|
|
import "@styles/player.css";
|
|
|
|
|
|
|
|
const API_URL = "https://api.codey.lol";
|
|
|
|
Howler.html5PoolSize = 32;
|
|
|
|
|
|
|
|
const STATIONS = {
|
|
|
|
main: { label: "Main", streamPath: "/sfm.ogg" },
|
|
|
|
rock: { label: "Rock", streamPath: "/rock.ogg" },
|
|
|
|
rap: { label: "Rap", streamPath: "/rap.ogg" },
|
|
|
|
electronic: { label: "Electronic", streamPath: "/electronic.ogg" },
|
|
|
|
classical: { label: "Classical", streamPath: "/classical.ogg" },
|
|
|
|
pop: { label: "Pop", streamPath: "/pop.ogg" },
|
|
|
|
};
|
|
|
|
|
|
|
|
// Global interval tracking (required for Astro + client:only)
|
|
|
|
let activeInterval = null;
|
|
|
|
let currentStationForInterval = null;
|
2025-06-18 07:46:59 -04:00
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
function clearGlobalMetadataInterval() {
|
|
|
|
if (activeInterval) {
|
|
|
|
clearInterval(activeInterval);
|
|
|
|
activeInterval = null;
|
|
|
|
currentStationForInterval = null;
|
|
|
|
}
|
2025-06-18 07:46:59 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
export function Player() {
|
2025-07-17 06:55:01 -04:00
|
|
|
const [activeStation, setActiveStation] = useState("main");
|
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
|
const [trackTitle, setTrackTitle] = useState("");
|
|
|
|
const [trackArtist, setTrackArtist] = useState("");
|
|
|
|
const [trackGenre, setTrackGenre] = useState("");
|
|
|
|
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
|
|
|
const [elapsed, setElapsed] = useState(0);
|
|
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
|
|
|
|
const soundRef = useRef(null);
|
|
|
|
const uuidRef = useRef(null);
|
|
|
|
const lastStationData = useRef(null);
|
|
|
|
|
|
|
|
const formatTime = (seconds) => {
|
|
|
|
const m = String(Math.floor(seconds / 60)).padStart(2, "0");
|
|
|
|
const s = String(Math.floor(seconds % 60)).padStart(2, "0");
|
|
|
|
return `${m}:${s}`;
|
|
|
|
};
|
|
|
|
|
|
|
|
const progress = duration > 0 ? (elapsed / duration) * 100 : 0;
|
|
|
|
|
|
|
|
// Create Howl instance on activeStation change
|
|
|
|
useEffect(() => {
|
|
|
|
if (soundRef.current) {
|
|
|
|
soundRef.current.unload();
|
|
|
|
soundRef.current = null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const streamUrl = "https://stream.codey.lol" + STATIONS[activeStation].streamPath;
|
|
|
|
|
|
|
|
const howl = new Howl({
|
|
|
|
src: [streamUrl],
|
|
|
|
html5: true,
|
|
|
|
onend: () => howl.play(),
|
|
|
|
onplay: () => setIsPlaying(true),
|
|
|
|
onpause: () => setIsPlaying(false),
|
|
|
|
onstop: () => setIsPlaying(false),
|
|
|
|
onloaderror: (id, err) => console.error("Load error", err),
|
|
|
|
onplayerror: (id, err) => {
|
|
|
|
console.error("Play error", err);
|
|
|
|
setTimeout(() => howl.play(), 1000);
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
soundRef.current = howl;
|
|
|
|
uuidRef.current = null;
|
|
|
|
lastStationData.current = null;
|
|
|
|
|
|
|
|
// Autoplay if previously playing
|
|
|
|
if (isPlaying) {
|
|
|
|
setTimeout(() => {
|
|
|
|
if (soundRef.current) {
|
|
|
|
soundRef.current.play();
|
|
|
|
}
|
|
|
|
}, 100);
|
|
|
|
}
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
howl.stop();
|
|
|
|
howl.unload();
|
|
|
|
};
|
|
|
|
}, [activeStation]);
|
|
|
|
|
|
|
|
const togglePlayback = () => {
|
|
|
|
if (isPlaying) {
|
|
|
|
if (soundRef.current) {
|
|
|
|
soundRef.current.pause();
|
|
|
|
}
|
|
|
|
setIsPlaying(false);
|
|
|
|
} else {
|
|
|
|
// If a previous Howl exists, unload it
|
|
|
|
if (soundRef.current) {
|
|
|
|
soundRef.current.stop();
|
|
|
|
soundRef.current.unload();
|
|
|
|
}
|
|
|
|
|
|
|
|
const streamUrl =
|
|
|
|
"https://stream.codey.lol" +
|
|
|
|
STATIONS[activeStation].streamPath +
|
|
|
|
`?t=${Date.now()}`; // Cache-busting param
|
|
|
|
|
|
|
|
const newHowl = new Howl({
|
|
|
|
src: [streamUrl],
|
|
|
|
html5: true,
|
|
|
|
onend: () => newHowl.play(),
|
|
|
|
onplay: () => {
|
|
|
|
setIsPlaying(true);
|
|
|
|
},
|
|
|
|
onpause: () => setIsPlaying(false),
|
|
|
|
onstop: () => setIsPlaying(false),
|
|
|
|
onloaderror: (_, err) => console.error("Load error", err),
|
|
|
|
onplayerror: (_, err) => {
|
|
|
|
console.error("Play error", err);
|
|
|
|
setTimeout(() => newHowl.play(), 1000);
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
soundRef.current = newHowl;
|
|
|
|
newHowl.play();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Metadata fetcher: global-safe
|
|
|
|
useEffect(() => {
|
|
|
|
clearGlobalMetadataInterval();
|
|
|
|
|
|
|
|
currentStationForInterval = activeStation;
|
|
|
|
|
|
|
|
const fetchTrackData = async () => {
|
|
|
|
try {
|
|
|
|
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
|
|
|
|
method: "POST",
|
|
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
// Ignore stale interval calls
|
|
|
|
if (currentStationForInterval !== activeStation) return;
|
|
|
|
|
|
|
|
if (data.artist === "N/A" && data.song === "N/A") {
|
|
|
|
if (lastStationData.current !== "offline") {
|
|
|
|
setTrackTitle("Offline");
|
|
|
|
setTrackArtist("");
|
|
|
|
setTrackGenre("");
|
|
|
|
setCoverArt("/images/radio_art_default.jpg");
|
|
|
|
setElapsed(0);
|
|
|
|
setDuration(0);
|
|
|
|
lastStationData.current = "offline";
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (data.uuid === uuidRef.current) {
|
|
|
|
if (lastStationData.current === data.uuid) {
|
|
|
|
setElapsed(data.elapsed);
|
|
|
|
setDuration(data.duration);
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
uuidRef.current = data.uuid;
|
|
|
|
lastStationData.current = data.uuid;
|
|
|
|
setTrackTitle(data.song);
|
|
|
|
setTrackArtist(data.artist);
|
|
|
|
setTrackGenre(data.genre !== "N/A" ? data.genre : "");
|
|
|
|
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
|
|
|
|
setElapsed(data.elapsed);
|
|
|
|
setDuration(data.duration);
|
|
|
|
} catch (error) {
|
|
|
|
console.error("Failed to fetch track data:", error);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
fetchTrackData();
|
|
|
|
activeInterval = setInterval(fetchTrackData, 1000);
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
clearGlobalMetadataInterval();
|
|
|
|
};
|
|
|
|
}, [activeStation]);
|
|
|
|
|
2025-06-18 07:46:59 -04:00
|
|
|
return (
|
2025-07-17 06:55:01 -04:00
|
|
|
<>
|
|
|
|
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
|
|
|
|
{Object.entries(STATIONS).map(([key, { label }]) => (
|
|
|
|
<button
|
|
|
|
key={key}
|
|
|
|
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${
|
|
|
|
activeStation === key
|
|
|
|
? "bg-neutral-800 text-white dark:bg-white dark:text-black"
|
|
|
|
: "bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
|
|
|
}`}
|
|
|
|
onClick={() => setActiveStation(key)}
|
|
|
|
aria-pressed={activeStation === key}
|
|
|
|
>
|
|
|
|
{label}
|
|
|
|
</button>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className="c-containter">
|
|
|
|
<div className="music-container mt-8">
|
|
|
|
<section className="album-cover">
|
|
|
|
<img src={coverArt} className="cover" alt="Cover Art" />
|
|
|
|
</section>
|
|
|
|
<section className="music-player">
|
|
|
|
<h1 className="music-player__header">serious.FM</h1>
|
|
|
|
<h1 className="music-player__title">{trackTitle}</h1>
|
|
|
|
<h2 className="music-player__author">{trackArtist}</h2>
|
|
|
|
{trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>}
|
|
|
|
<div className="music-time">
|
|
|
|
<p className="music-time__current">{formatTime(elapsed)}</p>
|
|
|
|
<p className="music-time__last">{formatTime(duration - elapsed)}</p>
|
|
|
|
</div>
|
|
|
|
<div className="music-bar" id="progress">
|
|
|
|
<div id="length" style={{ width: `${progress}%` }}></div>
|
|
|
|
</div>
|
|
|
|
<div className="music-control">
|
|
|
|
<div className="music-control__play" id="play">
|
|
|
|
{!isPlaying ? (
|
|
|
|
<Play onClick={togglePlayback} />
|
|
|
|
) : (
|
|
|
|
<Pause onClick={togglePlayback} />
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</section>
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
}
|