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;
|
2025-07-19 08:50:04 -04:00
|
|
|
const isIOS = /iP(ad|hone|od)/.test(navigator.userAgent);
|
2025-07-17 06:55:01 -04:00
|
|
|
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" },
|
|
|
|
};
|
|
|
|
|
|
|
|
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);
|
2025-07-19 08:50:04 -04:00
|
|
|
const [userHasInteracted, setUserHasInteracted] = useState(false);
|
2025-07-17 06:55:01 -04:00
|
|
|
|
|
|
|
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;
|
|
|
|
|
2025-07-19 08:50:04 -04:00
|
|
|
const playStream = () => {
|
|
|
|
let streamPath = STATIONS[activeStation].streamPath;
|
|
|
|
if (activeStation === "main" && isIOS) {
|
|
|
|
streamPath = "/hls/sfm.m3u8";
|
2025-07-17 06:55:01 -04:00
|
|
|
}
|
2025-07-19 08:50:04 -04:00
|
|
|
const streamUrl =
|
|
|
|
"https://stream.codey.lol" +
|
|
|
|
streamPath +
|
|
|
|
`?t=${Date.now()}`;
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-07-19 08:50:04 -04:00
|
|
|
if (soundRef.current) {
|
|
|
|
soundRef.current.stop();
|
|
|
|
soundRef.current.unload();
|
2025-07-17 11:30:14 -04:00
|
|
|
}
|
|
|
|
|
2025-07-19 08:50:04 -04:00
|
|
|
const newHowl = new Howl({
|
2025-07-17 06:55:01 -04:00
|
|
|
src: [streamUrl],
|
|
|
|
html5: true,
|
2025-07-19 08:50:04 -04:00
|
|
|
onend: () => newHowl.play(),
|
2025-07-17 06:55:01 -04:00
|
|
|
onplay: () => setIsPlaying(true),
|
|
|
|
onpause: () => setIsPlaying(false),
|
|
|
|
onstop: () => setIsPlaying(false),
|
2025-07-19 08:50:04 -04:00
|
|
|
onloaderror: (_, err) => console.error("Load error", err),
|
|
|
|
onplayerror: (_, err) => {
|
2025-07-17 06:55:01 -04:00
|
|
|
console.error("Play error", err);
|
2025-07-19 08:50:04 -04:00
|
|
|
setTimeout(() => newHowl.play(), 1000);
|
2025-07-17 06:55:01 -04:00
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2025-07-19 08:50:04 -04:00
|
|
|
soundRef.current = newHowl;
|
|
|
|
newHowl.play();
|
|
|
|
};
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-07-19 08:50:04 -04:00
|
|
|
const togglePlayback = () => {
|
|
|
|
if (isIOS && !userHasInteracted) return; // block only on iOS until interaction
|
|
|
|
if (isPlaying) {
|
|
|
|
soundRef.current?.pause();
|
|
|
|
setIsPlaying(false);
|
|
|
|
} else {
|
|
|
|
if (!soundRef.current) {
|
|
|
|
playStream();
|
|
|
|
} else {
|
|
|
|
soundRef.current.play();
|
2025-07-17 06:55:01 -04:00
|
|
|
}
|
2025-07-19 08:50:04 -04:00
|
|
|
setIsPlaying(true);
|
|
|
|
}
|
|
|
|
};
|
2025-07-17 06:55:01 -04:00
|
|
|
|
|
|
|
|
2025-07-19 08:50:04 -04:00
|
|
|
useEffect(() => {
|
|
|
|
if (!userHasInteracted) return;
|
|
|
|
|
2025-07-17 11:30:14 -04:00
|
|
|
if (isPlaying) {
|
2025-07-19 08:50:04 -04:00
|
|
|
playStream();
|
2025-07-17 11:30:14 -04:00
|
|
|
} else {
|
2025-07-19 08:50:04 -04:00
|
|
|
if (soundRef.current) {
|
|
|
|
soundRef.current.stop();
|
|
|
|
soundRef.current.unload();
|
|
|
|
soundRef.current = null;
|
2025-07-17 11:30:14 -04:00
|
|
|
}
|
|
|
|
}
|
2025-07-19 08:50:04 -04:00
|
|
|
}, [activeStation]);
|
2025-07-17 06:55:01 -04:00
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
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
|
|
|
<>
|
2025-07-19 08:50:04 -04:00
|
|
|
{/* Unlock overlay */}
|
|
|
|
{!userHasInteracted && isIOS && (
|
|
|
|
<div
|
|
|
|
className="fixed inset-0 z-50 bg-black bg-opacity-80 flex items-center justify-center text-white text-xl cursor-pointer"
|
|
|
|
onClick={() => {
|
|
|
|
setUserHasInteracted(true);
|
|
|
|
|
|
|
|
if (Howler.ctx?.state === "suspended") {
|
|
|
|
Howler.ctx.resume();
|
|
|
|
}
|
|
|
|
|
|
|
|
playStream(); // <-- Immediate play within same gesture
|
|
|
|
}}
|
|
|
|
|
|
|
|
>
|
|
|
|
Tap to Start Audio
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
|
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">
|
2025-07-19 08:50:04 -04:00
|
|
|
<div
|
|
|
|
className="music-control__play"
|
|
|
|
id="play"
|
|
|
|
onClick={togglePlayback}
|
|
|
|
role="button"
|
|
|
|
tabIndex={0}
|
|
|
|
aria-pressed={isPlaying}
|
|
|
|
>
|
2025-07-17 11:30:14 -04:00
|
|
|
{!isPlaying ? <Play /> : <Pause />}
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</section>
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
);
|
|
|
|
}
|