From d501272870ef420dbcdf27f0336962faf0fa0045 Mon Sep 17 00:00:00 2001 From: codey Date: Wed, 6 Aug 2025 15:37:07 -0400 Subject: [PATCH] Refactor AudioPlayer component: streamline state management, add lyric display, and improve stream handling --- src/assets/styles/player.css | 82 ++++++- src/components/AudioPlayer.jsx | 389 +++++++++++++++++---------------- 2 files changed, 277 insertions(+), 194 deletions(-) diff --git a/src/assets/styles/player.css b/src/assets/styles/player.css index 8ce45ed..ee3f006 100644 --- a/src/assets/styles/player.css +++ b/src/assets/styles/player.css @@ -20,7 +20,7 @@ body { .music-container { width: 800px; /* fixed desktop width */ max-width: 90vw; /* prevent overflow on smaller screens */ - height: 290px; + height: auto !important; margin: 0 auto 120px auto; /* increased bottom margin */ overflow-x: visible; /* allow horizontal overflow if needed */ overflow-y: hidden; @@ -57,7 +57,7 @@ body { flex: 1 1 70%; /* Take remaining ~70% */ max-width: 70%; width: auto; - height: 100%; /* Match container height */ + height: auto !important; /* Match container height */ padding: 1em; text-align: center; display: flex; @@ -174,6 +174,9 @@ body { width: 3em; height: 3em; margin: 0 1em; + padding-top: 5px; + padding-bottom: 5px; + margin-bottom: 5px; } .music-control__backward, @@ -219,4 +222,77 @@ body { min-width: 0 !important; flex-shrink: 0 !important; } -} \ No newline at end of file +} +.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; +} diff --git a/src/components/AudioPlayer.jsx b/src/components/AudioPlayer.jsx index 9d3ec17..d61a659 100644 --- a/src/components/AudioPlayer.jsx +++ b/src/components/AudioPlayer.jsx @@ -15,17 +15,6 @@ const STATIONS = { 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); @@ -34,39 +23,51 @@ export function Player() { 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 [lyrics, setLyrics] = useState([]); + const [currentLyricIndex, setCurrentLyricIndex] = useState(0); + const [elapsedTime, setElapsedTime] = useState(0); + const [trackDuration, setTrackDuration] = useState(0); - const audioRef = useRef(null); - const hlsRef = useRef(null); - const uuidRef = useRef(null); - const lastStationData = useRef(null); + 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 m = String(Math.floor(seconds / 60)).padStart(2, "0"); - const s = String(Math.floor(seconds % 60)).padStart(2, "0"); - return `${m}:${s}`; + const mins = String(Math.floor(seconds / 60)).padStart(2, "0"); + const secs = String(Math.floor(seconds % 60)).padStart(2, "0"); + return `${mins}:${secs}`; }; - 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; + // Initialize or switch HLS stream + const initializeStream = (station) => { + 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 = () => { + setTrackTitle("Offline"); + setIsPlaying(false); + }; if (audio.canPlayType("application/vnd.apple.mpegurl")) { audio.src = streamUrl; audio.load(); - audio.play().then(() => setIsPlaying(true)).catch(console.error); + audio.play().then(() => setIsPlaying(true)).catch(() => { + setTrackTitle("Offline"); + setIsPlaying(false); + }); return; } @@ -75,197 +76,187 @@ export function Player() { return; } - const hls = new Hls({ - maxBufferLength: 30, - maxMaxBufferLength: 60, - autoStartLoad: true, - startLevel: -1, - }); - + const hls = new Hls({ lowLatencyMode: true, abrEnabled: false }); + hlsInstance.current = hls; 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, () => { - 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) => { 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); - } + hls.destroy(); + hlsInstance.current = null; + setTrackTitle("Offline"); + setIsPlaying(false); } }); - - hlsRef.current = hls; }; + // Update elapsed time smoothly 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 + if (!isPlaying) return; - return () => clearInterval(interval); -}, [isPlaying]); + 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); - const togglePlayback = () => { - const audio = audioRef.current; - if (!audio) return; + return () => clearInterval(intervalId); + }, [isPlaying, trackDuration]); - 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(() => { - const audio = audioRef.current; - if (audio) { - audio.pause(); - audio.src = ""; - setIsPlaying(false); - } + if (!isPlaying || !lyrics.length) return; - playStream(); - - return () => { - if (hlsRef.current) { - hlsRef.current.destroy(); - hlsRef.current = null; + 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(() => { - clearGlobalMetadataInterval(); - currentStationForInterval = activeStation; - - const setPageTitle = (artist, song) => { - document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStation}]`; - }; - - const fetchTrackData = async () => { + const fetchMetadataAndLyrics = async () => { try { - const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { - method: "POST", - }); - const data = await response.json(); + const response = await fetch(`${API_URL}/radio/np?station=${activeStation}`, { method: 'POST' }); + const trackData = 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"; - } + if (trackData.artist === 'N/A') { + setTrackTitle('Offline'); + setLyrics([]); return; } - if (data.uuid === uuidRef.current) { - if (lastStationData.current === data.uuid) { - setElapsed(data.elapsed); - setDuration(data.duration); - } - return; - } + if (trackData.uuid !== currentTrackUuid.current) { + currentTrackUuid.current = trackData.uuid; - 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); + 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); + + // 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("Failed to fetch track data:", error); + console.error('Error fetching metadata:', error); + setTrackTitle('Offline'); + setLyrics([]); } }; - - fetchTrackData(); - activeInterval = setInterval(fetchTrackData, 1000); - - return () => clearGlobalMetadataInterval(); + fetchMetadataAndLyrics(); + const metadataInterval = setInterval(fetchMetadataAndLyrics, 700); + return () => clearInterval(metadataInterval); }, [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 ( <>
- {Object.entries(STATIONS).map(([key, { label }]) => ( + {Object.entries(STATIONS).map(([stationKey, { label }]) => ( ))}
-
-
{trackAlbum}
- {trackAlbum +
+ {trackAlbum} +
+ Cover Art
-

serious.FM

{trackTitle}

@@ -273,32 +264,48 @@ export function Player() { {trackGenre &&

{trackGenre}

}
-

{formatTime(elapsed)}

-

{formatTime(remaining)}

+

{formatTime(elapsedTime)}

+

{formatTime(trackDuration - elapsedTime)}

- -
+
+ className="progress-bar-fill h-full transition-all duration-200 bg-blue-500" + style={{ width: `${(elapsedTime / trackDuration) * 100}%` }} + /> +
+
+ {lyrics.map((lyricObj, index) => ( +

+ {lyricObj.line} +

+ ))}
-
{ + const audio = audioElement.current; + if (!audio) return; + if (isPlaying) { + audio.pause(); + setIsPlaying(false); + } else { + audio.play().then(() => setIsPlaying(true)); + } + }} role="button" tabIndex={0} aria-pressed={isPlaying} > - {!isPlaying ? : } + {isPlaying ? : }
- -
);