306 lines
8.9 KiB
JavaScript
306 lines
8.9 KiB
JavaScript
import { useState, useEffect, useRef } from "react";
|
|
import Hls from "hls.js";
|
|
import { metaData } from "../config";
|
|
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";
|
|
const STATIONS = {
|
|
main: { label: "Main" },
|
|
rock: { label: "Rock" },
|
|
rap: { label: "Rap" },
|
|
electronic: { label: "Electronic" },
|
|
classical: { label: "Classical" },
|
|
pop: { label: "Pop" },
|
|
};
|
|
|
|
let activeInterval = null;
|
|
let currentStationForInterval = null;
|
|
|
|
function clearGlobalMetadataInterval() {
|
|
if (activeInterval) {
|
|
clearInterval(activeInterval);
|
|
activeInterval = null;
|
|
currentStationForInterval = null;
|
|
}
|
|
}
|
|
|
|
export function Player() {
|
|
const [activeStation, setActiveStation] = useState("main");
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [trackTitle, setTrackTitle] = useState("");
|
|
const [trackArtist, setTrackArtist] = useState("");
|
|
const [trackGenre, setTrackGenre] = useState("");
|
|
const [trackAlbum, setTrackAlbum] = useState("");
|
|
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
const audioRef = useRef(null);
|
|
const hlsRef = 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;
|
|
|
|
const playStream = () => {
|
|
const lcStation = activeStation.toLowerCase();
|
|
const streamUrl = `https://stream.codey.lol/hls/${lcStation}/${lcStation}.m3u8?t=${Date.now()}`;
|
|
|
|
// Cleanup
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
hlsRef.current = null;
|
|
}
|
|
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
|
audio.src = streamUrl;
|
|
audio.load();
|
|
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
|
return;
|
|
}
|
|
|
|
if (!Hls.isSupported()) {
|
|
console.error("HLS not supported");
|
|
return;
|
|
}
|
|
|
|
const hls = new Hls({
|
|
maxBufferLength: 30,
|
|
maxMaxBufferLength: 60,
|
|
autoStartLoad: true,
|
|
startLevel: -1,
|
|
});
|
|
|
|
hls.attachMedia(audio);
|
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
hls.loadSource(streamUrl);
|
|
});
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
|
});
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
console.warn("HLS error:", data);
|
|
|
|
if (data.fatal) {
|
|
switch (data.type) {
|
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
|
case Hls.ErrorTypes.OTHER_ERROR:
|
|
console.error("Fatal HLS error, attempting recovery…");
|
|
try {
|
|
hls.destroy();
|
|
hlsRef.current = null;
|
|
setIsPlaying(false);
|
|
// Delay before trying to reconnect
|
|
setTimeout(() => {
|
|
playStream();
|
|
}, 3000);
|
|
} catch (e) {
|
|
console.error("HLS recovery failed:", e);
|
|
}
|
|
break;
|
|
default:
|
|
hls.destroy();
|
|
hlsRef.current = null;
|
|
setIsPlaying(false);
|
|
}
|
|
}
|
|
});
|
|
|
|
hlsRef.current = hls;
|
|
};
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
const audio = audioRef.current;
|
|
if (audio && isPlaying && audio.readyState < 2) {
|
|
console.warn("Playback seems stalled. Reloading stream.");
|
|
playStream();
|
|
}
|
|
}, 15000); // Check every 15 seconds
|
|
|
|
return () => clearInterval(interval);
|
|
}, [isPlaying]);
|
|
|
|
const togglePlayback = () => {
|
|
const audio = audioRef.current;
|
|
if (!audio) return;
|
|
|
|
if (isPlaying) {
|
|
audio.pause();
|
|
setIsPlaying(false);
|
|
} else {
|
|
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
const audio = audioRef.current;
|
|
if (audio) {
|
|
audio.pause();
|
|
audio.src = "";
|
|
setIsPlaying(false);
|
|
}
|
|
|
|
playStream();
|
|
|
|
return () => {
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
hlsRef.current = null;
|
|
}
|
|
};
|
|
}, [activeStation]);
|
|
|
|
useEffect(() => {
|
|
clearGlobalMetadataInterval();
|
|
currentStationForInterval = activeStation;
|
|
|
|
const setPageTitle = (artist, song) => {
|
|
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`;
|
|
};
|
|
|
|
const fetchTrackData = async () => {
|
|
try {
|
|
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, {
|
|
method: "POST",
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (currentStationForInterval !== activeStation) return;
|
|
|
|
if (data.artist === "N/A" && data.song === "N/A") {
|
|
if (lastStationData.current !== "offline") {
|
|
setTrackTitle("Offline");
|
|
setTrackArtist("");
|
|
setTrackAlbum("");
|
|
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 : "");
|
|
setTrackAlbum(data.album);
|
|
setPageTitle(data.artist, data.song);
|
|
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]);
|
|
|
|
const remaining = duration - elapsed;
|
|
|
|
const progressColorClass =
|
|
progress >= 90
|
|
? "bg-red-500 dark:bg-red-400"
|
|
: progress >= 75 || remaining <= 20
|
|
? "bg-yellow-400 dark:bg-yellow-300"
|
|
: "bg-blue-500 dark:bg-blue-400";
|
|
|
|
return (
|
|
<>
|
|
<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">
|
|
<div className="music-player__album" title="Album">{trackAlbum}</div>
|
|
<img
|
|
src={coverArt}
|
|
className="cover"
|
|
title={trackAlbum ? `"${trackAlbum}" Cover Art` : "Cover Art"}
|
|
alt={trackAlbum ? `"${trackAlbum}" Cover Art` : "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(remaining)}</p>
|
|
</div>
|
|
|
|
<div className="w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
|
style={{ width: `${progress}%` }}
|
|
></div>
|
|
</div>
|
|
|
|
<div className="music-control">
|
|
<div
|
|
className="music-control__play"
|
|
onClick={togglePlayback}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={isPlaying}
|
|
>
|
|
{!isPlaying ? <Play /> : <Pause />}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<audio ref={audioRef} preload="none" />
|
|
</div>
|
|
</>
|
|
);
|
|
}
|