2025-08-09 07:10:04 -04:00
|
|
|
import React, { useState, useEffect, useRef, Suspense, lazy } from "react";
|
2025-07-24 10:06:36 -04:00
|
|
|
import { metaData } from "../config";
|
2025-07-17 06:55:01 -04:00
|
|
|
import Play from "@mui/icons-material/PlayArrow";
|
|
|
|
import Pause from "@mui/icons-material/Pause";
|
|
|
|
import "@styles/player.css";
|
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
import { API_URL } from "@/config";
|
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
const STATIONS = {
|
2025-07-30 07:58:44 -04:00
|
|
|
main: { label: "Main" },
|
|
|
|
rock: { label: "Rock" },
|
|
|
|
rap: { label: "Rap" },
|
|
|
|
electronic: { label: "Electronic" },
|
|
|
|
classical: { label: "Classical" },
|
|
|
|
pop: { label: "Pop" },
|
2025-07-17 06:55:01 -04:00
|
|
|
};
|
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
export default 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("");
|
2025-07-21 16:00:49 -04:00
|
|
|
const [trackAlbum, setTrackAlbum] = useState("");
|
2025-07-17 06:55:01 -04:00
|
|
|
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
|
2025-08-06 15:37:07 -04:00
|
|
|
const [lyrics, setLyrics] = useState([]);
|
|
|
|
const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
|
|
|
|
const [elapsedTime, setElapsedTime] = useState(0);
|
|
|
|
const [trackDuration, setTrackDuration] = useState(0);
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
const audioElement = useRef(null);
|
|
|
|
const hlsInstance = useRef(null);
|
|
|
|
const currentTrackUuid = useRef(null);
|
|
|
|
const baseTrackElapsed = useRef(0);
|
|
|
|
const lastUpdateTimestamp = useRef(Date.now());
|
2025-07-17 06:55:01 -04:00
|
|
|
|
|
|
|
const formatTime = (seconds) => {
|
2025-08-06 15:37:07 -04:00
|
|
|
const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
|
|
|
|
const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
|
|
|
|
return `${mins}:${secs}`;
|
2025-07-17 06:55:01 -04:00
|
|
|
};
|
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
// Set page title based on current track and station
|
|
|
|
const setPageTitle = (artist, song) => {
|
|
|
|
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`;
|
|
|
|
};
|
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Initialize or switch HLS stream
|
|
|
|
const initializeStream = (station) => {
|
2025-08-09 07:10:04 -04:00
|
|
|
import('hls.js').then(({ default: Hls }) => {
|
|
|
|
const audio = audioElement.current;
|
|
|
|
if (!audio) return;
|
|
|
|
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
|
|
|
|
|
|
|
|
// Clean up previous HLS
|
|
|
|
if (hlsInstance.current) {
|
|
|
|
hlsInstance.current.destroy();
|
|
|
|
hlsInstance.current = null;
|
|
|
|
}
|
|
|
|
audio.pause();
|
|
|
|
audio.removeAttribute("src");
|
2025-07-30 08:58:18 -04:00
|
|
|
audio.load();
|
2025-08-09 07:10:04 -04:00
|
|
|
|
|
|
|
// Handle audio load errors
|
|
|
|
audio.onerror = () => {
|
2025-08-06 15:37:07 -04:00
|
|
|
setIsPlaying(false);
|
2025-08-09 07:10:04 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
if (audio.canPlayType("application/vnd.apple.mpegurl")) {
|
|
|
|
audio.src = streamUrl;
|
|
|
|
audio.load();
|
|
|
|
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
|
|
|
setTrackTitle("Offline");
|
|
|
|
setIsPlaying(false);
|
|
|
|
});
|
|
|
|
return;
|
|
|
|
}
|
2025-07-30 08:58:18 -04:00
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
if (!Hls.isSupported()) {
|
|
|
|
console.error("HLS not supported");
|
|
|
|
return;
|
|
|
|
}
|
2025-07-30 08:06:24 -04:00
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
const hls = new Hls({
|
|
|
|
lowLatencyMode: true,
|
|
|
|
abrEnabled: false,
|
|
|
|
liveSyncDuration: 3, // seconds behind live edge target
|
|
|
|
liveMaxLatencyDuration: 10, // max allowed latency before catchup
|
|
|
|
liveCatchUpPlaybackRate: 1.05, // playback speed when catching up
|
|
|
|
});
|
|
|
|
|
|
|
|
hlsInstance.current = hls;
|
|
|
|
hls.attachMedia(audio);
|
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
|
|
audio.play().then(() => setIsPlaying(true)).catch(() => {
|
|
|
|
setIsPlaying(false);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
hls.on(Hls.Events.ERROR, (event, data) => {
|
|
|
|
console.warn("HLS error:", data);
|
|
|
|
if (data.fatal) {
|
|
|
|
hls.destroy();
|
|
|
|
hlsInstance.current = null;
|
|
|
|
setTrackTitle("Offline");
|
|
|
|
setIsPlaying(false);
|
|
|
|
}
|
2025-08-06 15:37:07 -04:00
|
|
|
});
|
2025-07-30 08:58:18 -04:00
|
|
|
});
|
2025-07-19 08:50:04 -04:00
|
|
|
};
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Update elapsed time smoothly
|
2025-07-30 08:58:18 -04:00
|
|
|
useEffect(() => {
|
2025-08-06 15:37:07 -04:00
|
|
|
if (!isPlaying) return;
|
2025-07-30 08:58:18 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
const intervalId = setInterval(() => {
|
|
|
|
const now = Date.now();
|
|
|
|
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
|
|
|
|
let liveElapsed = baseTrackElapsed.current + deltaSec;
|
|
|
|
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
|
|
|
|
setElapsedTime(liveElapsed);
|
|
|
|
}, 200);
|
2025-07-30 08:58:18 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
return () => clearInterval(intervalId);
|
|
|
|
}, [isPlaying, trackDuration]);
|
2025-07-30 07:58:44 -04:00
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-07-19 08:58:36 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Use both base elapsed and audio time for precise lyric syncing
|
|
|
|
useEffect(() => {
|
|
|
|
if (!isPlaying || !lyrics.length) return;
|
2025-07-19 08:50:04 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
const lyricsInterval = setInterval(() => {
|
|
|
|
let newIndex = 0;
|
|
|
|
for (let i = 0; i < lyrics.length; i++) {
|
|
|
|
if (elapsedTime >= lyrics[i].timestamp) newIndex = i;
|
|
|
|
else break;
|
2025-07-30 07:58:44 -04:00
|
|
|
}
|
2025-08-06 15:37:07 -04:00
|
|
|
setCurrentLyricIndex(newIndex);
|
|
|
|
}, 200);
|
|
|
|
|
|
|
|
return () => clearInterval(lyricsInterval);
|
|
|
|
}, [isPlaying, lyrics, elapsedTime]);
|
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
|
|
|
|
|
|
|
|
// Scroll active lyric into view
|
2025-07-17 06:55:01 -04:00
|
|
|
useEffect(() => {
|
2025-08-06 15:37:07 -04:00
|
|
|
const activeElement = document.querySelector('.lrc-line.active');
|
|
|
|
if (activeElement) {
|
|
|
|
activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
}
|
|
|
|
}, [currentLyricIndex]);
|
2025-07-17 06:55:01 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Handle station changes: reset and start new stream
|
|
|
|
useEffect(() => {
|
|
|
|
setIsPlaying(false);
|
|
|
|
setTrackTitle("");
|
|
|
|
setTrackArtist("");
|
|
|
|
setTrackGenre("");
|
|
|
|
setTrackAlbum("");
|
|
|
|
setCoverArt("/images/radio_art_default.jpg");
|
|
|
|
|
|
|
|
setLyrics([]);
|
|
|
|
setCurrentLyricIndex(0);
|
|
|
|
setElapsedTime(0);
|
|
|
|
setTrackDuration(0);
|
|
|
|
|
|
|
|
currentTrackUuid.current = null;
|
|
|
|
baseTrackElapsed.current = 0;
|
|
|
|
lastUpdateTimestamp.current = Date.now();
|
|
|
|
|
|
|
|
initializeStream(activeStation);
|
|
|
|
}, [activeStation]);
|
2025-07-24 10:06:36 -04:00
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
// Fetch metadata and lyrics periodically
|
|
|
|
useEffect(() => {
|
|
|
|
const fetchMetadataAndLyrics = async () => {
|
2025-07-17 06:55:01 -04:00
|
|
|
try {
|
2025-08-06 15:37:07 -04:00
|
|
|
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { method: 'POST' });
|
|
|
|
const trackData = await response.json();
|
|
|
|
|
|
|
|
if (trackData.artist === 'N/A') {
|
|
|
|
setTrackTitle('Offline');
|
|
|
|
setLyrics([]);
|
2025-07-17 06:55:01 -04:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2025-08-06 15:37:07 -04:00
|
|
|
if (trackData.uuid !== currentTrackUuid.current) {
|
|
|
|
currentTrackUuid.current = trackData.uuid;
|
|
|
|
|
|
|
|
setTrackTitle(trackData.song);
|
|
|
|
setTrackArtist(trackData.artist);
|
|
|
|
setTrackGenre(trackData.genre || '');
|
|
|
|
setTrackAlbum(trackData.album);
|
|
|
|
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
|
|
|
|
|
|
|
|
baseTrackElapsed.current = trackData.elapsed;
|
|
|
|
lastUpdateTimestamp.current = Date.now();
|
|
|
|
setElapsedTime(trackData.elapsed);
|
|
|
|
|
|
|
|
setTrackDuration(trackData.duration);
|
|
|
|
|
|
|
|
setLyrics([]);
|
|
|
|
setCurrentLyricIndex(0);
|
2025-08-09 07:10:04 -04:00
|
|
|
setPageTitle(trackData.artist, trackData.song);
|
2025-08-06 15:37:07 -04:00
|
|
|
|
|
|
|
// Fetch lyrics as before
|
|
|
|
const lyricsResponse = await fetch(`${API_URL}/lyric/search`, {
|
|
|
|
method: 'POST',
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
body: JSON.stringify({
|
|
|
|
a: trackData.artist,
|
|
|
|
s: trackData.song,
|
|
|
|
excluded_sources: [],
|
|
|
|
extra: false,
|
|
|
|
lrc: true,
|
|
|
|
src: 'WEB-RADIO',
|
|
|
|
}),
|
|
|
|
});
|
|
|
|
const lyricsData = await lyricsResponse.json();
|
|
|
|
if (!lyricsData.err && Array.isArray(lyricsData.lrc)) {
|
|
|
|
const parsedLyrics = lyricsData.lrc.map(({ timeTag, words }) => {
|
|
|
|
const [mins, rest] = timeTag.split(':');
|
|
|
|
const secs = parseFloat(rest);
|
|
|
|
return { timestamp: Number(mins) * 60 + secs, line: words };
|
|
|
|
});
|
|
|
|
setLyrics(parsedLyrics);
|
|
|
|
setCurrentLyricIndex(0);
|
2025-07-17 06:55:01 -04:00
|
|
|
}
|
2025-08-06 15:37:07 -04:00
|
|
|
} else {
|
|
|
|
// Same track - do not update elapsed or elapsed base here
|
|
|
|
setTrackDuration(trackData.duration);
|
2025-07-17 06:55:01 -04:00
|
|
|
}
|
|
|
|
} catch (error) {
|
2025-08-06 15:37:07 -04:00
|
|
|
console.error('Error fetching metadata:', error);
|
|
|
|
setTrackTitle('Offline');
|
|
|
|
setLyrics([]);
|
2025-07-17 06:55:01 -04:00
|
|
|
}
|
|
|
|
};
|
2025-08-06 15:37:07 -04:00
|
|
|
fetchMetadataAndLyrics();
|
|
|
|
const metadataInterval = setInterval(fetchMetadataAndLyrics, 700);
|
|
|
|
return () => clearInterval(metadataInterval);
|
2025-07-17 06:55:01 -04:00
|
|
|
}, [activeStation]);
|
|
|
|
|
2025-08-09 07:10:04 -04:00
|
|
|
const progress = (elapsedTime / trackDuration) * 100;
|
|
|
|
const remaining = trackDuration - elapsedTime;
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
2025-07-24 10:06:36 -04:00
|
|
|
|
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">
|
2025-08-06 15:37:07 -04:00
|
|
|
{Object.entries(STATIONS).map(([stationKey, { label }]) => (
|
2025-07-17 06:55:01 -04:00
|
|
|
<button
|
2025-08-06 15:37:07 -04:00
|
|
|
key={stationKey}
|
|
|
|
onClick={() => setActiveStation(stationKey)}
|
|
|
|
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${activeStation === stationKey
|
|
|
|
? 'bg-neutral-800 text-white dark:bg-white dark:text-black'
|
|
|
|
: 'bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white'
|
|
|
|
}`}
|
|
|
|
aria-pressed={activeStation === stationKey}
|
2025-07-17 06:55:01 -04:00
|
|
|
>
|
|
|
|
{label}
|
|
|
|
</button>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
<div className="c-containter">
|
|
|
|
<div className="music-container mt-8">
|
|
|
|
<section className="album-cover">
|
2025-08-06 15:37:07 -04:00
|
|
|
<div className="music-player__album" title="Album">
|
|
|
|
{trackAlbum}
|
|
|
|
</div>
|
|
|
|
<img src={coverArt} className="cover" alt="Cover Art" />
|
2025-07-17 06:55:01 -04:00
|
|
|
</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>}
|
2025-07-30 07:58:44 -04:00
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
<div className="music-time">
|
2025-08-06 15:37:07 -04:00
|
|
|
<p className="music-time__current">{formatTime(elapsedTime)}</p>
|
|
|
|
<p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
2025-08-09 07:10:04 -04:00
|
|
|
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
|
2025-07-30 07:58:44 -04:00
|
|
|
<div
|
2025-08-09 07:10:04 -04:00
|
|
|
className={`h-full transition-all duration-200 ${progressColorClass}`}
|
|
|
|
style={{ width: `${progress}%` }}
|
|
|
|
></div>
|
2025-08-06 15:37:07 -04:00
|
|
|
</div>
|
2025-08-09 07:10:04 -04:00
|
|
|
|
2025-08-06 15:43:45 -04:00
|
|
|
<div className={`lrc-text ${lyrics.length === 0 ? 'empty' : ''}`}>
|
2025-08-06 15:37:07 -04:00
|
|
|
{lyrics.map((lyricObj, index) => (
|
|
|
|
<p
|
|
|
|
key={index}
|
|
|
|
className={`lrc-line ${index === currentLyricIndex ? 'active' : ''}`}
|
|
|
|
>
|
|
|
|
{lyricObj.line}
|
|
|
|
</p>
|
|
|
|
))}
|
2025-07-30 07:58:44 -04:00
|
|
|
</div>
|
2025-08-06 15:43:45 -04:00
|
|
|
|
2025-07-17 06:55:01 -04:00
|
|
|
<div className="music-control">
|
2025-07-19 08:50:04 -04:00
|
|
|
<div
|
|
|
|
className="music-control__play"
|
2025-08-06 15:37:07 -04:00
|
|
|
onClick={() => {
|
|
|
|
const audio = audioElement.current;
|
|
|
|
if (!audio) return;
|
|
|
|
if (isPlaying) {
|
|
|
|
audio.pause();
|
|
|
|
setIsPlaying(false);
|
|
|
|
} else {
|
|
|
|
audio.play().then(() => setIsPlaying(true));
|
|
|
|
}
|
|
|
|
}}
|
2025-07-19 08:50:04 -04:00
|
|
|
role="button"
|
|
|
|
tabIndex={0}
|
|
|
|
aria-pressed={isPlaying}
|
|
|
|
>
|
2025-08-06 15:37:07 -04:00
|
|
|
{isPlaying ? <Pause /> : <Play />}
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</section>
|
2025-06-18 07:46:59 -04:00
|
|
|
</div>
|
2025-08-06 15:37:07 -04:00
|
|
|
<audio ref={audioElement} preload="none" />
|
2025-07-17 06:55:01 -04:00
|
|
|
</div>
|
|
|
|
</>
|
|
|
|
);
|
2025-07-30 07:58:44 -04:00
|
|
|
}
|