import React, { useState, useEffect, useRef, Suspense, lazy } from "react"; import { metaData } from "../config"; import Play from "@mui/icons-material/PlayArrow"; import Pause from "@mui/icons-material/Pause"; import "@styles/player.css"; import { API_URL } from "@/config"; const STATIONS = { main: { label: "Main" }, rock: { label: "Rock" }, rap: { label: "Rap" }, electronic: { label: "Electronic" }, pop: { label: "Pop" }, }; export default 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 [lyrics, setLyrics] = useState([]); const [currentLyricIndex, setCurrentLyricIndex] = useState(0); const [elapsedTime, setElapsedTime] = useState(0); const [trackDuration, setTrackDuration] = useState(0); const audioElement = useRef(null); const hlsInstance = useRef(null); const currentTrackUuid = useRef(null); const baseTrackElapsed = useRef(0); const lastUpdateTimestamp = useRef(Date.now()); const formatTime = (seconds) => { const mins = String(Math.floor(seconds / 60)).padStart(2, "0"); const secs = String(Math.floor(seconds % 60)).padStart(2, "0"); return `${mins}:${secs}`; }; // Set page title based on current track and station const setPageTitle = (artist, song) => { document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`; }; // Initialize or switch HLS stream const initializeStream = (station) => { 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"); audio.load(); // Handle audio load errors audio.onerror = () => { setIsPlaying(false); }; if (audio.canPlayType("application/vnd.apple.mpegurl")) { audio.src = streamUrl; audio.load(); audio.play().then(() => setIsPlaying(true)).catch(() => { setTrackTitle("Offline"); setIsPlaying(false); }); return; } if (!Hls.isSupported()) { console.error("HLS not supported"); return; } const hls = new Hls({ lowLatencyMode: true, abrEnabled: false, liveSyncDuration: 1.0, // seconds behind live edge target liveMaxLatencyDuration: 2.0, // 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); } }); }); }; // Update elapsed time smoothly useEffect(() => { 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); return () => clearInterval(intervalId); }, [isPlaying, trackDuration]); // Use both base elapsed and audio time for precise lyric syncing useEffect(() => { if (!isPlaying || !lyrics.length) return; const lyricsInterval = setInterval(() => { let newIndex = 0; for (let i = 0; i < lyrics.length; i++) { if (elapsedTime >= lyrics[i].timestamp) newIndex = i; else break; } setCurrentLyricIndex(newIndex); }, 200); return () => clearInterval(lyricsInterval); }, [isPlaying, lyrics, elapsedTime]); // Scroll active lyric into view useEffect(() => { const activeElement = document.querySelector('.lrc-line.active'); if (activeElement) { activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [currentLyricIndex]); // 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]); // Fetch metadata and lyrics periodically useEffect(() => { const fetchMetadataAndLyrics = async () => { try { 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([]); return; } 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); setPageTitle(trackData.artist, trackData.song); // 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); } } else { // Same track - do not update elapsed or elapsed base here setTrackDuration(trackData.duration); } } catch (error) { console.error('Error fetching metadata:', error); setTrackTitle('Offline'); setLyrics([]); } }; fetchMetadataAndLyrics(); const metadataInterval = setInterval(fetchMetadataAndLyrics, 700); return () => clearInterval(metadataInterval); }, [activeStation]); 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"; return ( <>
{formatTime(elapsedTime)}
{formatTime(trackDuration - elapsedTime)}
{lyricObj.line}
))}