Enhance AudioPlayer with custom paginator styles, improved scroll handling, and WebSocket integration for real-time track updates. Refactor typeahead and queue management functionalities for better performance and user experience.

This commit is contained in:
2025-09-26 11:36:33 -04:00
parent c034a0c4ff
commit 153f50f774

View File

@@ -15,6 +15,29 @@ 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" },
@@ -25,61 +48,79 @@ const STATIONS = {
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;
setTimeout(() => {
const modalContent = document.querySelector('.p-dialog .p-dialog-content > div');
if (modalContent) {
modalContent.style.overflowY = 'auto';
modalContent.style.overscrollBehavior = 'contain';
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) => {
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();
}
e.stopPropagation();
// Allow normal scrolling within the modal
};
modalContent.removeEventListener('wheel', wheelHandler);
modalContent.addEventListener('wheel', wheelHandler, { passive: false });
}
}, 0);
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([]);
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);
// Accept bare array or { options: [...] }
setTypeaheadOptions(Array.isArray(data) ? data : data.options || []);
console.debug('Typeahead: setTypeaheadOptions', Array.isArray(data) ? data : data.options || []);
} catch (error) {
console.error('Typeahead: error', error);
setTypeaheadOptions([]);
}
};
console.debug('AudioPlayer user:', user);
if (user) {
console.debug('isAuthenticated:', user);
console.debug('roles:', user.roles);
console.debug('isDJ:', user && Array.isArray(user.roles) && user.roles.includes('dj'));
}
const theme = useHtmlThemeAttr();
const [activeStation, setActiveStation] = useState("main");
@@ -110,7 +151,7 @@ export default function Player({ user }) {
const [queueData, setQueueData] = useState([]);
const [queueTotalRecords, setQueueTotalRecords] = useState(0);
const [queuePage, setQueuePage] = useState(0);
const [queueRows, setQueueRows] = useState(10);
const [queueRows, setQueueRows] = useState(20);
// DJ controls state
const [requestInput, setRequestInput] = useState("");
@@ -125,8 +166,10 @@ export default function Player({ user }) {
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}`;
@@ -211,7 +254,7 @@ export default function Player({ user }) {
const intervalId = setInterval(() => {
const now = Date.now();
const deltaSec = (now - lastUpdateTimestamp.current) / 1000;
let liveElapsed = baseTrackElapsed.current + deltaSec;
let liveElapsed = (typeof baseTrackElapsed.current === 'number' && !isNaN(baseTrackElapsed.current) ? baseTrackElapsed.current : 0) + deltaSec;
if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration;
setElapsedTime(liveElapsed);
}, 200);
@@ -223,19 +266,12 @@ export default function Player({ user }) {
// 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]);
@@ -263,65 +299,170 @@ export default function Player({ user }) {
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();
const parseLrcString = useCallback((lrcString) => {
if (!lrcString || typeof lrcString !== 'string') return [];
// If station changed while request was in-flight, ignore the response
if (requestStation !== activeStationRef.current) return;
const lines = lrcString.split('\n').filter(line => line.trim());
const parsedLyrics = [];
if (!trackData.song || trackData.song === 'N/A') {
setTrackTitle('No track playing');
setLyrics([]);
return;
}
if (trackData.uuid !== currentTrackUuid.current) {
currentTrackUuid.current = trackData.uuid;
setTrackMetadata(trackData, requestStation);
// Refresh queue when track changes
fetchQueue();
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);
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() });
}
} else {
// Same track - do not update elapsed or elapsed base here
setTrackDuration(trackData.duration);
}
}
return parsedLyrics.sort((a, b) => a.timestamp - b.timestamp);
}, []);
const fetchLyrics = useCallback(async (trackData, requestStation) => {
try {
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);
}
} catch (error) {
console.error('Error fetching metadata:', error);
setTrackTitle('Error fetching track');
setLyrics([]);
console.error('Error fetching lyrics:', error);
}
}, [activeStation]);
}, []);
const handleTrackData = useCallback((trackData) => {
console.debug('handleTrackData called with:', 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')) {
console.debug('Setting no track playing state');
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();
// Fetch lyrics for the new track
fetchLyrics(trackData, requestStation);
} 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
}, [fetchLyrics]);
const initializeWebSocket = useCallback((station) => {
// Clean up existing WebSocket
if (wsInstance.current) {
wsInstance.current.close();
wsInstance.current = null;
}
const connectWebSocket = (retryCount = 0) => {
const maxRetries = 5;
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 () {
console.log('Connected to radio WebSocket for station:', station);
// 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
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) {
console.log('Radio WebSocket connection closed, code:', event.code, 'reason:', event.reason);
// Don't retry if it was a clean close (code 1000)
if (event.code === 1000) return;
// Attempt reconnection with exponential backoff
if (retryCount < maxRetries) {
const delay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
console.log(`Attempting WebSocket reconnection in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
setTimeout(() => {
connectWebSocket(retryCount + 1);
}, delay);
} else {
console.error('Max WebSocket reconnection attempts reached');
setTrackTitle('Connection lost');
setLyrics([]);
}
};
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');
@@ -330,23 +471,42 @@ export default function Player({ user }) {
setTrackAlbum(trackData.album || 'Unknown Album');
setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`);
baseTrackElapsed.current = trackData.elapsed;
const elapsed = typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed) ? trackData.elapsed : 0;
baseTrackElapsed.current = elapsed;
lastUpdateTimestamp.current = Date.now();
setElapsedTime(trackData.elapsed);
setElapsedTime(elapsed);
setTrackDuration(trackData.duration);
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;
fetchMetadataAndLyrics();
const metadataInterval = setInterval(fetchMetadataAndLyrics, 700);
return () => clearInterval(metadataInterval);
}, [activeStation]);
const progress = (elapsedTime / trackDuration) * 100;
// Initialize WebSocket connection for metadata
initializeWebSocket(activeStation);
return () => {
// Clean up WebSocket on station change or component unmount
if (wsInstance.current) {
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 =
@@ -486,18 +646,16 @@ export default function Player({ user }) {
};
const handleRemoveFromQueue = async (uuid) => {
console.debug("handleRemoveFromQueue called with uuid:", uuid); // Debugging log
try {
const response = await authFetch(`${API_URL}/radio/queue_remove`, {
const response = await authFetch(`${API_URL}/radio/remove_from_queue`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
uuid,
station: activeStation,
uuid,
}),
});
const data = await response.json();
console.debug("handleRemoveFromQueue response:", data); // Debugging log
if (!data.err) {
toast.success("OK!");
fetchQueue();
@@ -637,18 +795,15 @@ export default function Player({ user }) {
value={requestInput}
suggestions={typeaheadOptions}
completeMethod={(e) => {
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';
@@ -750,14 +905,17 @@ export default function Player({ user }) {
<DataTable
value={queueData}
paginator
alwaysShowPaginator={true}
rows={queueRows}
first={queuePage * queueRows}
totalRecords={queueTotalRecords}
onPage={(e) => {
setQueuePage(e.page);
setQueueRows(e.rows);
fetchQueue(e.page, e.rows, queueSearch);
fetchQueue(e.page, queueRows, queueSearch);
}}
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport"
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} entries"
paginatorClassName="queue-paginator !bg-neutral-50 dark:!bg-neutral-800 !text-neutral-900 dark:!text-neutral-100 !border-neutral-200 dark:!border-neutral-700"
className="p-datatable-gridlines rounded-lg shadow-md border-t border-neutral-300 dark:border-neutral-700"
style={{ minHeight: 'auto', height: 'auto', tableLayout: 'fixed', width: '100%' }}
lazy
@@ -776,7 +934,7 @@ export default function Player({ user }) {
sortable
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
style={{ width: '300px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
body={(rowData) => <span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artistsong}</span>}
body={(rowData) => <span title={rowData.artistsong} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '300px' }}>{rowData.artistsong}</span>}
></Column>
<Column
field="album"
@@ -784,7 +942,7 @@ export default function Player({ user }) {
sortable
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
style={{ width: '220px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
body={(rowData) => <span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '220px' }}>{rowData.album}</span>}
body={(rowData) => <span title={rowData.album} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '220px' }}>{rowData.album}</span>}
></Column>
<Column
field="genre"
@@ -792,7 +950,7 @@ export default function Player({ user }) {
sortable
headerClassName="bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 font-bold font-sans"
style={{ width: '120px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
body={(rowData) => <span style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>{rowData.genre}</span>}
body={(rowData) => <span title={rowData.genre} style={{ display: 'block', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '120px' }}>{rowData.genre}</span>}
></Column>
{isDJ && (
<Column
@@ -824,7 +982,6 @@ export default function Player({ user }) {
<button
className="px-2 py-1 rounded bg-red-400 text-neutral-900 hover:bg-red-500 text-xs shadow-md cursor-pointer font-bold font-sans"
onClick={() => {
console.debug("Remove button clicked for uuid:", rowData.uuid); // Debugging log
handleRemoveFromQueue(rowData.uuid);
}}
title="Remove"