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 { toast } from "react-toastify"; import { ENVIRONMENT } from "../config"; 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"; // Custom styles for paginator const paginatorStyles = ` .queue-paginator .p-paginator-current { background: transparent !important; color: inherit !important; border: none !important; } .dark .queue-paginator .p-paginator-current { color: rgb(212 212 212) !important; background: transparent !important; } .queue-paginator .p-paginator-bottom { padding: 16px !important; border-top: 1px solid rgb(229 229 229) !important; } .dark .queue-paginator .p-paginator-bottom { border-top-color: rgb(82 82 82) !important; } `; const STATIONS = { main: { label: "Main" }, rock: { label: "Rock" }, rap: { label: "Rap" }, electronic: { label: "Electronic" }, pop: { label: "Pop" }, }; export default function Player({ user }) { // Inject custom paginator styles useEffect(() => { const styleId = 'queue-paginator-styles'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = paginatorStyles; document.head.appendChild(style); } }, []); const [isQueueVisible, setQueueVisible] = useState(false); // Mouse wheel scroll fix for queue modal useEffect(() => { if (!isQueueVisible) return; const setupScrollHandlers = () => { // Target multiple possible scroll containers const selectors = [ '.p-dialog .p-dialog-content', '.p-dialog .p-datatable-wrapper', '.p-dialog .p-datatable-scrollable-body', '.p-dialog .p-datatable-tbody' ]; const elements = selectors .map(selector => document.querySelector(selector)) .filter(Boolean); // Also allow scrolling on the entire dialog const dialog = document.querySelector('.p-dialog'); if (dialog) elements.push(dialog); elements.forEach(element => { element.style.overflowY = 'auto'; element.style.overscrollBehavior = 'contain'; const wheelHandler = (e) => { e.stopPropagation(); // Allow normal scrolling within the modal }; element.removeEventListener('wheel', wheelHandler); element.addEventListener('wheel', wheelHandler, { passive: true }); }); }; // Use multiple timeouts to ensure elements are rendered setTimeout(setupScrollHandlers, 0); setTimeout(setupScrollHandlers, 100); setTimeout(setupScrollHandlers, 300); }, [isQueueVisible]); // Debugging output // Autocomplete for requests const fetchTypeahead = async (query) => { if (!query || query.length < 2) { setTypeaheadOptions([]); return; } try { 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(); // Accept bare array or { options: [...] } setTypeaheadOptions(Array.isArray(data) ? data : data.options || []); } catch (error) { console.error('Typeahead: error', error); setTypeaheadOptions([]); } }; const theme = useHtmlThemeAttr(); 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); // 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(20); // 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 wsInstance = useRef(null); const formatTime = (seconds) => { if (!seconds || isNaN(seconds) || seconds < 0) return "00:00"; 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: false, abrEnabled: false, liveSyncDuration: 0.6, // seconds behind live edge target liveMaxLatencyDuration: 3.0, // max allowed latency before catchup liveCatchUpPlaybackRate: 1.02, // maxBufferLength: 30, // maxMaxBufferLength: 60, // maxBufferHole: 2.0, // manifestLoadingTimeOut: 5000, // 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 = (typeof baseTrackElapsed.current === 'number' && !isNaN(baseTrackElapsed.current) ? baseTrackElapsed.current : 0) + deltaSec; if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration; setElapsedTime(liveElapsed); }, 200); return () => clearInterval(intervalId); }, [isPlaying, trackDuration]); // Scroll active lyric into view useEffect(() => { setTimeout(() => { const activeElement = document.querySelector('.lrc-line.active'); const lyricsContainer = document.querySelector('.lrc-text'); if (activeElement && lyricsContainer) { lyricsContainer.style.maxHeight = '220px'; lyricsContainer.style.overflowY = 'auto'; activeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 0); }, [currentLyricIndex, lyrics]); // Handle station changes: reset and start new stream useEffect(() => { // Reset metadata and state when switching stations 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); // Update page title to reflect the new station document.title = `${metaData.title} - Radio [${activeStation}]`; }, [activeStation]); const parseLrcString = useCallback((lrcString) => { if (!lrcString || typeof lrcString !== 'string') return []; const lines = lrcString.split('\n').filter(line => line.trim()); const parsedLyrics = []; for (const line of lines) { const match = line.match(/\[(\d{2}):(\d{2}\.\d{2})\]\s*(.+)/); if (match) { const [, mins, secs, text] = match; const timestamp = Number(mins) * 60 + parseFloat(secs); if (!isNaN(timestamp) && text.trim()) { parsedLyrics.push({ timestamp, line: text.trim() }); } } } return parsedLyrics.sort((a, b) => a.timestamp - b.timestamp); }, []); const handleTrackData = useCallback((trackData) => { const requestStation = activeStationRef.current; // Only set "No track playing" if we have clear indication of no track // and not just missing song field (to avoid flickering during transitions) if ((!trackData.song || trackData.song === 'N/A') && (!trackData.artist || trackData.artist === 'N/A')) { setTrackTitle('No track playing'); setLyrics([]); return; } if (trackData.uuid !== currentTrackUuid.current) { currentTrackUuid.current = trackData.uuid; setTrackMetadata(trackData, requestStation); // Clear lyrics immediately when new track is detected setLyrics([]); setCurrentLyricIndex(0); // Refresh queue when track changes fetchQueue(); } else { // Same track - update duration and elapsed time setTrackDuration(trackData.duration || 0); if (trackData.elapsed !== undefined && typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed)) { baseTrackElapsed.current = trackData.elapsed; lastUpdateTimestamp.current = Date.now(); setElapsedTime(trackData.elapsed); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const initializeWebSocket = useCallback((station) => { // Clean up existing WebSocket if (wsInstance.current) { wsInstance.current.onclose = null; // Prevent triggering reconnection logic wsInstance.current.close(); wsInstance.current = null; } const connectWebSocket = (retryCount = 0) => { const baseDelay = 1000; // 1 second const maxDelay = 30000; // 30 seconds const wsUrl = `${API_URL.replace(/^https?:/, 'wss:')}/radio/ws/${station}`; const ws = new WebSocket(wsUrl); ws.onopen = function () { // Reset retry count on successful connection }; ws.onmessage = function (event) { try { const data = JSON.parse(event.data); if (data.type === 'track_change') { // Handle track change setLyrics([]); setCurrentLyricIndex(0); handleTrackData(data.data); } else if (data.type === 'lrc') { // Handle LRC data const parsedLyrics = parseLrcString(data.data); setLyrics(parsedLyrics); setCurrentLyricIndex(0); } else { // Handle initial now playing data handleTrackData(data); } } catch (error) { console.error('Error parsing WebSocket message:', error); } }; ws.onclose = function (event) { // Don't retry if it was a clean close (code 1000) if (event.code === 1000) return; // Attempt reconnection with exponential backoff const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay); setTimeout(() => { connectWebSocket(retryCount + 1); }, delay); }; ws.onerror = function (error) { console.error('Radio WebSocket error:', error); // Don't set error state here - let onclose handle reconnection }; wsInstance.current = ws; }; connectWebSocket(); }, [handleTrackData, parseLrcString]); const setTrackMetadata = useCallback((trackData, requestStation) => { setTrackTitle(trackData.song || 'Unknown Title'); setTrackArtist(trackData.artist || 'Unknown Artist'); setTrackGenre(trackData.genre || ''); setTrackAlbum(trackData.album || 'Unknown Album'); setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`); const elapsed = typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed) ? trackData.elapsed : 0; baseTrackElapsed.current = elapsed; lastUpdateTimestamp.current = Date.now(); setElapsedTime(elapsed); setTrackDuration(trackData.duration || 0); setPageTitle(trackData.artist, trackData.song); }, []); useEffect(() => { // Ensure the ref points to the current activeStation for in-flight guards activeStationRef.current = activeStation; // Clean up the existing WebSocket connection before initializing a new one const cleanupWebSocket = async () => { if (wsInstance.current) { wsInstance.current.onclose = null; // Prevent triggering reconnection logic wsInstance.current.close(); wsInstance.current = null; } }; cleanupWebSocket().then(() => { // Initialize WebSocket connection for metadata initializeWebSocket(activeStation); }); return () => { // Clean up WebSocket on station change or component unmount if (wsInstance.current) { wsInstance.current.onclose = null; // Prevent triggering reconnection logic wsInstance.current.close(); wsInstance.current = null; } }; }, [activeStation, initializeWebSocket]); // Cleanup WebSocket on component unmount useEffect(() => { return () => { if (wsInstance.current) { wsInstance.current.close(); wsInstance.current = null; } }; }, []); const progress = trackDuration > 0 ? (elapsedTime / trackDuration) * 100 : 0; 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"; const handleSkip = async (uuid = null) => { try { const response = await authFetch(`${API_URL}/radio/skip`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ skipTo: uuid, station: activeStation, }), }); const data = await response.json(); if (data.success) { toast.success("OK!"); fetchQueue(); } else { toast.error("Skip failed."); } } catch (error) { console.error("Error skipping track:", error); toast.error("Skip failed."); } }; 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) { toast.success("OK!"); fetchQueue(); } else { toast.error("Reshuffle failed."); } } catch (error) { console.error("Error reshuffling queue:", error); toast.error("Reshuffle failed."); } }; const handleQueueShift = async (uuid, next = false) => { try { const response = await authFetch(`${API_URL}/radio/queue_shift`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uuid, next, station: activeStation, }), }); const data = await response.json(); if (!data.err) { toast.success("OK!"); fetchQueue(); } else { toast.error("Queue shift failed."); } } catch (error) { console.error("Error shifting queue:", error); toast.error("Queue shift failed."); } }; const handlePlayNow = async (artistSong, next = false) => { const toastId = "playNowToast"; // Unique ID for this toast if (!toast.isActive(toastId)) { toast.info("Trying...", { toastId }); } try { const response = await authFetch(`${API_URL}/radio/request`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ artistsong: artistSong, alsoSkip: !next, station: activeStation, }), }); const data = await response.json(); if (data.result) { setRequestInput(""); // Clear the input immediately toast.update(toastId, { render: "OK!", type: "success", autoClose: 2000 }); fetchQueue(); } else { toast.update(toastId, { render: "Play Now failed.", type: "error", autoClose: 3000 }); } } catch (error) { console.error("Error playing song immediately:", error); toast.update(toastId, { render: "Play Now failed.", type: "error", autoClose: 3000 }); } }; const handleSongRequest = async (artistSong) => { const toastId = "songRequestToast"; // Unique ID for this toast if (!toast.isActive(toastId)) { toast.info("Trying...", { toastId }); } try { const response = await authFetch(`${API_URL}/radio/request`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ artistsong: artistSong, alsoSkip: false, // Ensure alsoSkip is false for requests station: activeStation, }), }); const data = await response.json(); if (data.result) { setRequestInput(""); // Clear the input immediately toast.update(toastId, { render: "OK!", type: "success", autoClose: 2000 }); fetchQueue(); } else { toast.update(toastId, { render: "Song request failed.", type: "error", autoClose: 3000 }); } } catch (error) { console.error("Error requesting song:", error); toast.update(toastId, { render: "Song request failed.", type: "error", autoClose: 3000 }); } }; const handleRemoveFromQueue = 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.err) { toast.success("OK!"); fetchQueue(); } else { toast.error("Remove from queue failed."); } } catch (error) { console.error("Error removing from queue:", error); toast.error("Remove from queue failed."); } }; const [queueSearch, setQueueSearch] = useState(""); const fetchQueue = async (page = queuePage, rows = queueRows, search = queueSearch) => { const start = page * rows; console.log("Fetching queue for station (ref):", activeStationRef.current); try { const response = await authFetch(`${API_URL}/radio/get_queue`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ station: activeStationRef.current, // Use ref to ensure latest value start, length: rows, search, }), }); const data = await response.json(); setQueueData(data.items || []); setQueueTotalRecords( typeof search === 'string' && search.length > 0 ? data.recordsFiltered ?? data.recordsTotal ?? 0 : data.recordsTotal ?? 0 ); } catch (error) { console.error("Error fetching queue:", error); } }; useEffect(() => { if (isQueueVisible) { console.log("Fetching queue for station:", activeStation); fetchQueue(queuePage, queueRows, queueSearch); } }, [isQueueVisible, queuePage, queueRows, queueSearch, activeStation]); useEffect(() => { console.log("Active station changed to:", activeStation); if (isQueueVisible) { fetchQueue(queuePage, queueRows, queueSearch); } }, [activeStation]); useEffect(() => { if (isQueueVisible) { console.log("Track changed, refreshing queue for station:", activeStation); fetchQueue(queuePage, queueRows, queueSearch); } }, [currentTrackUuid]); // Always define queueFooter, fallback to Close button if user is not available const isDJ = (user && user.roles.includes('dj')) || ENVIRONMENT === "Dev"; 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 && (
{ fetchTypeahead(e.query); }} onChange={e => { setRequestInput(e.target.value); }} onShow={() => { setTimeout(() => { const panel = document.querySelector('.p-autocomplete-panel'); const items = panel?.querySelector('.p-autocomplete-items'); 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: '24rem', minWidth: '24rem', padding: '0.35rem 0.75rem', borderRadius: '9999px', border: '1px solid #d1d5db', fontSize: '1rem', 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 ? : }
); }