import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } 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 { Dialog } from "primereact/dialog"; import { AutoComplete } from "primereact/autocomplete"; import { DataTable } from "primereact/datatable"; import { Column } from "primereact/column"; import { API_URL } from "@/config"; import { authFetch } from "@/utils/authFetch"; import { requireAuthHook } from "@/hooks/requireAuthHook"; import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr"; import "@/components/TRip/RequestManagement.css"; const STATIONS = { main: { label: "Main" }, rock: { label: "Rock" }, rap: { label: "Rap" }, electronic: { label: "Electronic" }, pop: { label: "Pop" }, }; export default function Player({ user }) { const [isQueueVisible, setQueueVisible] = useState(false); // Mouse wheel scroll fix for queue modal useEffect(() => { if (!isQueueVisible) return; setTimeout(() => { const modalContent = document.querySelector('.p-dialog .p-dialog-content > div'); if (modalContent) { modalContent.style.overflowY = 'auto'; modalContent.style.overscrollBehavior = 'contain'; const wheelHandler = (e) => { const delta = e.deltaY; const atTop = modalContent.scrollTop === 0; const atBottom = modalContent.scrollTop + modalContent.clientHeight >= modalContent.scrollHeight; if ((delta < 0 && atTop) || (delta > 0 && atBottom)) { e.preventDefault(); } else { e.stopPropagation(); } }; modalContent.removeEventListener('wheel', wheelHandler); modalContent.addEventListener('wheel', wheelHandler, { passive: false }); } }, 0); }, [isQueueVisible]); // Debugging output // Autocomplete for requests const fetchTypeahead = async (query) => { if (!query || query.length < 2) { setTypeaheadOptions([]); console.debug('Typeahead: query too short', query); return; } try { console.debug('Typeahead: fetching for query', query); const response = await authFetch(`${API_URL}/radio/typeahead`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStation, query }), }); const data = await response.json(); console.debug('Typeahead: response data', data); setTypeaheadOptions(data.options || []); console.debug('Typeahead: setTypeaheadOptions', data.options || []); } catch (error) { console.error('Typeahead: error', error); setTypeaheadOptions([]); } }; console.debug('AudioPlayer user:', user); if (user) { console.debug('isAuthenticated:', user.isAuthenticated); console.debug('roles:', user.roles); console.debug('isDJ:', user.isAuthenticated && Array.isArray(user.roles) && user.roles.includes('dj')); } const theme = useHtmlThemeAttr(); const [activeStation, setActiveStation] = useState("main"); const [isPlaying, setIsPlaying] = useState(false); const [trackTitle, setTrackTitle] = useState(""); const [trackArtist, setTrackArtist] = useState(""); // ...existing code... 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); // Update currentLyricIndex as song progresses useEffect(() => { if (!lyrics || lyrics.length === 0) return; // Find the last lyric whose timestamp is <= elapsedTime let idx = 0; for (let i = 0; i < lyrics.length; i++) { if (lyrics[i].timestamp <= elapsedTime) { idx = i; } else { break; } } setCurrentLyricIndex(idx); }, [elapsedTime, lyrics]); const [trackDuration, setTrackDuration] = useState(0); const [queueData, setQueueData] = useState([]); const [queueTotalRecords, setQueueTotalRecords] = useState(0); const [queuePage, setQueuePage] = useState(0); const [queueRows, setQueueRows] = useState(10); // DJ controls state const [requestInput, setRequestInput] = useState(""); const [typeaheadOptions, setTypeaheadOptions] = useState([]); const [requestInputArtist, setRequestInputArtist] = useState(""); const [requestInputSong, setRequestInputSong] = useState(""); const [requestInputUuid, setRequestInputUuid] = useState(""); const audioElement = useRef(null); const hlsInstance = useRef(null); const currentTrackUuid = useRef(null); const baseTrackElapsed = useRef(0); const lastUpdateTimestamp = useRef(Date.now()); const activeStationRef = useRef(activeStation); 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} [${activeStationRef.current}]`; }; // 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: 0.5, // seconds behind live edge target liveMaxLatencyDuration: 3.0, // max allowed latency before catchup liveCatchUpPlaybackRate: 1.02, maxBufferLength: 30, maxMaxBufferLength: 60, maxBufferHole: 0.5, manifestLoadingTimeOut: 4000, fragLoadingTimeOut: 10000, // 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]); // ...existing code... // ...existing code... // Scroll active lyric into view useEffect(() => { setTimeout(() => { console.debug('Lyric scroll: currentLyricIndex', currentLyricIndex); console.debug('Lyric scroll: lyrics', lyrics); const activeElement = document.querySelector('.lrc-line.active'); const lyricsContainer = document.querySelector('.lrc-text'); console.debug('Lyric scroll: activeElement', activeElement); console.debug('Lyric scroll: lyricsContainer', lyricsContainer); if (activeElement && lyricsContainer) { lyricsContainer.style.maxHeight = '220px'; lyricsContainer.style.overflowY = 'auto'; activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); console.debug('Lyric scroll: scrolled into view'); } else { console.debug('Lyric scroll: could not scroll, missing element(s)'); } }, 0); }, [currentLyricIndex, lyrics]); // 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); // If no track is loaded, update page title to just station document.title = `${metaData.title} - Radio [${activeStation}]`; }, [activeStation]); const fetchMetadataAndLyrics = useCallback(async () => { const requestStation = activeStationRef.current; try { const response = await fetch(`${API_URL}/radio/np?station=${requestStation}`, { method: 'POST' }); const trackData = await response.json(); // If station changed while request was in-flight, ignore the response if (requestStation !== activeStationRef.current) return; if (trackData.artist === 'N/A') { setTrackTitle('Offline'); setLyrics([]); return; } if (trackData.uuid !== currentTrackUuid.current) { currentTrackUuid.current = trackData.uuid; setTrackMetadata(trackData, requestStation); 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(); // Guard against station or track changes during lyrics fetch if (requestStation !== activeStationRef.current || trackData.uuid !== currentTrackUuid.current) return; 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([]); } }, [activeStation]); const setTrackMetadata = useCallback((trackData, requestStation) => { setTrackTitle(trackData.song); setTrackArtist(trackData.artist); setTrackGenre(trackData.genre || ''); setTrackAlbum(trackData.album); setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`); baseTrackElapsed.current = trackData.elapsed; lastUpdateTimestamp.current = Date.now(); setElapsedTime(trackData.elapsed); setTrackDuration(trackData.duration); setPageTitle(trackData.artist, trackData.song); }, []); useEffect(() => { // ensure the ref points to the current activeStation for in-flight guards activeStationRef.current = activeStation; 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"; // ...existing code... const handleSkip = async (skipTo = null) => { try { const response = await authFetch(`${API_URL}/radio/skip`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStation, skipTo }), }); const data = await response.json(); if (!data.success) { console.error("Failed to skip track.", data); } } catch (error) { console.error("Error skipping track:", error); } }; const handleReshuffle = async () => { try { const response = await authFetch(`${API_URL}/radio/reshuffle`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStation }), }); const data = await response.json(); if (!data.ok) { console.error("Failed to reshuffle queue.", data); } } catch (error) { console.error("Error reshuffling queue:", error); } }; const handleQueueShift = async (uuid, playNow = false) => { try { const response = await authFetch(`${API_URL}/radio/queue_shift`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStation, uuid, playNow }), }); const data = await response.json(); if (!data.ok) { console.error("Failed to shift queue.", data); } } catch (error) { console.error("Error shifting queue:", error); } }; const handleQueueRemove = async (uuid) => { try { const response = await authFetch(`${API_URL}/radio/queue_remove`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStation, uuid }), }); const data = await response.json(); if (!data.ok) { console.error("Failed to remove from queue.", data); } } catch (error) { console.error("Error removing from queue:", error); } }; const handleSongRequest = async (artist, song) => { try { const response = await authFetch(`${API_URL}/radio/request`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStation, artist, song }), }); const data = await response.json(); if (!data.result) { console.error("Failed to request song.", data); } } catch (error) { console.error("Error requesting song:", error); } }; const [queueSearch, setQueueSearch] = useState(""); const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => { const start = page * rows; try { const response = await authFetch(`${API_URL}/radio/get_queue`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStation, start, length: rows, search, }), }); const data = await response.json(); setQueueData(data.items || []); setQueueTotalRecords(data.total || data.totalRecords || 0); } catch (error) { console.error("Error fetching queue:", error); } }; useEffect(() => { if (isQueueVisible) { fetchQueue(queuePage, queueRows, queueSearch); } }, [isQueueVisible, queuePage, queueRows, queueSearch]); // Always define queueFooter, fallback to Close button if user is not available const isDJ = user && Array.isArray(user.roles) && user.roles.includes('dj'); const queueFooter = isDJ ? (
) : (
); return ( <>
{Object.entries(STATIONS).map(([stationKey, { label }]) => ( ))}
{user && Hello, {user.user}}
{trackAlbum}
Cover Art

serious.FM

{trackTitle}

{trackArtist}

{trackGenre &&

{trackGenre}

}

{formatTime(elapsedTime)}

{formatTime(trackDuration - elapsedTime)}

{lyrics.map((lyricObj, index) => (

{lyricObj.line}

))}
{isDJ && (
{ console.debug('AutoComplete: completeMethod called', e); fetchTypeahead(e.query); }} onChange={e => { console.debug('AutoComplete: onChange', e); setRequestInput(e.target.value); }} onShow={() => { setTimeout(() => { const panel = document.querySelector('.p-autocomplete-panel'); const items = panel?.querySelector('.p-autocomplete-items'); console.debug('AutoComplete: onShow panel', panel); if (items) { items.style.maxHeight = '200px'; items.style.overflowY = 'auto'; items.style.overscrollBehavior = 'contain'; const wheelHandler = (e) => { const delta = e.deltaY; const atTop = items.scrollTop === 0; const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight; if ((delta < 0 && atTop) || (delta > 0 && atBottom)) { e.preventDefault(); } else { e.stopPropagation(); } }; items.removeEventListener('wheel', wheelHandler); items.addEventListener('wheel', wheelHandler, { passive: false }); } }, 0); }} placeholder="Request a song..." inputStyle={{ width: '14rem', padding: '0.25rem 0.5rem', borderRadius: '9999px', border: '1px solid #d1d5db', fontSize: '0.85rem', fontWeight: 'bold', }} className="typeahead-input" forceSelection={false} />
)} {/* Always show play/pause button */}
{ 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 ? : }
); }