Refactor AudioPlayer component: streamline state management, add lyric display, and improve stream handling

This commit is contained in:
2025-08-06 15:37:07 -04:00
parent d3ddfe135a
commit d501272870
2 changed files with 277 additions and 194 deletions

View File

@@ -20,7 +20,7 @@ body {
.music-container { .music-container {
width: 800px; /* fixed desktop width */ width: 800px; /* fixed desktop width */
max-width: 90vw; /* prevent overflow on smaller screens */ max-width: 90vw; /* prevent overflow on smaller screens */
height: 290px; height: auto !important;
margin: 0 auto 120px auto; /* increased bottom margin */ margin: 0 auto 120px auto; /* increased bottom margin */
overflow-x: visible; /* allow horizontal overflow if needed */ overflow-x: visible; /* allow horizontal overflow if needed */
overflow-y: hidden; overflow-y: hidden;
@@ -57,7 +57,7 @@ body {
flex: 1 1 70%; /* Take remaining ~70% */ flex: 1 1 70%; /* Take remaining ~70% */
max-width: 70%; max-width: 70%;
width: auto; width: auto;
height: 100%; /* Match container height */ height: auto !important; /* Match container height */
padding: 1em; padding: 1em;
text-align: center; text-align: center;
display: flex; display: flex;
@@ -174,6 +174,9 @@ body {
width: 3em; width: 3em;
height: 3em; height: 3em;
margin: 0 1em; margin: 0 1em;
padding-top: 5px;
padding-bottom: 5px;
margin-bottom: 5px;
} }
.music-control__backward, .music-control__backward,
@@ -219,4 +222,77 @@ body {
min-width: 0 !important; min-width: 0 !important;
flex-shrink: 0 !important; flex-shrink: 0 !important;
} }
} }
.progress-bar-container {
position: relative;
margin: 0.5rem 0;
height: 0.5rem; /* Ensure visible height */
background-color: #e5e7eb; /* fallback light gray */
border-radius: 9999px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
transition: width 0.2s linear;
border-radius: 9999px;
}
.lrc-text {
max-height: 125px; /* limit block height */
max-width: 100%;
overflow-y: auto; /* scroll if lyrics exceed height */
margin-top: 1rem;
padding: 0 1rem;
text-align: center;
font-family: "Mukta", sans-serif;
font-size: 0.85rem;
line-height: 1.4;
color: #ccc; /* subtle default color */
user-select: none; /* prevent accidental text selection */
background-color: rgba(0, 0, 0, 0.05); /* very light highlight behind text */
border-radius: 0.375rem; /* rounded corners */
box-shadow: inset 0 0 5px rgba(0,0,0,0.8);
box-sizing: border-box;
scrollbar-width: thin; /* Firefox thinner scrollbar */
scrollbar-color: #999 transparent;
}
.lrc-text::-webkit-scrollbar {
width: 6px;
}
.lrc-text::-webkit-scrollbar-track {
background: transparent;
}
.lrc-text::-webkit-scrollbar-thumb {
background-color: #999;
border-radius: 3px;
}
/* Each lyric line */
.lrc-line {
margin: 0.1rem 0;
white-space: normal;
word-wrap: break-word;
line-height: 1.4;
transition: color 0.3s ease, font-weight 0.3s ease;
color: inherit;
cursor: default;
font-size: 0.85rem;
}
/* Highlight the active lyric line */
.lrc-line.active {
color: #ffffff;
font-weight: 600;
font-size: 0.8rem;
text-shadow: 0 0 4px rgba(212, 175, 55, 0.6);
}
.lrc-line:hover {
color: #4fa2ff;
text-decoration: dotted underline;
}

View File

@@ -15,17 +15,6 @@ const STATIONS = {
pop: { label: "Pop" }, pop: { label: "Pop" },
}; };
let activeInterval = null;
let currentStationForInterval = null;
function clearGlobalMetadataInterval() {
if (activeInterval) {
clearInterval(activeInterval);
activeInterval = null;
currentStationForInterval = null;
}
}
export function Player() { export function Player() {
const [activeStation, setActiveStation] = useState("main"); const [activeStation, setActiveStation] = useState("main");
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
@@ -34,39 +23,51 @@ export function Player() {
const [trackGenre, setTrackGenre] = useState(""); const [trackGenre, setTrackGenre] = useState("");
const [trackAlbum, setTrackAlbum] = useState(""); const [trackAlbum, setTrackAlbum] = useState("");
const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg"); const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg");
const [elapsed, setElapsed] = useState(0); const [lyrics, setLyrics] = useState([]);
const [duration, setDuration] = useState(0); const [currentLyricIndex, setCurrentLyricIndex] = useState(0);
const [elapsedTime, setElapsedTime] = useState(0);
const [trackDuration, setTrackDuration] = useState(0);
const audioRef = useRef(null); const audioElement = useRef(null);
const hlsRef = useRef(null); const hlsInstance = useRef(null);
const uuidRef = useRef(null); const currentTrackUuid = useRef(null);
const lastStationData = useRef(null); const baseTrackElapsed = useRef(0);
const lastUpdateTimestamp = useRef(Date.now());
const formatTime = (seconds) => { const formatTime = (seconds) => {
const m = String(Math.floor(seconds / 60)).padStart(2, "0"); const mins = String(Math.floor(seconds / 60)).padStart(2, "0");
const s = String(Math.floor(seconds % 60)).padStart(2, "0"); const secs = String(Math.floor(seconds % 60)).padStart(2, "0");
return `${m}:${s}`; return `${mins}:${secs}`;
}; };
const progress = duration > 0 ? (elapsed / duration) * 100 : 0; // Initialize or switch HLS stream
const initializeStream = (station) => {
const playStream = () => { const audio = audioElement.current;
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) 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 = () => {
setTrackTitle("Offline");
setIsPlaying(false);
};
if (audio.canPlayType("application/vnd.apple.mpegurl")) { if (audio.canPlayType("application/vnd.apple.mpegurl")) {
audio.src = streamUrl; audio.src = streamUrl;
audio.load(); audio.load();
audio.play().then(() => setIsPlaying(true)).catch(console.error); audio.play().then(() => setIsPlaying(true)).catch(() => {
setTrackTitle("Offline");
setIsPlaying(false);
});
return; return;
} }
@@ -75,197 +76,187 @@ export function Player() {
return; return;
} }
const hls = new Hls({ const hls = new Hls({ lowLatencyMode: true, abrEnabled: false });
maxBufferLength: 30, hlsInstance.current = hls;
maxMaxBufferLength: 60,
autoStartLoad: true,
startLevel: -1,
});
hls.attachMedia(audio); hls.attachMedia(audio);
hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl));
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(streamUrl);
});
hls.on(Hls.Events.MANIFEST_PARSED, () => { hls.on(Hls.Events.MANIFEST_PARSED, () => {
audio.play().then(() => setIsPlaying(true)).catch(console.error); audio.play().then(() => setIsPlaying(true)).catch(() => {
setTrackTitle("Offline");
setIsPlaying(false);
});
}); });
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (event, data) => {
console.warn("HLS error:", data); console.warn("HLS error:", data);
if (data.fatal) { if (data.fatal) {
switch (data.type) { hls.destroy();
case Hls.ErrorTypes.NETWORK_ERROR: hlsInstance.current = null;
case Hls.ErrorTypes.MEDIA_ERROR: setTrackTitle("Offline");
case Hls.ErrorTypes.OTHER_ERROR: setIsPlaying(false);
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;
}; };
// Update elapsed time smoothly
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { if (!isPlaying) return;
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); const intervalId = setInterval(() => {
}, [isPlaying]); const now = Date.now();
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
let liveElapsed = baseTrackElapsed.current + deltaSec;
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
setElapsedTime(liveElapsed);
}, 200);
const togglePlayback = () => { return () => clearInterval(intervalId);
const audio = audioRef.current; }, [isPlaying, trackDuration]);
if (!audio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
audio.play().then(() => setIsPlaying(true)).catch(console.error);
}
};
// Use both base elapsed and audio time for precise lyric syncing
useEffect(() => { useEffect(() => {
const audio = audioRef.current; if (!isPlaying || !lyrics.length) return;
if (audio) {
audio.pause();
audio.src = "";
setIsPlaying(false);
}
playStream(); const lyricsInterval = setInterval(() => {
let newIndex = 0;
return () => { for (let i = 0; i < lyrics.length; i++) {
if (hlsRef.current) { if (elapsedTime >= lyrics[i].timestamp) newIndex = i;
hlsRef.current.destroy(); else break;
hlsRef.current = null;
} }
}; 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]); }, [activeStation]);
// Fetch metadata and lyrics periodically
useEffect(() => { useEffect(() => {
clearGlobalMetadataInterval(); const fetchMetadataAndLyrics = async () => {
currentStationForInterval = activeStation;
const setPageTitle = (artist, song) => {
document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`;
};
const fetchTrackData = async () => {
try { try {
const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { method: 'POST' });
method: "POST", const trackData = await response.json();
});
const data = await response.json();
if (currentStationForInterval !== activeStation) return; if (trackData.artist === 'N/A') {
setTrackTitle('Offline');
if (data.artist === "N/A" && data.song === "N/A") { setLyrics([]);
if (lastStationData.current !== "offline") {
setTrackTitle("Offline");
setTrackArtist("");
setTrackAlbum("");
setTrackGenre("");
setCoverArt("/images/radio_art_default.jpg");
setElapsed(0);
setDuration(0);
lastStationData.current = "offline";
}
return; return;
} }
if (data.uuid === uuidRef.current) { if (trackData.uuid !== currentTrackUuid.current) {
if (lastStationData.current === data.uuid) { currentTrackUuid.current = trackData.uuid;
setElapsed(data.elapsed);
setDuration(data.duration);
}
return;
}
uuidRef.current = data.uuid; setTrackTitle(trackData.song);
lastStationData.current = data.uuid; setTrackArtist(trackData.artist);
setTrackTitle(data.song); setTrackGenre(trackData.genre || '');
setTrackArtist(data.artist); setTrackAlbum(trackData.album);
setTrackGenre(data.genre !== "N/A" ? data.genre : ""); setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`);
setTrackAlbum(data.album);
setPageTitle(data.artist, data.song); baseTrackElapsed.current = trackData.elapsed;
setCoverArt(`${API_URL}/radio/album_art?station=${activeStation}&_=${Date.now()}`); lastUpdateTimestamp.current = Date.now();
setElapsed(data.elapsed); setElapsedTime(trackData.elapsed);
setDuration(data.duration);
setTrackDuration(trackData.duration);
setLyrics([]);
setCurrentLyricIndex(0);
// 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) { } catch (error) {
console.error("Failed to fetch track data:", error); console.error('Error fetching metadata:', error);
setTrackTitle('Offline');
setLyrics([]);
} }
}; };
fetchMetadataAndLyrics();
fetchTrackData(); const metadataInterval = setInterval(fetchMetadataAndLyrics, 700);
activeInterval = setInterval(fetchTrackData, 1000); return () => clearInterval(metadataInterval);
return () => clearGlobalMetadataInterval();
}, [activeStation]); }, [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 ( return (
<> <>
<div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative"> <div className="station-tabs flex gap-2 justify-center mb-4 flex-wrap z-10 relative">
{Object.entries(STATIONS).map(([key, { label }]) => ( {Object.entries(STATIONS).map(([stationKey, { label }]) => (
<button <button
key={key} key={stationKey}
className={`px-3 py-1 rounded-full text-sm font-semibold transition-colors ${ onClick={() => setActiveStation(stationKey)}
activeStation === key 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-800 text-white dark:bg-white dark:text-black'
: "bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white" : 'bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white'
}`} }`}
onClick={() => setActiveStation(key)} aria-pressed={activeStation === stationKey}
aria-pressed={activeStation === key}
> >
{label} {label}
</button> </button>
))} ))}
</div> </div>
<div className="c-containter"> <div className="c-containter">
<div className="music-container mt-8"> <div className="music-container mt-8">
<section className="album-cover"> <section className="album-cover">
<div className="music-player__album" title="Album">{trackAlbum}</div> <div className="music-player__album" title="Album">
<img {trackAlbum}
src={coverArt} </div>
className="cover" <img src={coverArt} className="cover" alt="Cover Art" />
title={trackAlbum ? `"${trackAlbum}" Cover Art` : "Cover Art"}
alt={trackAlbum ? `"${trackAlbum}" Cover Art` : "Cover Art"}
/>
</section> </section>
<section className="music-player"> <section className="music-player">
<h1 className="music-player__header">serious.FM</h1> <h1 className="music-player__header">serious.FM</h1>
<h1 className="music-player__title">{trackTitle}</h1> <h1 className="music-player__title">{trackTitle}</h1>
@@ -273,32 +264,48 @@ export function Player() {
{trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>} {trackGenre && <h2 className="music-player__genre">{trackGenre}</h2>}
<div className="music-time"> <div className="music-time">
<p className="music-time__current">{formatTime(elapsed)}</p> <p className="music-time__current">{formatTime(elapsedTime)}</p>
<p className="music-time__last">{formatTime(remaining)}</p> <p className="music-time__last">{formatTime(trackDuration - elapsedTime)}</p>
</div> </div>
<div className="progress-bar-container w-full h-2 rounded bg-neutral-300 overflow-hidden">
<div className="w-full h-2 rounded bg-neutral-300 dark:bg-neutral-700 overflow-hidden">
<div <div
className={`h-full transition-all duration-200 ${progressColorClass}`} className="progress-bar-fill h-full transition-all duration-200 bg-blue-500"
style={{ width: `${progress}%` }} style={{ width: `${(elapsedTime / trackDuration) * 100}%` }}
></div> />
</div>
<div className="lrc-text">
{lyrics.map((lyricObj, index) => (
<p
key={index}
className={`lrc-line ${index === currentLyricIndex ? 'active' : ''}`}
>
{lyricObj.line}
</p>
))}
</div> </div>
<div className="music-control"> <div className="music-control">
<div <div
className="music-control__play" className="music-control__play"
onClick={togglePlayback} onClick={() => {
const audio = audioElement.current;
if (!audio) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
} else {
audio.play().then(() => setIsPlaying(true));
}
}}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-pressed={isPlaying} aria-pressed={isPlaying}
> >
{!isPlaying ? <Play /> : <Pause />} {isPlaying ? <Pause /> : <Play />}
</div> </div>
</div> </div>
</section> </section>
</div> </div>
<audio ref={audioElement} preload="none" />
<audio ref={audioRef} preload="none" />
</div> </div>
</> </>
); );